Task Scheduler
bext includes a built-in task scheduler that eliminates the need for external cron, systemd timers, or third-party job queues. Tasks run on a dedicated tokio task inside the server process with persistent state tracking, overlap prevention, and failure monitoring.
Configuration
Define tasks in bext.config.toml:
[[tasks]]
name = "cache_gc"
schedule = "*/5 * * * *" # Every 5 minutes
handler = "cache::gc"
[[tasks]]
name = "warm_popular"
schedule = "10m" # Every 10 minutes
handler = "cache::warm"
config = { routes = ["/", "/products", "/pricing"] }
[[tasks]]
name = "health_check"
schedule = "30s" # Every 30 seconds
handler = "health::self_check"
[[tasks]]
name = "daily_report"
schedule = "@daily" # Once per day
handler = "report::generate"
enabled = true
[[tasks]]
name = "cleanup_sessions"
schedule = "1h" # Every hour
handler = "sessions::cleanup"
config = { max_age_hours = 24 }
Schedule Syntax
The scheduler supports three schedule formats:
Simple Intervals
The most common format -- a number followed by a time unit:
| Format | Meaning | Example |
|---|---|---|
Ns |
Every N seconds | 30s |
Nm |
Every N minutes | 5m |
Nh |
Every N hours | 2h |
Cron-Like Expressions
A subset of standard cron syntax is supported:
| Expression | Meaning |
|---|---|
*/5 * * * * |
Every 5 minutes |
0 */3 * * * |
Every 3 hours |
The scheduler parses the */N pattern in the minute field (first position) and the 0 */N pattern in the hour field (second position).
Shortcuts
| Shortcut | Equivalent |
|---|---|
@hourly |
Every 1 hour |
@daily |
Every 24 hours |
All intervals must be greater than zero. A schedule of 0s or 0m is rejected at startup with a configuration error.
Built-In Handlers
bext ships three built-in task handlers:
cache::gc
Evicts expired entries from the ISR cache. Runs every 5 minutes by default even without explicit configuration.
[[tasks]]
name = "cache_gc"
schedule = "*/5 * * * *"
handler = "cache::gc"
cache::warm
Pre-renders specified routes into the ISR cache, ensuring fast responses for popular pages after a deploy or restart.
[[tasks]]
name = "warm_popular"
schedule = "10m"
handler = "cache::warm"
config = { routes = ["/", "/products", "/blog"] }
health::self_check
Hits the server's own /health endpoint and logs a warning if the response is not 200. Useful for detecting degraded states.
[[tasks]]
name = "health_check"
schedule = "30s"
handler = "health::self_check"
Custom Handlers
For application-specific tasks, use a custom handler name. Custom handlers are resolved through the plugin system or the SDK API:
[[tasks]]
name = "sync_inventory"
schedule = "15m"
handler = "inventory::sync"
config = { source = "https://erp.internal/api/stock", timeout_ms = 30000 }
Register the handler in your plugin or server-side code:
registerTaskHandler("inventory::sync", async (config) => {
const response = await fetch(config.source, {
signal: AbortSignal.timeout(config.timeout_ms),
});
const stock = await response.json();
await updateInventoryCache(stock);
});
Overlap Prevention
The scheduler guarantees that a task never runs concurrently with itself. If a previous execution is still in progress when the next tick fires, the task is skipped for that tick. This prevents resource contention and data corruption in tasks that may occasionally run longer than their interval.
The scheduler checks for due tasks every 30 seconds (configurable via tick_interval). On the first tick after startup, all enabled tasks that have never run are immediately executed.
Failure Handling
When a task handler throws an error or returns a failure result, the scheduler:
1. Increments the task's error_count metric
2. Logs the error with the task name and handler
3. Marks the execution as complete (the task will retry on its next scheduled interval)
For tasks that need more sophisticated retry logic (exponential backoff, circuit breaking), use a durable flow instead of a simple scheduled task.
Disabling Tasks
Set enabled = false to disable a task without removing its configuration:
[[tasks]]
name = "experimental_sync"
schedule = "1h"
handler = "experimental::sync"
enabled = false # Paused — won't run until re-enabled
Disabled tasks do not appear in the due task list and consume no resources.
Status Monitoring
Task status is available at /_bext/admin/scheduler/status:
{
"is_running": true,
"tasks": [
{
"name": "cache_gc",
"handler": "cache::gc",
"enabled": true,
"run_count": 288,
"error_count": 0,
"is_running": false,
"interval_secs": 300
},
{
"name": "warm_popular",
"handler": "cache::warm",
"enabled": true,
"run_count": 144,
"error_count": 2,
"is_running": true,
"interval_secs": 600
}
]
}
These metrics are also exported in Prometheus format at /_bext/metrics under the bext_task_* namespace.
Integration with Durable Flows
For complex multi-step operations, use the task scheduler as a trigger for durable flows:
[[tasks]]
name = "monthly_billing"
schedule = "0 */24 * * *" # Daily
handler = "flow::trigger"
config = { flow_id = "billing-cycle", input = { type = "monthly" } }
This combines the scheduler's reliable timer with the flow engine's crash recovery, retry policies, and compensation logic. The scheduler fires the trigger, and the durable flow handles the multi-step execution independently.
One-Shot Tasks
For tasks that should run once at startup (database migrations, initial cache warming), set the schedule to a very long interval and rely on the "run immediately on first tick" behavior:
[[tasks]]
name = "startup_migration"
schedule = "8760h" # 1 year — effectively one-shot
handler = "db::migrate"
Alternatively, use the startup hook in your bext plugin for truly one-time initialization.
Graceful Shutdown
When the server receives a shutdown signal (SIGTERM or SIGINT), the scheduler stops accepting new task executions. Currently running tasks are given a grace period to complete before the process exits. The shutdown flag is propagated to all task handlers so they can check for early termination.