Skip to content

fix(schedules): parse and match cron with cron-utils - #549

Merged
bmscomp merged 2 commits into
mainfrom
fix/schedules-parse-cron-with-cron-utils
Oct 29, 2026
Merged

bmscomp merged 2 commits into
mainfrom
fix/schedules-parse-cron-with-cron-utils

Conversation

@bmscomp

@bmscomp bmscomp commented Oct 29, 2026

Copy link
Copy Markdown
Owner

Why

TestScheduler and DisruptionScheduler each matched cron expressions with the same hand-written matcher. It did not follow cron. 0 9 * * MON never fired, because names were not read. 0-30/5 was not understood. When day-of-month and day-of-week were both set, it required both to match (AND), where cron fires when either matches (OR). Neither the schedule API nor the disruption schedule API checked the expression before saving it. @daily, 0 0 ? * MON or a 6-field expression was stored with a 201 and then never ran, and nothing said so.

What changed

  • Matching. The hand-written matcher in TestScheduler is deleted. Schedules are parsed with cron-utils CronType.UNIX through one static CRON parser. matchesCron calls ExecutionTime.isMatch on now truncated to the minute. Matching is still in UTC (ZoneOffset.UTC).
  • Disruption schedules. DisruptionScheduler had a byte-for-byte copy of the same matcher. It drops its matchesCron and matchesField and calls TestScheduler.matchesCron instead, which is now public, so both schedulers share one parser.
  • Bad stored expressions. A stored expression that no longer parses throws inside the existing per-schedule try/catch in evaluateSchedules. The scheduler logs it and skips that schedule.
  • Validation on save.
    • ScheduleResource POST and PUT parse cronExpression with the same parser through one invalidCron helper. If it does not parse, they return a 400 validationFailed keyed cronExpression. The message is cron-utils' message plus a hint about the 5-field format.
    • @Size is now 64, matching the column.
    • PUT has no @Valid, so it calls validator.validateProperty(request, "cronExpression"). The PUT and POST 400 bodies are identical.
    • DisruptionScheduleResource POST and PUT reuse ScheduleResource.invalidCron. They also refuse expressions longer than 64 characters, the width of cron_expression VARCHAR(64) in V4__create_disruption_schedules.sql. PUT checks only when cronExpression is in the body.
    • Both resources document the 400 with @APIResponse.
  • Dependency. kates/pom.xml now declares com.cronutils:cron-utils with no version, because the Quarkus 3.20.6 BOM manages it. It was already on the classpath at 9.2.1, transitively through quarkus-scheduler-common.
  • Parser behaviour, checked against the parser before writing anything.
    • Accepted: names (MON, JAN-MAR, mon-fri), 0-30/5, and 7 as Sunday. When both day fields are set, it uses OR semantics. */2 in the month field means Jan, Mar, and so on.
    • Refused: @daily, @hourly and @reboot ("Nicknames not supported!"), 6-field expressions, ? and L.
  • Docs agree. The 5-field UNIX format in UTC is now stated in all of these:
    • the API reference for POST and PUT (docs/book/11-api-reference.md)
    • the CLI reference --cron row (docs/book/10-cli-reference.md)
    • --cron help in cli/cmd/schedule.go and cli/cmd/disruption_watch.go
    • the schedule and disruption schedule create entries in cli/cmd/data/doc_entries.yaml
  • MCP source refs re-pointed after import shifts:
    • TestScheduler.java:99-105 → 104-110. This is an anchored ref, Actor.SCHEDULER.
    • ScheduleResource.java:137-138 → 224-226 in mcp_tools_runs_list.go. This ref was already stale on main. It now points at the PUT "can't take the @Valid" comment it describes.
    • DisruptionScheduler.java:107-113 → 109-115 and 95-107 → 97-109 in mcp_tools_runs_activity.go.

Tests

  • TestSchedulerCronTest:
    • fullCronExpression is removed, because it asserted the AND semantics that were the bug.
    • invalidCronReturnsFalse is now invalidCronThrows.
    • New cases cover MON, 0-30/5, day 7, */2 month, day-of-month OR day-of-week, and seconds within the minute.
  • ScheduleResourceTest:
    • A parameterized POST+PUT 400 test covers @daily, 4 fields, 6 fields, 60, ?, and a 65-character expression that does parse, so only its length refuses it.
    • 0 9 * * MON is saved with a 201.
  • DisruptionReportPersistenceTest.aCronExpressionTheSchedulerCannotReadIsRefused: 0 9 * * MON gets a 201. @daily, 0 0 ? * MON and a 65-character valid expression get a 400 with message cronExpression: ... on both POST and PUT.

Run locally:

  • cd kates && ./mvnw test -Dquarkus.grpc.codegen.skip=true -Dquarkus.http.test-port=0: BUILD SUCCESS. 1760 tests, 0 failures, 0 errors, 3 skipped.
  • cd kates && ./mvnw -o -q spotless:check (JDK 25): pass.
  • cd kates && ./mvnw dependency:tree, compared with the tree for origin/main's pom: the same resolved artifact set. com.cronutils:cron-utils:jar:9.2.1:compile is now a direct dependency and nothing else is new. For Dependency review: no new artifact or version.
  • cd cli && go vet ./... && go test ./...: pass, including the TestMCPCaveat* ref anchors.
  • cd cli && golangci-lint run --new-from-rev=$(git merge-base HEAD origin/main) ./...: 0 issues.
  • ./scripts/gen-chart-table.sh --check, ./scripts/gen-version-matrix.sh --check, ./scripts/check-book-style.sh, ./scripts/check-book-contrast.py, ./scripts/check-mermaid.sh --quiet: all pass.
  • lychee --offline --no-progress --include-fragments README.md "docs/**/*.md": 0 errors.

Only CI runs these: integration tests, the native image build and smoke test, the Kind/Docker jobs, the book render, CodeQL and Dependency review.

Deliberately not done

  • Schedules still match in UTC. A per-schedule time zone would be a new feature.
  • Stored expressions that no longer parse are not migrated or rewritten. The scheduler logs and skips them, and a PUT with a valid expression fixes them.

The scheduler matched schedules with a hand-written matcher that knew only
*, */n, a,b, a-b and numbers, while POST and PUT saved any string. So
"0 9 * * MON", "@daily" and "0-30/5 * * * *" were saved with a 201 and
never fired, day-of-week 7 (Sunday) never matched, */2 in the month field
picked February, April, ... instead of January, March, ..., and
"0 9 1 * 1" fired only on a 1st that is a Monday, not on either. An
expression of 65 to 120 characters passed @SiZe(max = 120) and then
failed with a 500 at the 64-character column.

The matcher is gone: TestScheduler parses with cron-utils' UNIX
definition and matches with ExecutionTime.isMatch on the minute, still in
UTC. POST and PUT parse the expression with the same parser and refuse
one it can't read with a 400 keyed cronExpression; PUT now also applies
the field's @notblank and @SiZe, which is 64 to match the column.
Macros such as @daily, six-field expressions and Quartz's ? and L are
refused rather than saved. A stored expression that no longer parses
throws inside the scheduler's per-schedule try, so it is logged and
skipped without stopping the other schedules.

Disruption schedules had a copy of the same matcher and saved any
non-blank string, so they had the same bugs. DisruptionScheduler now
matches with TestScheduler's parser, and the disruption schedules' POST
and PUT give the same 400, and refuse an expression wider than its
64-character column.

cron-utils already came in through quarkus-scheduler; it is declared now
that Kates uses it directly, its version left to the Quarkus BOM. The
API reference, CLI reference, the --cron help of kates schedule create
and kates disruption schedule create, and their doc entries say which
cron the Kates API takes, and four MCP source refs into the edited files
are re-pointed.
…ron-with-cron-utils

# Conflicts:
#	docs/book/11-api-reference.md
@bmscomp
bmscomp merged commit 645182a into main Oct 29, 2026
60 checks passed
@bmscomp
bmscomp deleted the fix/schedules-parse-cron-with-cron-utils branch October 29, 2026 13:56
bmscomp added a commit that referenced this pull request Oct 29, 2026
#549 gave PUT the cronExpression's @notblank and @SiZe; name had the same
gap, so a PUT with a name over 128 characters still reached Postgres and
came back as a 500. ScheduleApiIT now sends both over-wide values through
PUT as well as POST.
bmscomp added a commit that referenced this pull request Oct 29, 2026
…s it found (#553)

* test(schedules): ScheduleApiIT against real Postgres, and the two bugs it found

The schedules API was only tested on H2, where Hibernate generates the
table from the entity. ScheduleApiIT runs it against the Flyway schema:
round trip, rename, refused request, column widths and the audit trail.

Two of its tests failed on main:

- The request allowed a 255-char name and a 120-char cron, but V2 makes
  the columns VARCHAR(128) and VARCHAR(64), so a longer value reached
  Postgres and came back as a 500. The @SiZe limits now match the columns.
- enabled defaulted to true, so a PUT that left it out (a rename)
  switched a disabled schedule back on. It is now a Boolean: POST still
  defaults to enabled, PUT keeps the stored flag when it is absent.

* fix(schedules): a PUT holds the name to its column width too

#549 gave PUT the cronExpression's @notblank and @SiZe; name had the same
gap, so a PUT with a name over 128 characters still reached Postgres and
came back as a 500. ScheduleApiIT now sends both over-wide values through
PUT as well as POST.
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.

1 participant