我们每年都会遇到两次约会问题,但我们不知道,你可能也会遇到这种情况😱
TL;DR
Novu 团队遇到了一个影响其 CI/CD 管道中的日期计算的重大错误,阻碍了所有部署。
该问题源于 date-fns 库的 addMonths 和 subMonths 函数。
我们通过使用 addDays 和 subDays 函数解决了这个问题。
Novu:开源通知基础设施🚀
简单介绍一下我们。Novu 是一个开源通知基础设施。我们主要负责管理所有产品通知。通知可以是应用内通知(类似于开发者社区的 Websockets 中的铃铛图标)、电子邮件、短信等等。
心态
在从事软件开发时,我们总是做好应对错误出现的准备。
有时它们很小,容易识别,而且可以快速修复。
其他时候,它们就像我们今年的“年度虫子”候选人。
这是一个如此难以捉摸和神秘的错误,它让我们仔细检查我们的管道,质疑我们的代码库,并面对日期操作的复杂性。
问题、各种问题、以及更多问题
我们的 CI/CD 流水线出问题了。具体来说,有两个测试阻塞了所有新的部署。是时候戴上我们的侦探帽了🕵️。
我们深入研究了提交历史记录,git bisect
但一无所获。Git bisect 却让我们回溯到 6 个月前的一些提交,这比我们对系统进行的任何最新更改(可能导致此问题)都要早得多。这个 bug 是在 Novu 刚开始的时候产生的吗?
然而,我们确实有一个线索。我们失败的单元测试表明我们的日期计算不正确。
收集线索💡
奇怪的是,差别仅仅只有一天。
const startDate = new Date("2023-08-31");
const oneMonthAhead = addMonths(startDate, 1);
const result = subMonths(oneMonthAhead, 1);
console.log(result); // Expected: 31st of August, Reality: 30th of August
我们还发现 7 月 31 日不会发生这种情况。
const startDate = new Date("2023-07-31");
const oneMonthAhead = addMonths(startDate, 1);
const result = subMonths(oneMonthAhead, 1);
console.log(result); // Expected: 31st of July, Reality: 31th of July
但该漏洞于 1 月 31 日再次出现。
const startDate = new Date("2023-01-31");
const oneMonthAhead = addMonths(startDate, 1);
const result = subMonths(oneMonthAhead, 1);
console.log(result); // Expected: 31st of January, Reality: 28th of January
因此,只有当我们将 1 个月添加到比下个月天数更多的月份,然后减去 1 个月回到上一个月时,才会发生此错误。
这是一个偷偷摸摸的
以下是我们目前所知道的情况:
- 它只会出现在执行此特定逻辑序列的系统上。
- 该代码必须在受影响的少数日期之一运行。
- 我们使用的任何库中都没有记录这种影响。
最糟糕的是,这个错误也出现了,人力资源工具、财务工具、薪酬工具、公共政府工具都依赖于这个包,但不幸的是,它仍然比我们自己制作功能要好。
人们曾多次说过,日期时间是编程中最棘手的方面之一,而我们当前的困境就是一个警示。

为什么简单的行为会导致坏事
发现这一点后,我们顿时灵光一闪。
我们的首席技术官 Dima Grossman 突然想到在raycast上尝试一下。有趣的是,他们的产品也实现了类似的功能。

我们意识到问题出在月底的最后一天,但究竟出了什么问题呢?
罪魁祸首:
这个流行的日期操作实用程序库是问题的核心。
具体来说,addMonths
和subMonths
功能。
这个addMonths
函数,当在任意月份的最后一天添加一个月份时,会返回下一个月份的最后一天。合乎逻辑吧?
// source: https://github.com/date-fns/date-fns/blob/main/src/addMonths/index.ts
const daysInMonth = endOfDesiredMonth.getDate()
if (dayOfMonth >= daysInMonth) {
// If we're already at the end of the month, then this is the correct date
// and we're done.
return endOfDesiredMonth
} else {
// Otherwise, we now know that setting the original day-of-month value won't
// cause an overflow, so set the desired day-of-month. Note that we can't
// just set the date of `endOfDesiredMonth` because that object may have had
// its time changed in the unusual case where where a DST transition was on
// the last day of the month and its local time was in the hour skipped or
// repeated next to a DST transition. So we use `date` instead which is
// guaranteed to still have the original time.
_date.setFullYear(
endOfDesiredMonth.getFullYear(),
endOfDesiredMonth.getMonth(),
dayOfMonth
)
return _date
}
但是,该subMonths
函数并没有自己专属的逻辑,只是简单地重复使用了addMonths
负数。DRY 原则确实在发挥作用,但却带来了意想不到的后果。
// source: https://github.com/date-fns/date-fns/blob/main/src/subMonths/index.ts
export default function subMonths<DateType extends Date>(
date: DateType | number,
amount: number
): DateType {
return addMonths(date, -amount)
}
以下是导致我们出现问题的原因
让我们这样说吧:
- 对于 2 月 28 日,加一个月,再减一个月,结果就是 2 月 28 日。这样就没问题了。
- 但是,8月31日,加一个月,减一个月,结果就是……8月30日。这真是日期错失的一天!
问题的核心是如何addMonths
确定期望月份的结束时间。
对于那些不在月底的日子来说,这个逻辑是合理的。
但是,对于一个月的最后一天,该函数默认为下个月的结束,而不是添加正确的天数。
简单的解决方法
为了确保日期操作方法的一致性,我们从使用addMonths
和subMonths
转变为使用addDays
和subDays
。

这提供了一种更细致、更精确的方法来处理日期计算,而且重要的是,让我们避开了addMonths
陷阱。
经验教训
这个漏洞在几个关键领域给我们上了深刻的教训:
- 假设有风险:永远不要假设广泛使用的库是绝对可靠的。即使是最流行的库也会有其自身的缺陷。
- 测试是黄金:如果没有我们严格的测试套件,这个错误可能仍然隐藏,只会在最不合时宜的时刻造成严重破坏。
- 日期是个棘手的问题:日期一直是软件开发中一个具有挑战性的方面,并且将继续如此。务必谨慎处理。
虽然这个错误给我们带来了麻烦,但它也强调了全面测试的重要性以及不断质疑和挑战我们的假设的必要性。
这个 Bug 的消亡
在代码的世界里,日期和时间是我们应用程序的重要组成部分,像这样的 bug 不仅会带来小问题,更是一个学习的机会。下次你在应用程序中发现奇怪的问题时,一定要深入挖掘。说不定,你就会发现下一个“年度 Bug”。

您可以在这里找到 PR 和问题:
文章来源:https://dev.to/novu/we-had-a-date-bug-that-happened-two-times-a-year-and-we-didnt-know-you-might-have-it-too-56o6