# Scheduled tasks (in-process cron)

DaloyJS ships a **queue-agnostic schedule primitive**: run periodic work inside *this* process on a fixed interval or a cron expression. It is the in-process counterpart to an external job queue. Reach for it for cache sweeps, token refresh, reconciliation, and other housekeeping. It is not for distributed fan-out. It has zero runtime dependencies and three properties a production scheduler needs:

- Flexible schedules: fixed intervals (`intervalMs`) or 5-field cron expressions (`cron`, with `@hourly`/`@daily`/... aliases and an optional IANA `timeZone`).
- Single-flight: a task never overlaps itself. If a tick fires while the previous run is still in progress, the tick is skipped (and counted), so a slow task can never pile up unbounded concurrent runs.
- Graceful shutdown: `app.cron()` ties the scheduler to the app lifecycle: on shutdown it stops arming new runs, awaits in-flight runs, and aborts their `AbortSignal` if they outlast the grace period. Timers are `unref`'d, so a scheduler never keeps an idle process alive on its own.

**Diagram: One tick**

1. **Tick fires** (schedule) - cron expr or intervalMs is due
2. **Single-flight check** - already running? skip + count getState().skipped
3. **Run handler** - ({ signal }) with optional timeoutMs budget
4. **Completed** - runs++, lastDurationMs recorded
5. **Threw / timed out** - failures++, onError(err, { timedOut })

Each tick is armed fixed-rate before the run starts. If the previous run is still in flight the overlapping tick is skipped rather than started concurrently, so a slow task degrades to back-to-back runs instead of piling up.

## Quick start with `app.cron()`

The easiest entry point is `app.cron()`. The first call lazily creates an app-managed `Scheduler`, starts it, and registers the shutdown drain for you.

```ts
import { createApp } from "@daloyjs/core";

const app = createApp();

// Every hour, on the hour.
app.cron({ name: "purge-sessions", cron: "0 * * * *" }, async ({ signal }) => {
  await purgeExpiredSessions({ signal });
});

// Every 30 seconds, starting immediately.
app.cron(
  { name: "heartbeat", intervalMs: 30_000, runOnStart: true },
  () => publishHeartbeat(),
);
```

Inspect or manually trigger tasks through `app.scheduledTasks` (the underlying `Scheduler`):

```ts
app.scheduledTasks?.list();              // execution stats for every task
app.scheduledTasks?.getState("heartbeat"); // one task's snapshot
await app.scheduledTasks?.runNow("purge-sessions"); // out-of-band run
```

## Cron expressions

The `cron` field accepts a standard 5-field expression (`minute hour day-of-month month day-of-week`) with wildcards, lists (`1,15,30`), ranges (`1-5`), steps (`*/5`), and case-insensitive month/day names. Day-of-week accepts both `0` and `7` for Sunday. The named aliases `@yearly`, `@monthly`, `@weekly`, `@daily`, and `@hourly` are also supported.

```ts
app.cron({ name: "nightly", cron: "0 2 * * *" }, run);          // 02:00 daily
app.cron({ name: "weekday-9am", cron: "0 9 * * 1-5" }, run);    // 09:00 Mon–Fri
app.cron({ name: "quarter-hour", cron: "*/15 * * * *" }, run);  // every 15 min
app.cron({ name: "first-of-month", cron: "@monthly" }, run);    // 00:00 on the 1st
```

Cron expressions evaluate in UTC by default. Pass an IANA `timeZone` to schedule against a wall clock:

```ts
app.cron(
  { name: "ny-open", cron: "30 9 * * 1-5", timeZone: "America/New_York" },
  run,
);
```

Parsing is purely arithmetic (no backtracking regular expressions), and a malformed or unsatisfiable expression (for example `0 0 30 2 *`, the 30th of February) throws a `CronParseError` at registration time.

## Single-flight & overruns

Schedules are **fixed-rate**: the next tick is armed before the current run starts. If a run outlasts its interval, the overlapping tick is *skipped* rather than started concurrently, and the skip is counted in `getState(name).skipped`. This guarantees at most one concurrent run per task: a slow task degrades to “runs back-to-back” instead of fanning out.

```ts
const state = app.scheduledTasks!.getState("purge-sessions")!;
state.runs;            // total completed runs
state.failures;        // runs that threw or timed out
state.skipped;         // ticks skipped due to overrun
state.running;         // is a run in progress right now?
state.lastDurationMs;  // wall-clock duration of the last run
state.nextRunAt;       // epoch ms of the next scheduled run
```

## Per-run timeouts

Set `timeoutMs` to bound a run. When it elapses the run's `signal` is aborted. Forward it to your I/O so the handler unwinds promptly. A timed-out run is recorded as a failure and reported to `onError` with `timedOut: true`.

```ts
app.cron(
  {
    name: "sync",
    cron: "*/5 * * * *",
    timeoutMs: 60_000,
    onError: (err, info) => metrics.increment("cron.failed", { task: info.name }),
  },
  async ({ signal }) => {
    await reconcile({ signal });
  },
);
```

## Using the `Scheduler` directly

For lifecycles you manage yourself (workers, scripts, tests), construct a `Scheduler` and drive it directly. It accepts an injectable clock and timer primitives, which makes it fully deterministic under test.

```ts
import { Scheduler } from "@daloyjs/core";

const scheduler = new Scheduler({ logger });
scheduler.define({ name: "cleanup", intervalMs: 60_000 }, ({ signal }) =>
  sweep({ signal }),
);
scheduler.start();

// On shutdown, wait up to 5s for in-flight runs, then abort:
process.on("SIGTERM", () => scheduler.stop(5_000));
```

The cron utilities are exported standalone too: `parseCron(expr)` compiles an expression to its field sets, and `nextCronRun(expr, after?, timeZone?)` returns the next matching `Date`.

## When to reach for a real queue instead

This scheduler runs in-process: each instance of your app runs its own timers. That is exactly what you want for idempotent maintenance, but for work that must run *exactly once* across a horizontally-scaled fleet (or that must survive a process restart) use a durable queue or a leader-elected external scheduler and have the elected instance call `runNow()`. The single-flight guarantee is per-process. It does not coordinate across a cluster.

For work that must survive a restart and run once across the cluster, use [background jobs](/docs/jobs). `app.cronEnqueue()` keeps the scheduler as the clock but turns each tick into an idempotent enqueue, so eight replicas firing the same `0 2 * * *` collapse into one job that one worker runs. Keep `app.cron()` for process-local maintenance (the other replicas have their own memory to sweep). Use `cronEnqueue` when the tick's work is a global side effect.

---

Source: https://daloyjs.dev/docs/scheduler