Skip to content

v2.2.7

Choose a tag to compare

@ggaabe ggaabe released this 25 Sep 15:07
· 12 commits to main since this release

This patch makes next() and previous() on rules without COUNT (unbounded or UNTIL-bound, the usual calendar case) 30–120x faster, speeds up time zone setup and sub-daily generation, and fixes HOURLY, MINUTELY, and SECONDLY rules that ignored their INTERVAL cadence, plus two monthly BYSETPOS bugs. There are no API changes.

Faster queries on rules without COUNT

next(), previous(), between(), matches(), and occursOn() used to clone a rule without COUNT and replay it through Temporal on every call. Simple DAILY, WEEKLY, MONTHLY, YEARLY, and fixed-step rules now visit only the recurrence periods around the query, in integer time, and RDATE/EXDATE merge as they do for COUNT rules. Unsupported shapes, DST gaps, and scans the general engine couldn't finish within maxIterations keep the existing path. (#145)

Query v2.2.6 v2.2.7 Speedup
WEEKLY M/W/F next(), 40 EXDATEs, no end, Chicago 157.5 µs 1.33 µs 118x
WEEKLY M/W/F next(), no end, UTC 61.25 µs 0.69 µs 88.77x
WEEKLY M/W/F next(), UNTIL 2030, UTC 64.59 µs 0.89 µs 72.57x
MONTHLY last Friday next(), no end, Chicago 68.79 µs 1.39 µs 49.49x
YEARLY previous(), no end, Chicago 79.11 µs 2.23 µs 35.48x
HOURLY every 4 hours next(), no end, UTC 19.32 µs 0.61 µs 31.67x
WEEKLY M/W/F between() one month, no end, UTC 36.22 µs 6.40 µs 5.66x
DAILY between() one month, no end, Chicago 82.59 µs 35.89 µs 2.30x

Faster time zone setup and generation

  • Time zone tables are built from the Temporal implementation's own transitions instead of daily Intl probes. The first all() for a Chicago weekday rule in a fresh process dropped from 23.3 ms to 4.6 ms. (#145)
  • Long-running processes no longer slow down exponentially. A table used to double toward the past whenever a query landed beyond its end. Creating Chicago rules two years apart, the 15th all() took 45 seconds; it now takes about 0 ms. (#145)
  • Fast paths in more cases: UTC WEEKLY, MONTHLY, and expanded DAILY rules keep their fast paths when RDATE/EXDATE are present, and long named-zone DAILY/WEEKLY COUNT rules no longer fall back to the general engine. (#145)
  • Sub-daily rules with BYxxx parts run on a new integer engine that constructs only the occurrences it returns. (#146)
Generation v2.2.6 v2.2.7 Speedup
UTC WEEKLY M/W/F with RDATE/EXDATE, all(), COUNT 1,000 12.555 ms 0.697 ms 18.02x
HOURLY quarter hours, all(), COUNT 1,000, UTC 3.967 ms 1.073 ms 3.70x
MINUTELY 9:00–16:45 quarter hours, all(), COUNT 1,000, UTC 3.964 ms 1.299 ms 3.05x
HOURLY weekdays, all(), COUNT 1,000, Chicago 3.818 ms 1.918 ms 1.99x
Chicago weekdays, first all() in a fresh process 23.282 ms 4.593 ms 5.07x

Measured against v2.2.6 on an Apple M2 Max with Node 25.2.1 and the bundled polyfill. Every scenario returned identical results from both builds before it was timed. Queries are warm medians per call; generation uses uncached all() calls, alternating the builds each sample.

These are selected scenarios, not universal gains:

  • The 13 existing COUNT-bound query scenarios measured 0.93–2.81x, and the 29 existing generation scenarios 0.93–1.18x. Isolated re-runs of the lowest cases were within 2% of v2.2.6.
  • The first UTC call in a fresh process was 0.05 ms slower.
  • The bundle grew by 3.1 kB gzipped (6.6%).

Full benchmark evidence includes every run, sample ranges, and the source hash; the README has the updated comparison with rrule.

Correctness fixes

  • Sub-daily rules ignored the INTERVAL cadence. HOURLY, MINUTELY, and SECONDLY rules with BYxxx parts jumped to the next BYxxx value instead of the next DTSTART + k * INTERVAL instant. (#146)
    • From DTSTART:20240131T233000Z, the rule FREQ=MINUTELY;INTERVAL=3;BYMINUTE=0,17,45;UNTIL=20240201T013000Z returned only 23:45 from all(). It should return 23:45, 00:00, 00:45 and 01:00.
    • previous(01:29) returned 01:17, which 3-minute steps from 23:30 never reach.
    • Other rules skipped whole weeks, emitted occurrences before DTSTART, returned fewer than COUNT results, or exceeded maxIterations.
    • RFC 5545's own "every 20 minutes from 9:00 to 16:40" example went wrong at its 25th occurrence.
  • Monthly BYSETPOS returned a date twice when positive and negative positions selected the same candidate: BYMONTHDAY=1,2,3;BYSETPOS=1,-3 gave Jan 1, Jan 1, Feb 1, Feb 1 instead of Jan 1, Feb 1, Mar 1, Apr 1. (#146)
  • Named-zone MONTHLY BYSETPOS ranked a time that doesn't exist. In America/Chicago, FREQ=MONTHLY;BYDAY=SU;BYSETPOS=3;BYHOUR=2 counted 02:00 on March 8, 2026, which the switch to daylight saving time skips, and returned March 15 instead of March 22. (#146)
  • Fast paths agree with the general engine on the polyfill. temporal-polyfill misses some short offset regimes that Intl reports, so all() and all(callback) could return different instants: 13:00+01:00 vs 12:00+01:00 for a Casablanca daily 12:00 rule. (#145)

Behavior changes

  • SECONDLY and MINUTELY rules limited by BYDAY now begin at the matching day's first second or minute, not at DTSTART's time of day. FREQ=SECONDLY;BYDAY=MO from a Wednesday at 12:00 now starts on Monday at 00:00:00, as it does in python-dateutil, rrule.js, and rrule-rust.
  • Sub-daily rules with BYxxx parts, especially with INTERVAL greater than 1, can return different occurrences than v2.2.6. The new results are the correct ones, so regenerate any occurrences you stored from such rules.

Validation

  • 1,567 tests pass on Node 20 and 24 (bundled polyfill) and Node 26 (native Temporal).
  • Sub-daily results match python-dateutil for 152 reference rules, and a separate brute-force enumeration agrees on all of them.
  • An independent oracle covers DST folds and gaps, 30-minute and :45 transitions, and Samoa's skipped day.
  • 377,164 extended seeded fuzz comparisons of the new query plans and sub-daily engine, against the general engine and independent oracles, found no mismatches.
  • 1,436 INTERVAL=1 sub-daily rules across 15 DTSTART calendars return the same results as v2.2.6. (#148)

Full changelog: v2.2.6...v2.2.7