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.