Skip to content

Conversation

@asuhag113
Copy link

Overview

This PR fixes an issue where using the CronExpressionParser to parse the next scheduled time yielded an Out of the timespan range error when currentDate and endDate where milliseconds apart. Since cron expressions don't need to worry about milliseconds and we set milliseconds to 0 for other addUnit operations, it seems reasonable to apply this to the seconds operations as well.

Issue

It appears that when using addSecond to go to the next time candidate, the milliseconds are preserved. This causes potential candidates to be skipped over in specific cases like when trying to schedule every minute.

Failing example

import {CronExpressionParser} from "cron-parser";

const scheduledTime = new Date("2025-08-21T17:16:00.000Z");
const cron = "* * * * *";

// 2025-08-21T17:15:59.999Z
const instantBeforeTrigger = new Date(
  scheduledTime.getTime() - 1,
).toISOString();

// 2025-08-21T17:16:00.001Z
const instantAfterTrigger = new Date(scheduledTime.getTime() + 1).toISOString();

// Expected: 2025-08-21T17:16:00.000Z, Got: Out of the timespan range
const nextTime = CronExpressionParser.parse(cron, {
  currentDate: instantBeforeTrigger,
  endDate: instantAfterTrigger,
  tz: "America/New_York",
})
  .next()
  .toISOString();

@harrisiirak
Copy link
Owner

Hi @asuhag113!

Thanks for opening a PR!

startOf second was initially removed due some performance concerns. This can be also verified by running the npm run bench script.

I'm already working on this as there are couple of related issues (that proposed approach may fix or not) as #273, #378 and #385

Haven't had time to put together the proper fix.

@asuhag113
Copy link
Author

startOf second was initially removed due some performance concerns. This can be also verified by running the npm run bench script.

Hey @harrisiirak, thanks for the quick response! My bad on missing the bench.

Hmm, it definitely seems to have a bit of a surprising performance impact.

FWIW, I also tried something like:

 addSecond(): void {
    const currentMs = this.#date.valueOf();
    const nextSecondMs = Math.floor(currentMs / 1000) * 1000 + 1000;
    this.#date = DateTime.fromMillis(nextSecondMs, { zone: this.#date.zone });
}

Which seemed to also resolve the issue and pass the tests.

Just gonna drop some benchmarks below that may be helpful

Original code (before PR)

┌────────────────────┬────────────┬────────────┬──────────┬─────┐
│ Pattern            │   Old Mean │   New Mean │   Change │     │
├────────────────────┼────────────┼────────────┼──────────┼─────┤
│ * * * * * *        │    38.25ms │    37.36ms │    2.32% │  ↑  │
├────────────────────┼────────────┼────────────┼──────────┼─────┤
│ 0 * * 1,4-10,L * * │  1558.52ms │  1566.78ms │   -0.53% │  ↓  │
├────────────────────┼────────────┼────────────┼──────────┼─────┤
│ 10-30/2 2 12 8 0   │  1724.75ms │  1735.82ms │   -0.64% │  ↓  │
├────────────────────┼────────────┼────────────┼──────────┼─────┤
│ 10 2 12 8 7        │  4285.93ms │  4315.61ms │   -0.69% │  ↓  │
├────────────────────┼────────────┼────────────┼──────────┼─────┤
│ 0 12 */5 6 *       │  4075.70ms │  4108.17ms │   -0.80% │  ↓  │
├────────────────────┼────────────┼────────────┼──────────┼─────┤
│ 0 0 0 * * 1L,5L    │  4523.04ms │  4563.98ms │   -0.91% │  ↓  │
├────────────────────┼────────────┼────────────┼──────────┼─────┤
│ 0 H/3 * * *        │  3379.55ms │  3418.83ms │   -1.16% │  ↓  │
├────────────────────┼────────────┼────────────┼──────────┼─────┤
│ 0 0 6-20/2,L 2 *   │  4237.37ms │  4297.90ms │   -1.43% │  ↓  │
├────────────────────┼────────────┼────────────┼──────────┼─────┤
│ H H H(9-20)/3 1-1… │  4239.36ms │  4300.46ms │   -1.44% │  ↓  │
├────────────────────┼────────────┼────────────┼──────────┼─────┤
│ 0 H * * *          │  4026.29ms │  4095.53ms │   -1.72% │  ↓  │
├────────────────────┼────────────┼────────────┼──────────┼─────┤
│ 0 15 */5 5 *       │  4012.52ms │  4091.21ms │   -1.96% │  ↓  │
├────────────────────┼────────────┼────────────┼──────────┼─────┤
│ 0 0 0 * * 4,6L     │  4226.37ms │  4391.83ms │   -3.91% │  ↓  │
└────────────────────┴────────────┴────────────┴──────────┴─────┘

Current PR

┌────────────────────┬────────────┬────────────┬──────────┬─────┐
│ Pattern            │   Old Mean │   New Mean │   Change │     │
├────────────────────┼────────────┼────────────┼──────────┼─────┤
│ * * * * * *        │    47.71ms │    44.80ms │    6.11% │  ↑  │
├────────────────────┼────────────┼────────────┼──────────┼─────┤
│ 10 2 12 8 7        │  4596.87ms │  4790.99ms │   -4.22% │  ↓  │
├────────────────────┼────────────┼────────────┼──────────┼─────┤
│ 0 12 */5 6 *       │  4325.40ms │  4570.92ms │   -5.68% │  ↓  │
├────────────────────┼────────────┼────────────┼──────────┼─────┤
│ 0 15 */5 5 *       │  4174.46ms │  4423.72ms │   -5.97% │  ↓  │
├────────────────────┼────────────┼────────────┼──────────┼─────┤
│ 0 0 0 * * 1L,5L    │  4561.86ms │  4868.22ms │   -6.72% │  ↓  │
├────────────────────┼────────────┼────────────┼──────────┼─────┤
│ 0 0 0 * * 4,6L     │  4181.95ms │  4466.24ms │   -6.80% │  ↓  │
├────────────────────┼────────────┼────────────┼──────────┼─────┤
│ 0 H/3 * * *        │  3409.87ms │  3663.37ms │   -7.43% │  ↓  │
├────────────────────┼────────────┼────────────┼──────────┼─────┤
│ H H H(9-20)/3 1-1… │  4186.07ms │  4509.18ms │   -7.72% │  ↓  │
├────────────────────┼────────────┼────────────┼──────────┼─────┤
│ 0 H * * *          │  3996.01ms │  4347.56ms │   -8.80% │  ↓  │
├────────────────────┼────────────┼────────────┼──────────┼─────┤
│ 0 0 6-20/2,L 2 *   │  4242.41ms │  4627.44ms │   -9.08% │  ↓  │
├────────────────────┼────────────┼────────────┼──────────┼─────┤
│ 10-30/2 2 12 8 0   │  1857.12ms │  2144.99ms │  -15.50% │  ↓  │
├────────────────────┼────────────┼────────────┼──────────┼─────┤
│ 0 * * 1,4-10,L * * │  1574.39ms │  1847.16ms │  -17.33% │  ↓  │
└────────────────────┴────────────┴────────────┴──────────┴─────┘

New rewrite (removing luxon plus and startOf)

┌────────────────────┬────────────┬────────────┬──────────┬─────┐
│ Pattern            │   Old Mean │   New Mean │   Change │     │
├────────────────────┼────────────┼────────────┼──────────┼─────┤
│ 0 * * 1,4-10,L * * │  1707.25ms │   543.65ms │   68.16% │  ↑  │
├────────────────────┼────────────┼────────────┼──────────┼─────┤
│ 10-30/2 2 12 8 0   │  1953.75ms │   814.06ms │   58.33% │  ↑  │
├────────────────────┼────────────┼────────────┼──────────┼─────┤
│ * * * * * *        │    38.38ms │    19.48ms │   49.24% │  ↑  │
├────────────────────┼────────────┼────────────┼──────────┼─────┤
│ 0 H * * *          │  4548.65ms │  3074.87ms │   32.40% │  ↑  │
├────────────────────┼────────────┼────────────┼──────────┼─────┤
│ 0 H/3 * * *        │  3415.67ms │  2324.39ms │   31.95% │  ↑  │
├────────────────────┼────────────┼────────────┼──────────┼─────┤
│ 0 12 */5 6 *       │  4426.85ms │  3246.49ms │   26.66% │  ↑  │
├────────────────────┼────────────┼────────────┼──────────┼─────┤
│ 0 0 0 * * 4,6L     │  4431.89ms │  3255.19ms │   26.55% │  ↑  │
├────────────────────┼────────────┼────────────┼──────────┼─────┤
│ 10 2 12 8 7        │  4558.26ms │  3383.98ms │   25.76% │  ↑  │
├────────────────────┼────────────┼────────────┼──────────┼─────┤
│ 0 0 0 * * 1L,5L    │  5109.16ms │  3795.60ms │   25.71% │  ↑  │
├────────────────────┼────────────┼────────────┼──────────┼─────┤
│ H H H(9-20)/3 1-1… │  4340.67ms │  3229.53ms │   25.60% │  ↑  │
├────────────────────┼────────────┼────────────┼──────────┼─────┤
│ 0 0 6-20/2,L 2 *   │  4586.11ms │  3487.95ms │   23.95% │  ↑  │
├────────────────────┼────────────┼────────────┼──────────┼─────┤
│ 0 15 */5 5 *       │  4117.10ms │  3322.04ms │   19.31% │  ↑  │
└────────────────────┴────────────┴────────────┴──────────┴─────┘

Replacing only startOf (with Math.ceil)

┌────────────────────┬────────────┬────────────┬──────────┬─────┐
│ Pattern            │   Old Mean │   New Mean │   Change │     │
├────────────────────┼────────────┼────────────┼──────────┼─────┤
│ 0 H/3 * * *        │  3450.30ms │  3534.11ms │   -2.43% │  ↓  │
├────────────────────┼────────────┼────────────┼──────────┼─────┤
│ 0 0 0 * * 1L,5L    │  4540.36ms │  4676.26ms │   -2.99% │  ↓  │
├────────────────────┼────────────┼────────────┼──────────┼─────┤
│ 0 0 6-20/2,L 2 *   │  4276.17ms │  4417.40ms │   -3.30% │  ↓  │
├────────────────────┼────────────┼────────────┼──────────┼─────┤
│ 0 12 */5 6 *       │  4131.12ms │  4272.83ms │   -3.43% │  ↓  │
├────────────────────┼────────────┼────────────┼──────────┼─────┤
│ 0 0 0 * * 4,6L     │  4152.23ms │  4317.19ms │   -3.97% │  ↓  │
├────────────────────┼────────────┼────────────┼──────────┼─────┤
│ 10 2 12 8 7        │  4309.94ms │  4482.72ms │   -4.01% │  ↓  │
├────────────────────┼────────────┼────────────┼──────────┼─────┤
│ 0 15 */5 5 *       │  4050.84ms │  4227.53ms │   -4.36% │  ↓  │
├────────────────────┼────────────┼────────────┼──────────┼─────┤
│ H H H(9-20)/3 1-1… │  4132.22ms │  4323.09ms │   -4.62% │  ↓  │
├────────────────────┼────────────┼────────────┼──────────┼─────┤
│ 0 H * * *          │  4032.72ms │  4219.01ms │   -4.62% │  ↓  │
├────────────────────┼────────────┼────────────┼──────────┼─────┤
│ * * * * * *        │    38.18ms │    40.37ms │   -5.74% │  ↓  │
├────────────────────┼────────────┼────────────┼──────────┼─────┤
│ 0 * * 1,4-10,L * * │  1602.00ms │  1751.07ms │   -9.31% │  ↓  │
├────────────────────┼────────────┼────────────┼──────────┼─────┤
│ 10-30/2 2 12 8 0   │  1722.00ms │  1898.36ms │  -10.24% │  ↓  │
└────────────────────┴────────────┴────────────┴──────────┴─────┘

Finally, I also tried a small change to findSchedule where I move the conditional that sets ms to 0 to the beginning plus some handling for going in reverse.

#findSchedule(reverse = false): CronDate {
    const dateMathVerb: DateMathOp = reverse ? DateMathOp.Subtract : DateMathOp.Add;
    const currentDate = new CronDate(this.#currentDate);
+
+   // Preserve milliseconds for reverse scheduling
+   const hadMilliseconds = currentDate.getMilliseconds() !== 0;
+
+   if (currentDate.getMilliseconds() !== 0) {
+     currentDate.setMilliseconds(0);
+   }

    const startTimestamp = currentDate.getTime();
    let stepCount = 0;

    while (++stepCount < LOOP_LIMIT) {
      this.#validateTimeSpan(currentDate);

      if (!this.#matchDayOfMonth(currentDate)) {
        currentDate.applyDateOperation(dateMathVerb, TimeUnit.Day, this.#fields.hour.values.length);
        continue;
      }
      if (
        !(this.#fields.dayOfWeek.nthDay <= 0 || Math.ceil(currentDate.getDate() / 7) === this.#fields.dayOfWeek.nthDay)
      ) {
        currentDate.applyDateOperation(dateMathVerb, TimeUnit.Day, this.#fields.hour.values.length);
        continue;
      }
      if (!CronExpression.#matchSchedule(currentDate.getMonth() + 1, this.#fields.month.values)) {
        currentDate.applyDateOperation(dateMathVerb, TimeUnit.Month, this.#fields.hour.values.length);
        continue;
      }
      if (!this.#matchHour(currentDate, dateMathVerb, reverse)) {
        continue;
      }
      if (!CronExpression.#matchSchedule(currentDate.getMinutes(), this.#fields.minute.values)) {
        currentDate.applyDateOperation(dateMathVerb, TimeUnit.Minute, this.#fields.hour.values.length);
        continue;
      }
      if (!CronExpression.#matchSchedule(currentDate.getSeconds(), this.#fields.second.values)) {
        currentDate.applyDateOperation(dateMathVerb, TimeUnit.Second, this.#fields.hour.values.length);
        continue;
      }

      if (startTimestamp === currentDate.getTime()) {
-      if (dateMathVerb === 'Add' || currentDate.getMilliseconds() === 0) {
+     if (dateMathVerb === 'Add' || !hadMilliseconds) {
          currentDate.applyDateOperation(dateMathVerb, TimeUnit.Second, this.#fields.hour.values.length);
        }
        continue;
      }
      break;
    }

    /* istanbul ignore next - should be impossible under normal use to trigger the branch */
    if (stepCount > LOOP_LIMIT) {
      throw new Error(LOOPS_LIMIT_EXCEEDED_ERROR_MESSAGE);
    }

-   if (currentDate.getMilliseconds() !== 0) {
-    currentDate.setMilliseconds(0);
-   }
    this.#currentDate = currentDate;
    return currentDate;
}
Editing findSchedule

┌────────────────────┬────────────┬────────────┬──────────┬─────┐
│ Pattern            │   Old Mean │   New Mean │   Change │     │
├────────────────────┼────────────┼────────────┼──────────┼─────┤
│ * * * * * *        │    48.33ms │    45.13ms │    6.62% │  ↑  │
├────────────────────┼────────────┼────────────┼──────────┼─────┤
│ 0 * * 1,4-10,L * * │  1782.69ms │  1740.21ms │    2.38% │  ↑  │
├────────────────────┼────────────┼────────────┼──────────┼─────┤
│ H H H(9-20)/3 1-1… │  4754.32ms │  4648.33ms │    2.23% │  ↑  │
├────────────────────┼────────────┼────────────┼──────────┼─────┤
│ 10-30/2 2 12 8 0   │  2011.49ms │  1983.64ms │    1.38% │  ↑  │
├────────────────────┼────────────┼────────────┼──────────┼─────┤
│ 10 2 12 8 7        │  5087.09ms │  5036.84ms │    0.99% │  ↑  │
├────────────────────┼────────────┼────────────┼──────────┼─────┤
│ 0 0 0 * * 1L,5L    │  5245.87ms │  5240.56ms │    0.10% │  ↑  │
├────────────────────┼────────────┼────────────┼──────────┼─────┤
│ 0 15 */5 5 *       │  4623.75ms │  4662.17ms │   -0.83% │  ↓  │
├────────────────────┼────────────┼────────────┼──────────┼─────┤
│ 0 12 */5 6 *       │  4707.84ms │  4808.74ms │   -2.14% │  ↓  │
├────────────────────┼────────────┼────────────┼──────────┼─────┤
│ 0 0 0 * * 4,6L     │  4508.67ms │  4614.44ms │   -2.35% │  ↓  │
├────────────────────┼────────────┼────────────┼──────────┼─────┤
│ 0 H * * *          │  4298.68ms │  4424.81ms │   -2.93% │  ↓  │
├────────────────────┼────────────┼────────────┼──────────┼─────┤
│ 0 0 6-20/2,L 2 *   │  4858.53ms │  5058.69ms │   -4.12% │  ↓  │
├────────────────────┼────────────┼────────────┼──────────┼─────┤
│ 0 H/3 * * *        │  3710.95ms │  3884.96ms │   -4.69% │  ↓  │
└────────────────────┴────────────┴────────────┴──────────┴─────┘

I'm already working on this as there are couple of related issues (that proposed approach may fix or not) as #273, #378 and #385

Thanks for linking these. I tried pulling in some test cases described in the issues. It does seem like these changes resolve #385 but not the others.

Happy to help keep digging or provide anything else.

@harrisiirak harrisiirak self-assigned this Aug 24, 2025
@harrisiirak
Copy link
Owner

harrisiirak commented Aug 24, 2025

@asuhag113 This addSecond change is actually clever and useful.

NB: I’ve also tested other methods (addMinute, addHour) and replaced them with direct date manipulation, observing 60–70% performance improvements on average.

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.

2 participants