@palinx/scheduler
v0.1.9
Published
Scheduled task execution for Palinx. Cron expressions, intervals, one-off delays, and `@Timeout` decorators, with persist-before-dispatch durability (WAL + `synchronous=FULL`) so a scheduled trigger survives a process restart.
Readme
@palinx/scheduler
Scheduled task execution for Palinx. Cron expressions, intervals, one-off
delays, and @Timeout decorators, with persist-before-dispatch
durability (WAL + synchronous=FULL) so a scheduled trigger survives a
process restart.
⚠️ Horizontal-scaling caveat (v0)
Single-process only at v0. Running multiple processes against the same database will cause every scheduled firing to happen on every process, causing duplicate executions.
Distributed coordination — electing a single "scheduler-leader" across processes — is not part of v0. Until then, use one of the workarounds below to keep a single process firing schedules.
Workarounds today, if you need single-fire-across-instances:
- Application-level mutex: pick one process to be the scheduler via
an env flag (
PALINX_SCHEDULER=1) and gatepalinx.config.tsto only boot scheduled tasks on that process. The others run web traffic only. --no-schedulerflag:px serve --no-scheduleron every process except your designated scheduler instance.
Concepts
Trigger shapes
| Decorator / API | Cadence | Example |
| --- | --- | --- |
| @Cron("0 2 * * *", { tz: "America/New_York" }) | recurring on cron schedule | Daily 2am refresh |
| @Interval(60_000) | recurring every N ms | Every-minute health check |
| @Timeout(60_000) | single-shot, N ms after boot | "Warm a cache 60s after start" |
| scheduler.scheduleOnce({ delayMs }, TaskClass, payload) | single-shot runtime API | "In 1 hour" |
| scheduler.scheduleOnce({ at: epochMs }, TaskClass, payload) | single-shot at absolute time | "Send digest at 9am tomorrow" |
Discovery
Tasks live under src/scheduled/ and the file name must end with
.scheduled.ts. The scheduler is opt-in by convention: no
src/scheduled/ directory means the scheduler doesn't boot and no
migrations run.
src/
├── routes/
├── services/
└── scheduled/
├── nightly-report.scheduled.ts
└── digest-emails.scheduled.tsBase class
Every task extends ScheduledTask and implements run(input?). DI is
the same inject() helper every other Palinx primitive uses.
import { ScheduledTask, Cron } from "@palinx/scheduler";
import { inject } from "@palinx/core";
import { ReportService } from "../services/report.service";
@Cron("0 2 * * *", { tz: "America/New_York" })
export default class NightlyReport extends ScheduledTask {
static readonly name = "nightly-report";
private reports = inject(ReportService);
async run() {
await this.reports.rebuildDaily();
}
}For runtime-scheduled tasks, declare a payload schema so the serializability boundary is explicit:
import { z } from "zod";
import { ScheduledTask, Scheduler } from "@palinx/scheduler";
import { inject } from "@palinx/core";
import { MailerService } from "../services/mailer.service";
export default class SendDigest extends ScheduledTask {
static readonly name = "send-digest";
static readonly input = z.object({ userId: z.string() });
private mailer = inject(MailerService);
async run(input: { userId: string }) {
await this.mailer.sendDigest(input.userId);
}
}
// Call site — obtain the running scheduler via DI, then enqueue a one-off:
const scheduler = inject(Scheduler);
await scheduler.scheduleOnce({ delayMs: 60_000 }, SendDigest, { userId: "u_123" });Retry policy
Failures default to fail-loudly: one attempt, no retry. Tasks opt
in to retries via static readonly retry:
export default class FlakyTask extends ScheduledTask {
static readonly name = "flaky-task";
static readonly retry = {
maxAttempts: 3,
backoff: "exponential" as const,
baseDelayMs: 1_000,
maxDelayMs: 60_000,
};
async run() { /* ... */ }
}A failed firing increments attempt_count on the scheduled_tasks
row and stamps last_error. Once attempt_count >= maxAttempts, the
row's status flips to failed and stays there until an operator
manually re-arms it.
Catchup semantics
- One-off firings (
scheduleOnce/@Timeout) whoserun_atfell during a downtime window fire on the next boot; their persistence inscheduled_taskssurvives the outage. - Cron / interval firings missed during downtime are skipped.
Resuming a
@Cron("* * * * *")task after 10 minutes of downtime produces ONE firing, not ten.
Lifecycle
| Command | Scheduler boots? |
| --- | --- |
| px dev | yes |
| px serve (production) | yes |
| px build (build) | no (build-time should not fire tasks) |
| px test | no (tests opt in via the test harness) |
Pass --no-scheduler to either dev or serve to disable scheduling
on a specific process.
Test harness
import { testScheduledTask } from "@palinx/scheduler/testing";
import NightlyReport from "../src/scheduled/nightly-report.scheduled";
test("nightly-report rebuilds the daily report", async () => {
await testScheduledTask(NightlyReport);
// assert side effects...
});testScheduledTask invokes task.run(payload) directly with a fresh
DI scope, with no scheduler tick loop involvement and no timing dependence.
Durability
scheduler.scheduleOnce() returns ONLY after the row's INSERT has
fsync'd against the scheduled_tasks table (WAL + synchronous=FULL).
A crash between the call returning and the next tick still results in
the firing happening on the next boot; the row is your durable
record.
Recurring schedules (cron/interval/timeout) live in code via
decorators; the scheduler re-reads them at boot and re-anchors
lastFiredAt to the most-recent firing from the step log (or to
registration time on first boot).
