Scheduler¶
The katalyst-scheduler module runs recurring and one-off jobs inside your application. You
declare jobs from a Service using the jobs DSL; the scheduler invokes the declaring function
at startup and manages the coroutines. For a walkthrough, see
Schedule background jobs.
Enable it with features { enableScheduler() }.
requireScheduler and the jobs DSL¶
Inside a Service, requireScheduler() returns a ServiceScheduler. Its jobs { … } method
registers a group of jobs and returns a SchedulerJobHandle. Returning that handle from a public
function is the discovery signal.
import io.github.darkryh.katalyst.scheduler.extension.requireScheduler
import kotlin.time.Duration.Companion.seconds
class MaintenanceJobs(private val service: MaintenanceService) : Service {
private val scheduler = requireScheduler()
fun jobs() = scheduler.jobs {
cron("nightly", "0 0 2 * * ?") { service.runNightly() }
fixedDelay("poll", 5.seconds) { service.poll() }
fixedRate("heartbeat", 10.seconds) { service.heartbeat() }
oneTime("warmup") { service.warmup() }
}
}
If the scheduler feature is not enabled, requireScheduler() throws
SchedulerServiceNotAvailableException.
The discovery contract¶
When the application is ready, the framework scans every registered Service and invokes each
method that matches all of:
- it is declared on a class implementing
Service; - it is public — a
privatemethod returningSchedulerJobHandleis never registered; - it returns
SchedulerJobHandle; - it reaches the jobs DSL, either in its own body or through a helper on the same class hierarchy (up to three calls deep).
The method may be inherited from a base service class, may be suspend, and may declare
parameters — they are resolved from the container, and Kotlin default values are honoured.
A method that returns SchedulerJobHandle but fails the contract (it is private, or it never
reaches the DSL) is skipped, not failed: the application still starts, and the scheduler logs
the service, the method and the reason at WARN, then counts it in its completion summary —
Scheduler initialization completed: N registration(s), N rejection(s), N failure(s). A method
that throws while registering is a failure, not a rejection, and does stop startup with
SchedulerInvocationException.
Job types¶
The SchedulerJobsBuilder (the receiver of jobs { … }) offers four builders. Each accepts
either a name + schedule, or a full ScheduleConfig.
| Builder | Signature (name form) | First run | Next run |
|---|---|---|---|
cron |
cron(name, expression) { … } |
The next instant matching the expression — never at registration | Each subsequent matching instant, evaluated in timeZone |
fixedDelay |
fixedDelay(name, delay) { … } |
Immediately, after initialDelay |
delay after the previous run finishes |
fixedRate |
fixedRate(name, period) { … } |
Immediately, after initialDelay |
Every period from the previous run's start |
oneTime |
oneTime(name) { … } |
Once, after initialDelay |
Never |
cron also accepts a CronExpression instance instead of a string.
A job never runs concurrently with itself. Each job is one coroutine that runs its task to completion before considering the next trigger, so a slow run delays the schedule instead of piling up overlapping executions.
fixedRate, overruns, and catching up¶
fixedRate measures the period from the start of one run to the start of the next, so the time
the task itself takes is not added to the period. The next start is an anchor that accumulates one
period per tick and is never re-anchored to "now" — the same rule as the JDK's
ScheduledThreadPoolExecutor.
When a run overruns its period, the ticks it covered are still due. They fire back-to-back (sequentially, never concurrently) as soon as the slow run finishes, until the schedule is back on its original grid. With a 100 ms period and one run that takes 350 ms, the runs for +100 ms, +200 ms and +300 ms all start the moment the slow run ends, and the schedule resumes at +400 ms.
If you would rather an overrun push the whole schedule out — no catch-up burst, always a fixed
gap of idle time between runs — use fixedDelay instead.
cron and time zones¶
A cron job waits for the next matching instant before its first run; registering one at startup
does not run it. cron("nightly", "0 0 2 * * ?") runs at 02:00 and only at 02:00.
The expression is evaluated against the wall clock of ScheduleConfig.timeZone (defaulting to the
JVM's zone), not the machine's zone, so a job configured for Europe/Madrid fires at Madrid's
02:00 wherever the process runs. Across a daylight-saving transition, a matching local time that
falls inside a gap is shifted forward to the first valid instant after the gap, and one that
falls inside an overlap uses the earlier of the two offsets.
If a run overruns past one or more matching instants, those instants are skipped: cron catches up to the next future match rather than replaying missed ones.
ScheduleConfig¶
Pass ScheduleConfig instead of a bare name for full control.
| Field | Type | Default | Meaning |
|---|---|---|---|
taskName |
String |
— | Unique job name. |
tags |
Set<String> |
empty | Grouping/filtering tags. |
initialDelay |
Duration |
none | Delay before the first run (before the first cron evaluation). |
timeZone |
ZoneId |
system | Time zone the cron expression is evaluated in. |
maxExecutionTime |
Duration? |
none | Cancel a run that exceeds this. |
onSuccess |
(taskName: String, executionTime: Duration) -> Unit |
no-op | Called after each successful run, with how long it took. |
onError |
(taskName: String, exception: Throwable, executionCount: Long) -> Boolean |
{ _, _, _ -> true } |
Called on a failed or timed-out run. Return true to keep scheduling, false to stop the job. |
import io.github.darkryh.katalyst.scheduler.config.ScheduleConfig
import java.time.ZoneId
import kotlin.time.Duration.Companion.minutes
fun jobs() = scheduler.jobs {
cron(
config = ScheduleConfig(
taskName = "billing.run",
tags = setOf("prod"),
timeZone = ZoneId.of("UTC"),
maxExecutionTime = 5.minutes,
onError = { task, error, runs ->
log.error("$task failed on run $runs", error)
true // keep scheduling; return false to stop the job
}
),
expression = "0 0/15 * * * ?"
) { billing.run() }
}
CronExpression and CronValidator¶
CronExpression(expression) parses a cron string; nextExecutionAfter(dateTime) computes the
next run.
CronValidator validates expressions without constructing one:
CronValidator.isValid("0 0 2 * * ?") // Boolean
CronValidator.validate("bad expr") // List of error messages
Cron expressions use the six-field form second minute hour day-of-month month day-of-week.
Supported syntax¶
Every field accepts *, a single number, a list (1,3,5), a range (1-5) and a step
(*/5, 0-30/5, 0/2). ? is accepted in the day-of-month and day-of-week fields (Quartz
style); at least one of the two must be restricted. Day-of-week is 0-6 with 0 = Sunday.
Some syntax found in Quartz and in Unix crontabs is not supported. These fail at registration (or silently do not mean what they do elsewhere), so avoid them:
| Not supported | Example | Use instead |
|---|---|---|
| Day-of-week and month names | MON-FRI, JAN |
Numbers: 1-5, 1 |
Day-of-week 7 for Sunday |
0 0 2 ? * 7 |
0 |
| Last-day / last-weekday | L, LW, 6L |
An explicit day number |
| Nth weekday of month | FRI#3, 6#3 |
— |
| Nearest weekday | 15W |
— |
| Macros | @daily, @hourly, @reboot |
The equivalent six-field expression |
| Five-field crontab form | 0 2 * * * |
The six-field form: 0 0 2 * * ? |
SchedulerJobHandle¶
The value returned by jobs { … }. It is a kotlinx.coroutines.Job, so it supports
cancel(), join(), and the standard Job API. The scheduler stops all jobs on application
shutdown.
SchedulerService¶
The underlying service (AutoCloseable, CoroutineScope) that owns job coroutines. It is
managed for you; stop() and close() shut it down. You normally interact with it only
through requireScheduler().
The two differ in whether they wait:
| Method | Behaviour |
|---|---|
stop() |
Cancels every job and returns immediately. Cancellation is asynchronous, so a run already in flight may still be executing when it returns. Safe to call from inside a coroutine. |
close() |
Cancels, then waits up to 5 seconds for the runs already in flight to unwind before returning. Past that grace they are abandoned and shutdown continues, so a job wedged on a blocking call cannot hold the process open. Blocks the calling thread. |
Shutdown closes beans in reverse registration order, which puts the DatabaseFactory last: the
wait in close() is what keeps a job's final write on a live connection pool instead of racing
HikariDataSource has been closed.
Exceptions¶
All extend SchedulerException. Only one of them is thrown by the framework today; the rest are
reserved, catchable types kept for forward compatibility.
| Exception | Status |
|---|---|
SchedulerInvocationException |
Thrown by the runtime-ready initializer when one or more registration methods fail. Each candidate is invoked in isolation, so one failure does not stop the others; a single aggregate exception is thrown once all have been attempted, and per-method causes are in the logs rather than chained onto it. Note this covers registration, not a job body that throws at run time — that goes to onError. |
SchedulerServiceNotAvailableException |
Reserved, never thrown. requireScheduler() raises a plain IllegalStateException when the feature is not enabled. |
SchedulerValidationException |
Reserved, never thrown. An invalid cron expression fails with IllegalArgumentException from CronExpression's own require checks. |
SchedulerDiscoveryException |
Reserved, never thrown. Discovery failures propagate as the underlying reflection error or are aggregated into SchedulerInvocationException. |
SchedulerConfigurationException |
Reserved, never thrown. |
See also¶
- Schedule background jobs
- Test your application — verifying jobs run in tests.