DEV Community

Cover image for We had a date bug that happened two times a year, and we didn't know, you might have it too 😱
Zac_A_Clifton for novu

Posted on

We had a date bug that happened two times a year, and we didn't know, you might have it too 😱

TL;DR

Novu's team encountered a significant bug affecting date calculations in their CI/CD pipelines, hindering all deployments.

The issue arose from the date-fns library's addMonths and subMonths functions.

We fixed this by using addDays and subDays functions instead.

Panic Gif


Novu: Open-source notification infrastructure 🚀

Just a quick background about us. Novu is an open-source notification infrastructure. We basically help to manage all the product notifications. It can be In-App (the bell icon like you have in the Dev Community - Websockets), Emails, SMSs and so on.

Novu Request Stars On Github


The Mindset

When working in software development, we're always prepared for bugs to crop up.

Sometimes they're small, easy to identify, and quick to fix.

Other times, they're like this year's candidate for our 'Bug Of The Year'.

This was a bug so elusive and mysterious that it had us rummaging through our pipelines, questioning our code-base, and coming face-to-face with the intricacies of date manipulation.

Problems, Different Problems, and More Problems

Our CI/CD pipelines were failing. Specifically, two tests which were blocking ALL new deployments. It was time to put on our detective hats 🕵️.

We dove into our commit history using git bisect however it offered us no insight. Git bisect took us back to commits that where over 6 months in the past, long before any of our newest changes to the system that would have caused this. Was this bug created at the very beginning of Novu?

However, we did have a clue. Our failing unit tests showed us that we had incorrect date calculations.

Gathering the Clues 💡

Strangely, the difference was just one day.

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
Enter fullscreen mode Exit fullscreen mode

We also found that this does not happen on 31st July.

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
Enter fullscreen mode Exit fullscreen mode

But the bug shows up again January 31st.

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
Enter fullscreen mode Exit fullscreen mode

So this bug only happens when we add 1 month to a month that has more days then the next month and then subtract 1 month to go back to the month before.

This is a sneaky one

So here is what we know so far:

  • It would only show up on systems that does this specific sequence of logic.
  • The code would have to be ran on one of the few dates that are effected.
  • This effect is not documented anywhere on any of the libraries we use.

The worst thing is that this bug is also shows up HR tools, finance tools, salary tools, public government tools all rely on this package but unfortunately it is still better then us making the functions our-self's.

It has been said many times that date-times are among the trickiest aspects of programming, and our current predicament served as a hash reminder.

This is tough GIF

Why a simple actions can lead to bad things

After finding this out, we had a 'Eureka!' moment.
Our CTO, Dima Grossman, then had the idea to try it it on raycast. Interestingly enough it was happening in their product too.

Showing Raycast also uses date-fns

Mind Blown Gif

We realized that the issue stemmed from being on the last day of the month, but what exactly was going awry?

The Culprit:

date-fns icon

This popular utility library for date operations was at the heart of the problem.

Specifically, the addMonths and subMonths functions.

The addMonths function, when adding a month to the last day of any given month, would take you to the last day of the following month. Logical, right?

// 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
  }
Enter fullscreen mode Exit fullscreen mode

But the subMonths function, rather than having its own dedicated logic, simply reused addMonths with a negative number. D.R.Y principles in action, but with an unintended consequence.

// 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)
}
Enter fullscreen mode Exit fullscreen mode

Here is what exactly caused our issue

Let's put it this way:

  • For 28th February, add one month and then subtract one month, and you get 28th February. No problems there.
  • But, for 31st August, add one month and then subtract one month, and you land on... 30th August. That's one day lost in date limbo!

The core of the issue was the way addMonths determined the end of the desired month.

For days that were not at the end of the month, the logic was sound.

However, for the last day of a month, the function defaulted to the end of the next month instead of adding the correct amount of days.

The Simple Fix

To ensure a consistent approach to date manipulation, we shifted from using addMonths and subMonths to addDays and subDays.

Quicly Coding Cat Gif

This provided a more granular and precise way to handle date calculations, and importantly, allowed us to sidestep the addMonths pitfall.

Lessons Learnt

This bug served as a strong lesson in a few key areas:

  1. Assumptions are Risky: Never assume that widely-used libraries are infallible. Even the most popular ones have their quirks.
  2. Tests are Gold: If not for our rigorous testing suite, this bug might have remained hidden, only to wreak havoc at the most inopportune moment.
  3. Dates are Tricky: They've always been, and will continue to be, a challenging aspect of software development. Always handle with care.

While this bug threw a wrench in our pipes, it also reinforced the importance of comprehensive tests and the need to continually question and challenge our assumptions.

Death of this Bug

In a world of code where dates and times form such a crucial part of our applications, bugs like these provide not just a hiccup, but a learning opportunity. The next time you find a weird issue in your application, dig deep. Who knows, you might just uncover the next 'Bug Of The Year'.

Bug Goodbye Gif

You can find the PRs and Issues here:

Oldest comments (44)

Collapse
 
cliftonz profile image
Zac_A_Clifton

Have you had a bug with dates before?

Collapse
 
combarnea profile image
Tomer Barnea novu

The question should be if you ever wrote something with dates with no bugs at all????

Collapse
 
cliftonz profile image
Zac_A_Clifton

That is so true, even the best of us get tripped up by this.

Collapse
 
rockykev profile image
Rocky Kev

Two hardest things in programming: Cache invalidation and naming things.

I think we should add time, timezones and dates.

Collapse
 
darkwiiplayer profile image
𒎏Wii 🏳️‍⚧️

Not quite dates, but arguably even more strange: Lua is a very small language, with a relatively simple model for handling dates, and sometimes you need to get a bit hackey to deal with time zones.

Well, luckily it just uses the C time/date functions, which are clearly defined, so you can at least rely on your hacks to work everywhe— hold up a second, you didn't consider windows.

Turns out microsoft's fuck-up of a C compiler knows better than the spec, so on any Lua version compiled using it, you cannot get the current time zone as a numeric offset, because the library function just returns some other short string instead.

This took way too long to debug, and when I figured out the cause was that microsoft simply does what microsoft wants, I felt like throwing my PC out the window.

Collapse
 
cliftonz profile image
Zac_A_Clifton

I think most people feel the same way about microsoft.

and, by the way, I enjoyed reading this story!

Collapse
 
cliftonz profile image
Zac_A_Clifton

How worried are you about date bugs coming up in your product?

Collapse
 
robinamirbahar profile image
Robina

Awesome

Collapse
 
empe profile image
Emil Pearce novu

Two key takeaways:

Never Assume: Even popular libraries can have hidden pitfalls. Always question, regardless of its reputation.

Testing is Crucial: Your robust tests highlighted a significant issue, emphasizing the need for thorough testing in software development.

Thanks for sharing this valuable lesson! Here's to more bug-free coding! 🥂

Collapse
 
cliftonz profile image
Zac_A_Clifton

These are great takeaways, thank you for sharing!

Collapse
 
poosham profile image
Jean-Philippe Green

Doesn't this happen for Mars, May and October too? So 5 times a year rather than 2

Collapse
 
darkwiiplayer profile image
𒎏Wii 🏳️‍⚧️

I'm sorry for the off-topic, but the joke just has to be made:

Two dates a year is a problem? I'm lucky if I even get that many... 🤭

Collapse
 
nevodavid profile image
Nevo David

🤣

Collapse
 
artxe2 profile image
Yeom suyun

The bug does not occur in the Date object of v8.
I like to leverage the basic features of the JS engine.

Collapse
 
cliftonz profile image
Zac_A_Clifton

That is very true, I do not know why we choose this library but it does provide a lot of good utilities to leverage.

Collapse
 
oculus42 profile image
Samuel Rouse

The native Date object has plenty of complexity to be aware of, too. Entering "equivalent" date strings do not always produce the same result:

// ISO Assumes UTC Timezone, so we get Midnight UTC
const dateFromISO = new Date('2023-09-14');
// 'Wed Sep 13 2023 20:00:00 GMT-0400 (Eastern Daylight Time)'

// Other formats assume local time zone so I get Midnight Eastern
const dateFromMDY = new Date('9/14/2023');
const dateFromElements = new Date(2023, 8, 14);
// 'Thu Sep 14 2023 00:00:00 GMT-0400 (Eastern Daylight Time)'
Enter fullscreen mode Exit fullscreen mode

This can be a real problem when parsing user-entered dates... some regions/people use the YYYY-MM-DD format for dates, which means you can be many hours off from the expected time.

Collapse
 
artxe2 profile image
Yeom suyun

The default Date object is indeed complex, and this is also a problem of ambiguity that exists throughout JavaScript.
However, I think the confusion that JavaScript's Date object gives to developers mostly comes from time zones and the conversion between strings and Dates.
If only these three problems could be solved, wouldn't we be able to get a lot of benefits from the basic features of JS?
For example, you can use a simple string_to_date function to solve the problems you mentioned, as follows.

function string_to_date(date, format = "YYYY-MM-DDTHH:mm:ss.sss") => {
    let x = format.indexOf("YYYY")
    const year = x < 0 ? "0000" : date.slice(x, x + 4)
    x = format.indexOf("MM")
    const month = x < 0 ? "00" : date.slice(x, x + 2)
    x = format.indexOf("DD")
    const day = x < 0 ? "00" : date.slice(x, x + 2)
    x = format.indexOf("HH")
    const hours = x < 0 ? "00" : date.slice(x, x + 2)
    x = format.indexOf("mm")
    const minutes = x < 0 ? "00" : date.slice(x, x + 2)
    x = format.indexOf("ss")
    const seconds = x < 0 ? "00" : date.slice(x, x + 2)
    x = format.indexOf("sss")
    const milliseconds = x < 0 ? "000" : date.slice(x, x + 3)
    return new Date(`${year}-${month}-${day}T${hours}:${minutes}:${seconds}.${milliseconds}`)
}
Enter fullscreen mode Exit fullscreen mode
Collapse
 
taikedz profile image
TaiKedz

Date-math is non-trivial, month-math especially so.

What does "add one month" (or subtract) actually mean? In the case of this library, it's "jump to the same day number ahead/back". Adding/subtracting 30 would also be error-prone.

So your takeaways are spot on, though I'd also add this caveat:

When something isn't of a given fixed quantity, beware abstractions that treat them as if they were...!

Collapse
 
cliftonz profile image
Zac_A_Clifton

Great Insight!

Collapse
 
syeo66 profile image
Red Ochsenbein (he/him)

Well, Dates are hard...

Collapse
 
cliftonz profile image
Zac_A_Clifton

Exactly

Collapse
 
syeo66 profile image
Red Ochsenbein (he/him)

Now, also try to deal with timezones and daylight saving, and leap seconds as well. There is a huge rabbit hole to go down and no right or wrong, but expectations and solutions on a case by case basis.

Collapse
 
cliftonz profile image
Zac_A_Clifton

Absolutely, we are looking at implementing timezone aware notifications for our users and it adds a plethora of complexity.

Collapse
 
sang profile image
Sang

If you have ever had a problem with timezone, espescially DST and timezone stuff. You can try this: dev.to/sang/javascript-zoned-date-...