refactor: rewrite matcher-walker to infer next run from cron fields by merencia · Pull Request #533 · node-cron/node-cron · GitHub
Skip to content

refactor: rewrite matcher-walker to infer next run from cron fields#533

Merged
merencia merged 4 commits into
mainfrom
refactor/matcher-walker
Jun 16, 2026
Merged

refactor: rewrite matcher-walker to infer next run from cron fields#533
merencia merged 4 commits into
mainfrom
refactor/matcher-walker

Conversation

@merencia

Copy link
Copy Markdown
Member

Fixes #518.

Problem

The field-by-field walker resolved a non-existent local time (DST spring-forward gap) forward and carried the drifted hour as it advanced day/month/year. 30 2 * * * in America/New_York returned 2027-01-01 (the 02:xx hour does not exist on the transition day), and the final fallthrough returned the date without re-checking that it matched.

Approach

Infer the next run from the fields instead of scanning time tick by tick:

  • Walk the calendar day by day (a Y/M/D is the same weekday in every timezone).
  • On each day that matches month and day-of-month, try the matching times in ascending order.
  • Materialize each candidate to a real instant in the timezone and confirm it with the real matcher (match()).

The matcher enforces the weekday constraint and rejects DST-gap times by construction (a wall-clock that does not exist materializes to a different instant whose hour will not match), so a gap day is skipped to the next day where the time exists. No date is ever returned unverified. The search is bounded (~10 years of days) and throws for impossible expressions like 0 0 31 2 *.

Verified

merencia added 4 commits June 16, 2026 14:14
Fixes #518.

The old field-by-field walker resolved a non-existent local time (the DST
spring-forward gap) forward, then carried the drifted hour as it advanced
day/month/year. That overshot ~10 months and returned a date that did not even
match (e.g. "30 2 * * *" in America/New_York returned 2027-01-01).

Rewrite getNextMatch as infer + verify: walk the calendar day by day, and on
each day that matches month and day-of-month try the matching times in
ascending order, materializing each to a real instant in the timezone and
confirming it with the real matcher. The matcher enforces the weekday
constraint and rejects DST-gap times by construction, so a gap day is skipped
to the next day where the time exists. No date is returned unverified.

Exposes localTimeToTimestamp from localized-time for the walker. Adds
regression tests for the gap-daily case (#518), weekday overshoot (#510),
Feb 29 (multi-year jump) and skipped day-31 months.
- Sort the time fields once in the constructor instead of per qualifying day.
- Raise the calendar search bound to 100 years so valid-but-sparse expressions
  (e.g. Feb 29 on a specific weekday, ~28y apart) resolve instead of throwing;
  the loop is cheap, so the impossible-expression case stays fast.
- Add tests: impossible expression throws, and the fall-back repeated hour
  returns the first occurrence.
…ons field

Replace the three inline lower-bound guards with a single named predicate
(isLaterInDay) plus the cheap whole-hour skip, and remove the now-unused public
`expressions` field (the sorted private fields cover every use). No behavior
change.
…mestamp

The rewritten walker calls localTimeToTimestamp directly and never mutates a
LocalizedTime, so set() had no remaining caller. Remove it and add direct unit
tests for localTimeToTimestamp covering the three DST cases (normal, the
non-existent spring-forward gap resolved forward, and the ambiguous fall-back
resolved to the first occurrence). Restores localized-time.ts to 100% line and
function coverage.
@merencia merencia merged commit 58cabac into main Jun 16, 2026
3 checks passed
@merencia merencia mentioned this pull request Jun 17, 2026
@merencia merencia deleted the refactor/matcher-walker branch June 17, 2026 17:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

getNextMatch overshoots ~1 year for a daily schedule whose time falls in the spring-forward gap

1 participant