feat(server): wall-clock cron triggers for job schedules (#134)#141
Merged
Conversation
JobSchedule only supported interval triggers (interval_hours since last run), which drift from the clock and cannot express "Mon 15:00" or "1st of month". Add a tagged-union trigger over flat columns — Interval | Cron — with cron via cron4s, a per-schedule IANA timezone, and a per-schedule misfire policy. - Schema: add trigger_type / cron_expr / timezone / misfire_policy, make interval_hours nullable, and a CHECK that exactly one trigger's columns are populated. Existing rows stay Interval (DEFAULT 'Interval'); no data rewrite. - ScheduleTrigger ADT + TriggerType/MisfirePolicy enums (EnumJson + EnumSql). 5-field unix input is normalized to cron4s's 6-field form: prepend a 0 seconds field and map unix day-wildcards onto cron4s's exactly-one-? day-field rule (restricting both day fields is rejected — cron4s can't express it). IANA zone validated at every creation path. - JobSchedule.isDue(now, grace): Interval = HOURS.between(lastRunAt, now); Cron = prev-boundary after lastRunAt, gated by misfire (CatchUp fires however late; Skip only within grace, so a boundary missed during downtime is skipped and the next on-time boundary resumes). Cron rows insert with lastRunAt=now so the first fire is the next boundary, not a backfire. - JobScheduler: grace = 2 * pollInterval; the isDue call is wrapped so a malformed (hand-edited) cron row logs and is treated as not-due instead of aborting the whole poll iteration. - Route + CLI: POST/PUT accept cron fields (triggerType/misfire are typed enums decoded snake_case); `ccas schedule add --cron --tz --misfire`. Trigger shape is validated (exactly one trigger; cross-type fields rejected) and intervalHours is range-guarded against SMALLINT overflow. Backward compatible: a legacy intervalHours-only body still creates an interval schedule. - Boot seeds (SchedulerDefaults) stay interval-only; cron is user-created. Existing databases need the column adds + CHECK applied by hand before running the new binary (per the no-migration-files convention); the test layer creates the new schema fresh, so tests need no migration. Friendlier cron entry (Vixie macros / next-fire preview / help hints) is deferred to #140. Closes #134 Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This was referenced Jun 29, 2026
Sootopolis
added a commit
that referenced
this pull request
Jul 3, 2026
sbt stage fails since cron4s landed (#141): native-packager's stage pulls packagedArtifacts, which builds every declared artifact — main jar, javadoc jar (running Compile/doc), sources jar — only for universalDepMappings to discard the doc/src jars from lib/. The scaladoc leg now dies reading cron4s-core 0.8.2's TASTy: its JVM jar bakes in scalajs-stubs annotations (JSExportTopLevel) that don't resolve on a JVM classpath, and Scala 3 scaladoc resolves annotations in dependency TASTy where the compiler doesn't need to. Declaring publishArtifact := false for Compile/packageDoc and Compile/packageSrc removes both jars from packagedArtifacts, so stage never invokes scaladoc (and stops zipping two jars it always threw away). A direct `sbt doc` still fails — upstream cron4s issue; nothing in CI or hooks runs it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sootopolis
added a commit
that referenced
this pull request
Jul 3, 2026
sbt stage fails since cron4s landed (#141): native-packager's stage pulls packagedArtifacts, which builds every declared artifact — main jar, javadoc jar (running Compile/doc), sources jar — only for universalDepMappings to discard the doc/src jars from lib/. The scaladoc leg now dies reading cron4s-core 0.8.2's TASTy: its JVM jar bakes in scalajs-stubs annotations (JSExportTopLevel) that don't resolve on a JVM classpath, and Scala 3 scaladoc resolves annotations in dependency TASTy where the compiler doesn't need to. Declaring publishArtifact := false for Compile/packageDoc and Compile/packageSrc removes both jars from packagedArtifacts, so stage never invokes scaladoc (and stops zipping two jars it always threw away). A direct `sbt doc` still fails — upstream cron4s issue; nothing in CI or hooks runs it. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Adds wall-clock cron triggers to
JobSchedulealongside the existing interval triggers — a tagged union (Interval | Cron) over flat columns, so a schedule can fire at fixed clock times ("Mon 15:00", "1st of month 03:00", "daily 00:00") rather than only "every N hours since the last run".trigger_type/cron_expr/timezone/misfire_policycolumns;interval_hoursmade nullable; aCHECKenforcing exactly one trigger's columns. Existing rows stayInterval(DEFAULT 'Interval') — no data rewrite.ScheduleTriggerADT +TriggerType/MisfirePolicyenums (EnumJson+EnumSql). Cron via cron4s 0.8.2. Users type 5-field unix cron; it's normalized to cron4s's 6-field form (leading0seconds + mapping unix day-wildcards onto cron4s's exactly-one-?day-field rule). Per-schedule IANA timezone, validated.JobSchedule.isDue(now, grace)(pure, TestClock-friendly) — Interval:HOURS.between; Cron: previous boundary afterlastRunAt, gated by misfire (CatchUpfires however late;Skiponly withingrace, so a boundary missed during downtime is skipped and the next on-time boundary resumes). Cron rows insert withlastRunAt = now→ first fire is the next boundary, no backfire.JobScheduler—grace = 2 × pollInterval; theisDuecall is wrapped so a malformed (hand-edited) cron row logs and is treated as not-due instead of aborting the whole poll.POST/PUT /api/schedulesaccept cron fields (triggerType/misfireare typed enums, snake_case on the wire);ccas schedule add --cron "0 15 * * MON" --tz Europe/London --misfire catch_up. Trigger shape validated (exactly one trigger; cross-type fields → 400);intervalHoursrange-guarded againstSMALLINToverflow. Backward compatible: a legacyintervalHours-only body still creates an interval schedule.SchedulerDefaults) stay interval-only; cron is user-created.DB migration (operators)
Existing databases need the column adds +
CHECKapplied by hand before deploying the new binary (per the no-migration-files convention). The test layer creates the new schema fresh, so tests need no migration. ALTER statements are in the issue / commit notes.Fixes
Closes #134.
Testing
TestScheduleTrigger(normalize /?-rule / validators),TestJobScheduleIsDue(Interval + Cron × Skip/CatchUp × first-enable / missed-during-downtime / double-poll / no-lastRunAt).TestJobScheduleSql(cron round-trip +CHECK-rejection),TestJobScheduler(cron fire under TestClock),TestRoutes(cronPOST201, invalid cron/tz, both-triggers, overflow → 400; legacy interval body still 201),TestCliParser(cron flags).Follow-up
Friendlier cron entry (Vixie macros / next-fire preview / help hints) deferred to #140.
🤖 Generated with Claude Code