Skip to content

feat(server): wall-clock cron triggers for job schedules (#134)#141

Merged
Sootopolis merged 1 commit into
mainfrom
wip
Jun 29, 2026
Merged

feat(server): wall-clock cron triggers for job schedules (#134)#141
Sootopolis merged 1 commit into
mainfrom
wip

Conversation

@Sootopolis

Copy link
Copy Markdown
Owner

What

Adds wall-clock cron triggers to JobSchedule alongside 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".

  • Schematrigger_type / cron_expr / timezone / misfire_policy columns; interval_hours made nullable; a CHECK enforcing exactly one trigger's columns. Existing rows stay Interval (DEFAULT 'Interval') — no data rewrite.
  • ScheduleTrigger ADT + TriggerType / MisfirePolicy enums (EnumJson + EnumSql). Cron via cron4s 0.8.2. Users type 5-field unix cron; it's normalized to cron4s's 6-field form (leading 0 seconds + 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 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 → first fire is the next boundary, no backfire.
  • JobSchedulergrace = 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.
  • Route + CLIPOST/PUT /api/schedules accept cron fields (triggerType/misfire are 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); intervalHours 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.

DB migration (operators)

Existing databases need the column adds + CHECK applied 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

  • New pure suites: TestScheduleTrigger (normalize / ?-rule / validators), TestJobScheduleIsDue (Interval + Cron × Skip/CatchUp × first-enable / missed-during-downtime / double-poll / no-lastRunAt).
  • Extended TestJobScheduleSql (cron round-trip + CHECK-rejection), TestJobScheduler (cron fire under TestClock), TestRoutes (cron POST 201, invalid cron/tz, both-triggers, overflow → 400; legacy interval body still 201), TestCliParser (cron flags).
  • Full suite green: 989 passed, 0 failed (pre-push hook + locally).

Follow-up

Friendlier cron entry (Vixie macros / next-fire preview / help hints) deferred to #140.

🤖 Generated with Claude Code

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>
@Sootopolis Sootopolis merged commit 45a7623 into main Jun 29, 2026
1 check passed
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>
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.

Wall-clock (cron) job schedules, not just interval

1 participant