Workflow Scheduling
tramai-scheduler adds durable time-based execution to explicit TramAI workflows. It sits above tramai-orchestration — it decides when a workflow run should start or resume.
What This Covers
- Cron-backed workflow schedules
- Timezone-aware execution
- Skip calendars and business-hours filters
- Delay-step wakeups for suspended workflows
- Durable tick claiming through
WorkflowSchedulerStore - Misfire handling and observer events
When to Add It
Add tramai-scheduler when:
- a workflow must run on a clock rather than from application code
- a delayed workflow must resume after a persisted timer
- you need schedule state in JDBC instead of an external cron wrapper
If you only need workflow.run(...) from application code, stay with tramai-orchestration.
Minimum Setup
Dependency
implementation("dev.tramai:tramai-scheduler:0.3.1")
implementation("dev.tramai:tramai-orchestration:0.3.1")
Basic Schedule
import dev.tramai.scheduler.at
import dev.tramai.scheduler.dailyAt
import dev.tramai.scheduler.every
import java.time.temporal.ChronoUnit
// Cron expression: "minute hour day-of-month month day-of-week"
val schedule = at(
expression = "0 9 * * 1", // 9 AM every Monday
zone = "Europe/Rome",
)
// Daily at fixed time
val daily = dailyAt(
hour = 9,
minute = 30,
zone = "Europe/Rome",
)
// Simple interval
val fiveMinutes = every(5, ChronoUnit.MINUTES, zone = "UTC")
Schedule DSL
at(...)
Use when you already know the cron expression. Supports:
- five-field: minute, hour, day-of-month, month, day-of-week
- six-field: + seconds as first field
ZoneIdor zone string overloads
dailyAt(...)
For one fixed local time each day.
every(...)
For simple recurring intervals. Supported units: SECONDS, MINUTES, HOURS, DAYS.
Caveat: every(N, DAYS) uses cron day-of-month stepping — it resets at month boundaries, not a true continuous timer.
Calendar and Business-Hour Policies
Skip Calendar
Skip specific dates:
import dev.tramai.scheduler.FixedDate
val schedule = dailyAt(
hour = 9,
minute = 0,
zone = "Europe/Rome",
skipCalendar = listOf(FixedDate(12, 25)), // skip Christmas
)
Business Hours Only
Run only during business hours:
val weekdayMorning = dailyAt(
hour = 9,
minute = 0,
zone = "Europe/Rome",
skipCalendar = listOf(FixedDate(12, 25)),
businessHoursOnly = true,
)
Current behavior: When a cron fire lands outside business hours, the scheduler returns the next business-hour start directly. That adjusted time is not revalidated against the cron expression.
Delay Steps
Scheduling enables delayStep(...) across process boundaries:
workflow<MyState>("invoice-follow-up") {
localStep("prepare") { state, _ -> state.copy(prepared = true) }
delayStep("wait-15-minutes", duration = 15, unit = TimeUnit.MINUTES)
aiStep(
name = "follow-up",
input = { state -> state.prompt },
invoke = followUpService::send,
merge = { state, result -> state.copy(result = result) },
)
}
Runtime behavior:
- the workflow checkpoints itself and throws
WorkflowSuspendedException - the scheduler store records a delay wakeup
- a later scheduler poll resumes the workflow from persisted state
Timer Model
ScheduledWorkflowTimer is the runtime entry point. It:
- registers workflows with a declared schedule
- writes schedule state to
WorkflowSchedulerStore - polls for due ticks
- claims work with fencing tokens
- starts new workflow runs
- resumes delayed runs when wakeups mature
Persistence Model
Two levels:
- Workflow state persistence from
tramai-orchestration— checkpoints, leases - Schedule/tick persistence from
tramai-scheduler— schedule ticks, delay wakeups
Typical durable setup:
// From tramai-orchestration
val persistence = WorkflowPersistence(
checkpointStore = JdbcWorkflowCheckpointStore(dataSource),
stateCodec = JacksonStateCodec(),
)
// From tramai-scheduler
val store = JdbcWorkflowSchedulerStore(dataSource)
The scheduler does not hide those boundaries — you choose the persistence strategy explicitly.
Misfire Handling
The scheduler compares scheduled fire time to misfireThreshold.
Default settings in ScheduledWorkflowTimer:
| Setting | Default |
|---|---|
| Poll interval | 1 second |
| Claim duration | 30 seconds |
| Misfire threshold | 5 minutes |
| Batch size | 50 |
When a claimed tick exceeds the misfire threshold, the timer marks it as misfired and emits observer callbacks instead of running the workflow.
Observer Surface
Scheduler hooks in the workflow observer:
onScheduledTick(...)— a tick was claimed and will executeonSkippedTick(...)— a tick was skipped (calendar, business hours)onMissedTick(...)— a tick was misfired
This keeps scheduling observable without forcing an OpenTelemetry dependency.
Limitations
- does not provide distributed cron coordination across multiple scheduler nodes
- currently supports cron schedules only
- no runtime editing of workflow definitions
- no true continuous every-N-days semantics (resets at month boundaries)
Next Steps
- Orchestration — build the workflows the scheduler runs
- Orchestration Persistence — state, codecs, leases
- Server — expose scheduled workflows over HTTP
