拉取请求 · Birchline

#312 — 把通知投递改造成队列

9 个文件 +418 / −190 分支 notify-queuemain 作者 @priya
提示词 帮我给评审者写一份 PR #312 的说明。讲清动机,带他们逐文件过一遍改动并说明每处的原因,对比改动前后的行为,然后明确告诉他们重点看哪里。他们已经半年没碰通知相关代码了。
一句话总结

通知发送之前是内联在请求链路里跑的。一上量就给写操作增加 200–800 ms 的延迟,SMTP 连接池一打满还会悄悄丢邮件。这个 PR 把投递挪到已有的 pg-boss 队列上,API 立即返回,失败的发送会按退避策略重试。

为什么要改

上个季度我们在评论里加了 @提及功能,每次提及都会在保存评论的同一个事务里触发最多三次发送(站内、邮件、Slack)。刚上线时还好,但现在一条任务更新会扇出给四十个关注者,这就撑不住了。

改造前
  • 发送内联在写操作的处理器里
  • SMTP 超时 = 评论本身报 500
  • 不重试——丢了的邮件就是丢了
  • comments.create 的 p99:1.4 s
改造后
  • 处理器为每个收件人入队一个任务,立刻返回
  • Worker 按指数退避重试 3 次
  • 用尽重试后落入死信表,留作排查
  • comments.create 的 p99:180 ms(预发环境)

逐文件过一遍

按阅读顺序排,不是按字母序。先看 worker——这是新引入的部分——再看入队调用点,最后是配套改动。

packages/notify/src/worker.ts 新增 +126

这个 PR 的核心。一个 pg-boss 订阅者,拉取 notify.deliver 任务,解析用户的渠道偏好,然后调用对应的适配器。重试按渠道分别配置——邮件重试三次,Slack 只重试一次,因为它的 API 在我们这边已经是幂等的。

boss.work('notify.deliver', { batchSize: 20 }, async (jobs) => {
  for (const job of jobs) {
    const { userId, event, channel } = job.data;
    const prefs = await getPrefs(userId);
    if (!prefs[channel]) return;          // 用户屏蔽了该渠道

    try {
      await adapters[channel].send(userId, event);
    } catch (err) {
      if (job.retryCount >= MAX_RETRY[channel]) {
        await deadLetter(job, err);         // 不抛出 —— ack 后暂存
        return;
      }
      throw err;                            // 让 pg-boss 重新调度
    }
  }
});
packages/api/src/routers/comments.ts 改动 +14 −62

收益落地的地方。这个写操作以前会直接调用 sendEmailsendSlackcreateInApp。现在它只插入评论、算出收件人,然后入队。一锅 try/catch 也没了。

  const comment = await db.comments.insert(input);
  const recipients = await resolveWatchers(input.taskId, input.mentions);

  for (const r of recipients) {
    await sendEmail(r, comment);       // 阻塞了响应
    await sendSlack(r, comment);
  }
  await boss.insert(recipients.flatMap((r) =>
    CHANNELS.map((ch) => ({
      name: 'notify.deliver',
      data: { userId: r.id, channel: ch, event: toEvent(comment) },
      singletonKey: `${comment.id}:${r.id}:${ch}`,  // 幂等
    }))));
  return comment;
packages/db/migrations/0051_dead_letter.sql 新增 +22

用尽重试的任务落到这张表。我故意没有设自动清理——在我们对新链路有信心之前,每周都得翻一翻。表里有完整的任务载荷和最后一次的错误信息。

packages/notify/src/adapters/{email,slack,inapp}.ts 改动 +88 −74

大部分是搬位置。每个适配器现在都实现统一的 Adapter 接口,并抛出带类型的 RetryableErrorPermanentError,让 worker 知道要不要重试。邮件适配器同时去掉了自己的重试循环——重试现在归队列管,四月份那次重复邮件就是双重重试惹的祸。

apps/worker/src/index.ts, infra/fly.toml 改动 +31 −4

在已有的 worker 进程里注册新订阅者,并把并发数从 5 提到 20。不需要新的部署单元。

packages/notify/src/__tests__/worker.test.ts 新增 +137

覆盖了重试边界、死信路径、渠道屏蔽和 singleton key 去重。直接用真实的 pg-boss 跑在测试库上——上个季度那次 mock 队列测试都过了,结果生产环境的顺序却挂了,我们已经被坑过一次。

评审重点看哪里

1
重试 / 死信的分界线
worker.ts:31–44。捕获异常后我会检查 retryCount,要么暂存要么抛出。这段逻辑写错了,要么无限重试要么丢消息——而这两种故障模式正是这个 PR 要解决的。
2
singleton key
comments.ts:28${commentId}:${userId}:${channel} 应该能让 API 处理器重试时的重复入队保持幂等。请帮我兜底确认一下,它在不同任务之间不会冲突。
3
我故意没做的事
没做按用户聚合的摘要批次,没做投递回执,也没做优先级通道。这三件事都能干净地叠在这个 PR 之上;硬塞进来的话就没法评审了。

测试计划

单测:重试 → 死信路径、渠道屏蔽、singleton 去重
packages/notify —— 14 个用例,跑在测试库的真实 pg-boss 上
集成:建一条带 3 个关注者的评论,断言 9 个任务入队并消费完毕
预发压测:comments.create 持续 10 分钟 500 rps,p99 = 180 ms
改造前是 1.4 s —— dashboard 链接在 PR 描述里
手测:在压测高峰期 kill 掉 SMTP,确认任务落到死信且不报 500
放到 10% 灰度阶段做

放量节奏

notify_queue_v2 特性开关挡住。老的内联路径还留在代码库里,死着但保留一个版本,万一需要切回。

第 0 天
内部
仅 Birchline 团队。盯死信表和 worker 错误率。
第 2 天
10%
随机抽样。死信率超过发送量 0.5% 就告警。
第 4 天
100%
全量放开。下周再发一个跟进 PR 删掉内联路径。