The Cron That Skipped Every Month With Thirty One Days

The monthly report ran in some months and silently vanished in others, and the pattern was the calendar. A day arithmetic bug that only fires on the 31st is…

Share
The Cron That Skipped Every Month With Thirty One Days. Abstract bug hunt illustration in orange and dark grey on debugly.dev

The finance team noticed the report was missing for March, and the first hypothesis was a deploy, because missing work usually is. But the report had also been missing for January, and present for February, and the deploy history did not line up with either. Someone eventually wrote the months on a whiteboard and the pattern appeared before any code was read: the report ran in months with thirty days and vanished in months with thirty one. The calendar was the reproduction step.

This is the bug hunt for a date arithmetic defect that only fires on the 31st, which makes it one of the most patient bugs there is, surfacing seven times a year and hiding five, and teaching exactly why date math deserves the same suspicion as concurrency.

This was a Node 22.14 job scheduled on the first of the month, computing "the last day of the previous month" with hand rolled arithmetic, and the arithmetic was where the 31st went to die.

The symptom, precisely

The job was meant to run on the first of each month over the previous month's data. It ran in February, April, June, September and November, and skipped January, March, May, July, August, October and December. Wait, that is backwards from the title, and the inversion is the interesting part, so let me state the mechanism and then the observed pattern, because the title describes the data month, not the run month.

The job, on the first of month M, computed the last day of M minus one by constructing a date from the current day number, and on the first that logic is fine, but the code did not use the first. It used "today" at the moment of a retry path that ran on the 31st of the previous month for some months, and the construction "new Date(year, month, 31)" for a month with thirty days rolls over into the next month, producing the first of the following month, which the guard "is this still the previous month" then rejected, and the job exited silently. So the data month with thirty one days was the one whose processing overflowed, and the report for that month never ran. Hence: every month with thirty one days was skipped.

The hypotheses that died

A deploy broke it

First, the deploy log. The missing months predated any recent change, and the present months included months after the suspected deploy. It died because the timeline did not fit.

The scheduler dropped the runs

Next, the cron history. The job had, in the skipped months, actually started and exited zero, cleanly, which is worse than a crash, because an exit zero is a success to every monitor. It died because the scheduler had done its job, and the job had quietly chosen to do nothing.

The data was empty

Then, the data. The skipped months had normal volume. It died because the input was fine, and the job had refused it.

The breakthrough

The breakthrough was reading the guard. The job's first line checked that the computed window belonged to the previous month, and exited zero if not, a defensive check added months earlier to prevent double processing. The defensive check was correct. The date it checked was wrong, because the construction that produced it overflowed on 31 day months, and the correct defence then vetoed the run. The bug was a correct check guarding a wrong value, which is why nothing looked broken, every piece was individually sensible.

This is the date version of the silent cap in the export that always returned nine hundred ninety nine rows: a boundary condition, an exit zero, and no alarm, because the failure mode was designed to be quiet.

Why date arithmetic is a concurrency of calendars

The deeper lesson is that calendar arithmetic is not arithmetic over a number line. Months have different lengths, so "the same day last month" is a partial function, and any code that assumes day numbers carry across month boundaries is assuming a regularity the calendar does not have. The 31st is the undefined behaviour, and February is the trap after it, which is the family of bugs in the cron job that ran thirteen times in one night, where the calendar's irregularity, there a transition, here a month length, defeats code that assumes smoothness.

The fix is never to compute the last day by subtracting from a day number. The last day of the previous month is "the zeroth day of the current month" in most date libraries, or a library call that knows month lengths, and using the library's calendar functions is the same discipline as using parameterised queries: you are declining to reimplement a system that already handles the edge cases.

What I changed

The immediate fix replaced the hand rolled last day computation with the library's month boundary function, and the skipped months were backfilled by hand, with an apology.

The durable fixes were three. First, the job now fails loud on an impossible window: if the computed window does not belong to the previous month, it exits nonzero and pages, because a defensive check that silently exits is a landmine with a polite face, and the veto should be an alarm when it was not expected.

Second, the schedule moved to the second of the month with an explicit window, removing the retry path that ran on the 31st, so the day number the code reasons about is always small and safe, because the cheapest fix for 31 day overflow is to never hold a 31.

Third, a calendar fuzz test: for every month in a twenty year range, assert the job's window equals the library's previous month, which is the property that failed, made into a loop over the very dimension, month length, that hid it.

What I would do differently

I would have treated the silent exit as the defect the moment it was added, because a code path that decides to do nothing and reports success is a path that cannot be monitored, and every defensive veto deserves an else branch that screams when the veto was not anticipated.

I would also have fuzzed the calendar at the time the date code was written, because the calendar is a small, finite, knowable adversary, and a loop over twenty four months catches what five months of production cannot.

The rule

Calendar arithmetic is partial, the 31st is its undefined behaviour, and code that carries day numbers across month boundaries is assuming a regularity the calendar refuses to have. Use the library's month boundary functions, keep the day numbers you reason about small, and make every defensive veto loud when unanticipated, because a silent exit zero on the wrong date is a skipped month with a clean conscience.

The report did not fail. It declined, politely, seven times a year, and the only witness was a whiteboard with the months written in a row, which is the oldest debugging tool there is.


Published on debugly.dev by Rohit Bhadani | Founder of Krova Cloud