flowable/flowable-engine · error · java.lang.IllegalArgumentException

Start year must be less than stop year

Error message

Start year must be less than stop year

What it means

Inside addToSet, when a range's end is before its start (e.g. 22-2 hours), CronExpression overflows into the next cycle by adding a per-field maximum (60, 24, 31, ...). YEAR has no cyclic maximum, so a year range whose end is less than its start (e.g. '2026-2020') hits the switch's YEAR case and throws an IllegalArgumentException (unchecked, NOT ParseException) with this message.

Solutions

  1. Swap the years so the range is ascending, e.g. 2025-2030 instead of 2030-2025.
  2. If a descending range is intentional, expand it into a list or two separate expressions/cron schedules.
  3. Catch IllegalArgumentException (not ParseException) when constructing CronExpression with user-supplied year fields, and validate start <= stop before formatting the string.
  4. Simplify: drop the year field entirely (Flowable timers rarely need it) so the 6-field expression has no year range to invert.

Example fix

// before
CronExpression expr = new CronExpression("0 0 0 1 1 ? 2030-2025"); // throws
// after
CronExpression expr = new CronExpression("0 0 0 1 1 ? 2025-2030");
Defensive patterns

Strategy: validation

Validate before calling

if (startYear > endYear) {
    throw new IllegalArgumentException("Year range must be ascending: " + startYear + "-" + endYear);
}
String cron = "0 0 0 1 1 ? " + startYear + "-" + endYear;

Try / catch

try {
    CronExpression expr = new CronExpression(cron);
} catch (IllegalArgumentException e) {
    // year range start >= stop (note: unchecked, distinct from ParseException)
    throw new ConfigurationException("Invalid year range in cron: " + cron, e);
} catch (ParseException e) {
    throw new ConfigurationException("Invalid cron: " + cron, e);
}

Prevention

When it happens

Trigger: new CronExpression(cronString) whose year field (7th, optional) contains a descending range like '0 0 0 1 1 ? 2030-2025'. Only the YEAR field triggers this; descending ranges in other fields are legal wrap-around ranges.

Common situations: Dynamically assembled year ranges where start/stop were swapped; template placeholders replaced out of order; assuming all fields support wrap-around ranges like hours do.

Understand the failure class

Background: "Must be a positive integer", "Invalid value", "Unsupported": the invalid-argument-value error family, when a library rejects the value you pass — this error's family across 35 libraries.

Related errors


AI-assisted analysis of flowable/flowable-engine@d6d39ce1c6 (2026-09-11). Data as JSON: /api/errors/b437762a93b42c39. Report an issue: GitHub.

Appendix: source

Thrown at modules/flowable-engine-common/src/main/java/org/flowable/common/engine/impl/calendar/CronExpression.java:1073

                startAt = 1970;
            }
        }

        // if the end of the range is before the start, then we need to overflow
        // into
        // the next day, month etc. This is done by adding the maximum amount
        // for that
        // type, and using modulus max to determine the value being added.
        int max = -1;
        if (stopAt < startAt) {
            max = switch (type) {
                case SECOND -> 60;
                case MINUTE -> 60;
                case HOUR -> 24;
                case MONTH -> 12;
                case DAY_OF_WEEK -> 7;
                case DAY_OF_MONTH -> 31;
                case YEAR -> throw new IllegalArgumentException("Start year must be less than stop year");
                default -> throw new IllegalArgumentException("Unexpected type encountered");
            };
            stopAt += max;
        }

        for (int i = startAt; i <= stopAt; i += incr) {
            if (max == -1) {
                // ie: there's no max to overflow over
                set.add(i);
            } else {
                // take the modulus to get the real value
                int i2 = i % max;

                // 1-indexed ranges should not include 0, and should include
                // their max
                if (i2 == 0 && (type == MONTH || type == DAY_OF_WEEK || type == DAY_OF_MONTH)) {
                    i2 = max;
                }

View on GitHub (pinned to d6d39ce1c6)