Kylndocs

Scheduled tasks

Some work runs on a clock rather than in response to a request: a nightly cleanup, a periodic refresh, a warm-up a minute after boot. Kyln handles this with the @kyln/scheduler package. You declare tasks as classes with a schedule decorator, drop them in a conventional directory, and the dev and production servers boot them for you.

Install

Projects scaffolded with kyln preheat already include @kyln/scheduler, so there's nothing to install. If your project doesn't have it (hand-rolled, started from the frontend template, or scaffolded before the package was included), add it:

bun add @kyln/scheduler

Then create a src/scheduled/ directory. The scheduler is opt-in by convention: if src/scheduled/ doesn't exist, the scheduler never boots and adds nothing to your app. Add the directory and it comes to life.

A scheduled task

A task is a class that extends ScheduledTask, carries a schedule decorator, and implements run(). Task files live under src/scheduled/ and end in .scheduled.ts.

src/scheduled/session-cleanup.scheduled.ts
// src/scheduled/session-cleanup.scheduled.ts
import { ScheduledTask, Cron } from "@kyln/scheduler";
import { inject } from "@kyln/core";
import { DatabaseService } from "../services/db.service";

@Cron("0 3 * * *", { tz: "America/New_York" })
export default class SessionCleanup extends ScheduledTask {
  static readonly name = "session-cleanup";

  private db = inject(DatabaseService);

  async run() {
    this.db.conn.run("DELETE FROM sessions WHERE expires_at < datetime('now')");
  }
}

Two things make this work:

  • static readonly name is the task's canonical identifier; it's how the task shows up in logs and on the operator surface. It's required.
  • Dependency injection is the same inject() you use everywhere else. This task pulls in the DatabaseService from Services & DI and clears expired sessions every night at 3am Eastern.

Schedules

Three decorators cover the common cases:

Decorator Fires
@Cron(expr, { tz }) On a cron schedule. @Cron("0 3 * * *") runs daily at 3am; pass { tz } to anchor it to a timezone.
@Interval(ms) Every ms milliseconds while the app runs. @Interval(60_000) runs once a minute.
@Timeout(ms) Once, ms after boot. @Timeout(60_000) warms something up a minute after start.
import { ScheduledTask, Interval } from "@kyln/scheduler";

@Interval(60_000)
export default class Heartbeat extends ScheduledTask {
  static readonly name = "heartbeat";

  async run() {
    console.log("still alive", new Date().toISOString());
  }
}

Booting

You don't wire anything up. Both kyln dev and kyln serve check for src/scheduled/, discover the tasks there, register them, and start the scheduler alongside your web server. The same process serves requests and runs the schedule.

The scheduler runs the same way in every mode. kyln dev, kyln serve, and the standalone production bundle from kyln bake (bun dist/server/index.js) all boot the scheduler when src/scheduled/ exists, so scheduled tasks behave identically in development and production. An app with no src/scheduled/ directory bundles no scheduler at all, keeping the production server lean. Deployment covers running the scheduler in containers, including keeping its database on a persistent volume.

One-off firings

Besides the recurring decorators, the scheduler can fire a task once at a future time, scheduled at runtime. Think of the "purge this account in 24 hours" case. You define the task the same way, minus the schedule decorator, and trigger it from your app code when you need it.

A one-off task lives in src/scheduled/ like any other, extends ScheduledTask, and takes a payload through run():

src/scheduled/account-purge.scheduled.ts
// src/scheduled/account-purge.scheduled.ts
import { ScheduledTask } from "@kyln/scheduler";
import { inject } from "@kyln/core";
import { DatabaseService } from "../services/db.service";

export default class AccountPurge extends ScheduledTask {
  static readonly name = "account-purge";

  private db = inject(DatabaseService);

  async run(input: { userId: number }) {
    this.db.conn.run("DELETE FROM tasks WHERE user_id = ?", [input.userId]);
    this.db.conn.run("DELETE FROM sessions WHERE user_id = ?", [input.userId]);
    this.db.conn.run("DELETE FROM users WHERE id = ?", [input.userId]);
  }
}

With no schedule decorator, this task never fires on its own, but because it lives in src/scheduled/, the scheduler discovers and registers it at boot, ready to be triggered on demand.

To trigger it, inject the Scheduler (the same inject() you use for any dependency) and call scheduleOnce:

import { inject } from "@kyln/core";
import { Scheduler } from "@kyln/scheduler";
import AccountPurge from "../scheduled/account-purge.scheduled";

// in a route or service, when the user requests deletion:
const scheduler = inject(Scheduler);
await scheduler.scheduleOnce({ delayMs: 24 * 60 * 60 * 1000 }, AccountPurge, {
  userId: user.id,
});

scheduleOnce(opts, task, payload?) enqueues the firing. opts is either { delayMs } (relative) or { at } (an absolute Unix-ms timestamp). The payload is handed to the task's run() when it fires; it's serialized to the scheduler's database, so it must be JSON-safe.

Two things to know:

  • The task must be one defined in src/scheduled/. scheduleOnce fires an already-registered task on demand, not an arbitrary class; it throws if the task wasn't discovered at boot.
  • This needs a configured scheduler. inject(Scheduler) resolves only when your app has a src/scheduled/ directory (which, since that's where you defined the task, it does). An app with no scheduler has nothing to inject.

For validating the payload at the boundary, a task may declare an optional static input Zod schema; the scheduler checks the payload against it before the firing is accepted.

Running at scale

At this stage the scheduler is single-process. Every process that opens the scheduler database and boots src/scheduled/ runs the full schedule. If you scale your app across several processes against one shared database, each recurring task fires once per process, not once overall.

For a single-process deployment this is exactly what you want and needs no thought. If you run multiple processes, designate one to boot the scheduler (keep src/scheduled/ off the others, or gate booting behind your own coordination) so scheduled work runs once. Plan for this before scaling horizontally.

Next

Scheduled tasks, routes, and API endpoints all deserve tests. Testing covers how Kyln runs them.