@yaebal/cron
typed cron jobs for yaebal bots: declarative schedules (5/6-field cron expressions, @aliases, or a plain millisecond interval), per-job timezones, retries with backoff,
overlap control, cooperative timeouts, catch-up after downtime, a distributed-lock hook for
multi-instance deployments, runtime job management, and a chat-native /cron admin
surface — with graceful shutdown throughout. @yaebal/broadcast is close but
purpose-built for mass messaging — cron is a generic scheduler for periodic tasks;
close over bot.api in your task to send anything.
install
pnpm add @yaebal/cronas a bot plugin
jobs arm on bot.onStart and drain on bot.onStop — await bot.stop() won't resolve until any in-flight run finishes (see graceful shutdown). the plugin also decorates ctx.cron with the same handle, so any handler can reach trigger/pause/states without a separate import.
import { createBot } from "yaebal";
import { cron } from "@yaebal/cron";
const bot = createBot(process.env.BOT_TOKEN!).install(
cron({
tz: "Europe/Moscow", // default zone for every job; UTC if omitted
jobs: {
digest: {
schedule: "0 9 * * *", // every day at 09:00 local — real deploys wait for this
task: async (ctx) => {
// ctx.attempt is 1 on the first try, 2 on the first retry, ...
console.log(`sending digest (attempt ${ctx.attempt})`);
},
retries: 1,
},
},
}),
);
// the plugin decorates ctx.cron — any handler can trigger/inspect a job directly
bot.command("run-digest", async (ctx) => {
const outcome = await ctx.cron.trigger("digest"); // "ran" | "skipped", bypasses the schedule
await ctx.reply(`digest: ${outcome}`);
});
bot.start();import { cron } from "@yaebal/cron";
bot.install(
cron({
tz: "Europe/Moscow", // default zone for every job below; UTC if omitted
jobs: {
digest: {
schedule: "0 9 * * *", // every day at 09:00 local
task: async () => {
await bot.api.sendMessage({ chat_id: adminId, text: "good morning" });
},
retries: 2,
timeoutMs: 10_000,
},
heartbeat: {
schedule: 30_000, // every 30s
task: () => probe.ping(),
},
},
}),
);
// the plugin also decorates ctx.cron — any handler can reach trigger/pause/resume/states/nextRuns
bot.command("run-digest", async (ctx) => {
await ctx.reply(`digest: ${await ctx.cron.trigger("digest")}`);
});standalone (webhooks / serverless)
for webhook and serverless deployments, where bot.onStart/onStop never
fire, use createCron() and call start()/stop() yourself.
chained .job() calls accumulate valid names, so jobs.trigger("cleanup") and jobs.state("digest") are typo-checked at compile time.
import { createCron } from "@yaebal/cron";
const jobs = createCron()
.job("cleanup", "*/15 * * * *", () => cleanupExpiredSessions())
.job("digest", "0 9 * * *", sendDailyDigest, { timeoutMs: 60_000 });
jobs.start();
// ...
await jobs.stop();admin commands
cronAdmin installs a telegram-native ops surface for the jobs cron() added — list every job's state, trigger one on demand, pause/resume its schedule, or preview
upcoming runs — straight from a chat, gated by an isAdmin check you provide.
isolated via Composer.filter (not guard) — a rejected check continues
the outer chain instead of halting it, so installing this doesn't gate any handler
registered elsewhere on the same composer.
import { createBot } from "yaebal";
import { cron, cronAdmin } from "@yaebal/cron";
const bot = createBot(process.env.BOT_TOKEN!)
.install(
cron({
jobs: {
digest: { schedule: "0 9 * * *", task: () => {} },
cleanup: { schedule: 60_000, task: () => {} },
},
}),
)
// isAdmin: () => true for the demo — check ctx.from?.id against a real allow-list in production
.install(cronAdmin({ isAdmin: () => true }));
bot.start();import { cron, cronAdmin } from "@yaebal/cron";
bot
.install(cron({ jobs: { digest: { schedule: "0 9 * * *", task: sendDailyDigest } } }))
.install(cronAdmin({ isAdmin: (ctx) => ctx.from?.id === adminId }));
// /cron — every job's state: paused/running, run/failure counts, next & last run
// /cron run digest — trigger a job immediately, respecting its overlap policy
// /cron pause digest — stop its automatic schedule (trigger() still works)
// /cron resume digest — restart it
// /cron next digest — preview its next 3 scheduled fire timestimezones
set tz globally (every job's default) or per job (overriding it). resolved
per-instant via Intl — DST-correct, no timezone database bundled: a skipped
spring-forward hour is simply never matched, a repeated fall-back hour fires once. millisecond
intervals ignore tz — they're anchored on absolute time, not wall-clock fields.
cron({
tz: "Europe/Moscow", // default zone for every job — UTC if omitted
jobs: {
// inherits the "Europe/Moscow" default above
digest: { schedule: "0 9 * * *", task: sendDailyDigest },
// overrides it per job
nyReport: { schedule: "0 17 * * *", task: sendReport, tz: "America/New_York" },
},
});
// resolved per-instant via Intl — DST-correct, no timezone database bundled.
// a wall-clock time that's skipped on a spring-forward day is simply never matched.retries & cooperative timeouts
timeoutMs races the task against a timer and aborts ctx.signal —
cooperative, so pass it into your own fetch/api calls to actually cancel work.
either way the scheduler stops waiting, fails the run, and re-arms — a task that never resolves
can never wedge the schedule. retries/retryDelayMs re-run a failed (or
timed-out) attempt before giving up; only the final attempt counts toward state().failures and calls onError.
jobs.job(
"syncInventory",
"*/5 * * * *",
async (ctx) => {
// ctx.attempt is 1 on the first try, 2 on the first retry, ...
await fetch("https://example.com/inventory", { signal: ctx.signal });
},
{
retries: 2, // up to 2 extra attempts after the first failure
retryDelayMs: (attempt) => attempt * 1_000, // 1s, then 2s
timeoutMs: 10_000, // races the task; the scheduler stops waiting either way
},
);
// only the FINAL attempt counts toward state().failures and calls onError —
// an eventual success after retries is not a failure.overlap
decide what happens when a job's previous run is still going when it's due again.
jobs.job("sync", 5_000, syncInventory, { overlap: "wait" });
// "skip" (default) drops a run that arrives while the previous one is still going.
// "wait" queues exactly one run to fire right after the current one finishes.
// "allow" fires concurrently — no skipping or queueing at all.catch-up after downtime
with a store configured, a job with catchUp: true fires once at start() if its schedule had an occurrence due between the last recorded run and
now — so a restart during a deploy window doesn't silently drop a day's digest.
import { fileStorage } from "@yaebal/sklad/file";
const jobs = createCron({ store: fileStorage("./cron-state.json") })
.job("digest", "0 9 * * *", sendDailyDigest, { catchUp: true });
// with catchUp + a store, a missed occurrence (the process was down at 09:00) fires
// once at the next start() instead of silently vanishing until tomorrow.
// store only needs get/set (delete optional), sync or async — any @yaebal/sklad
// adapter (MemoryStorage, redisStorage, sqliteStorage, kvStorage, fileStorage) works.distributed locks
acquireLock gates each fire behind a lock you control — for a fleet running several
instances of the same bot, so a job only actually executes on one of them. no backend is
bundled; wire it to whatever your infrastructure already has.
const jobs = createCron({
// one instance wins per job name; the rest skip that fire with reason: "lock"
acquireLock: async (name) => {
const acquired = await redis.set(`lock:${name}`, "1", "NX", "PX", 30_000);
return acquired ? () => redis.del(`lock:${name}`) : false;
},
}).job("digest", "0 9 * * *", sendDailyDigest);
// no backend bundled — wire it to redis, postgres advisory locks, or whatever your fleet has.
// a throwing/rejecting hook is treated as "denied", never as a scheduler crash.runtime management
manage jobs after the scheduler is already running — e.g. from an admin command.
jobs.pause("digest"); // stop its automatic schedule; trigger() still works
jobs.resume("digest"); // re-arm it
jobs.reschedule("digest", "0 8 * * *"); // swap the schedule in place, re-arms immediately
jobs.remove("digest"); // unregister it and clear its timer
jobs.nextRuns("digest", 3); // preview the next 3 fire times, without arming anythinggraceful shutdown & events
stop() clears every timer immediately (no new runs start) and, by default, waits
for in-flight runs — and any queued overlap: "wait" follow-up they spawn — to
finish, up to drainTimeoutMs (30s), then aborts ctx.signal on anything
still going. pass { graceful: false } to abort and return immediately instead.
const jobs = createCron({
onEvent: (event) => console.log(event.type, event),
graceful: true,
drainTimeoutMs: 30_000,
});
// scheduled / run_started / run_completed / run_failed / run_retry /
// run_skipped (reason: "overlap" | "lock") / run_timeout / store_error / schedule_error
// every event carries `at`; run-scoped ones carry `run`/`attempt` too.schedule syntax
| form | example | meaning |
|---|---|---|
| cron expression | 0 9 * * * | minute hour day-of-month month day-of-week |
| with seconds | 30 0 9 * * * | optional 6th leading field — 09:00:30 every day |
| step | */15 * * * * | every 15 minutes |
| list / range | 0 9-17 * * 1-5 | hourly, 9-17, Mon-Fri |
| names | 0 9 * jan mon-fri | JAN–DEC / SUN–SAT, case-insensitive |
| alias | @daily | @yearly/@monthly/@weekly/@daily/@hourly/@midnight/@annually/@reboot |
| interval | 30_000 | a plain number of milliseconds — fires every 30s from when the job was armed |
0 0 31 2 * — February never has 31 days) is
rejected at registration, not discovered later. @reboot fires once when the
scheduler starts and never arms a timer.exports
| export | kind | use |
|---|---|---|
cron | function | installable bot plugin — wires bot.onStart/onStop, decorates ctx.cron |
createCron | function | standalone scheduler — call start()/stop() yourself |
cronAdmin | function | /cron ops commands, gated by isAdmin |
Cron | class | the scheduler — see the api table below |
parseCron | function | parse an expression into a pure, unit-testable CronSchedule |
CronExpressionError | class | thrown by parseCron/job() on a malformed or unsatisfiable expression, or an unrecognized tz |
CronJobExistsError | class | thrown by job() for a name registered twice |
CronJobNotFoundError | class | thrown by trigger()/pause()/… for an unregistered name |
CronTimeoutError | class | the reason ctx.signal aborts with when timeoutMs elapses |
CronStoppedError | class | the reason ctx.signal aborts with when stop()'s drain window elapses |
Cron methods
| method | returns | description |
|---|---|---|
job(name, schedule, task, options?) | Cron | register a job; chainable, accumulates typed names |
start() / stop(options?) | this / Promise<void> | arm / disarm every job |
trigger(name) | Promise<"ran" | "skipped"> | run now, respecting overlap — never disturbs the schedule |
pause(name) / resume(name) | void | stop/restart automatic firing; trigger() still works while paused |
reschedule(name, schedule) | void | replace the schedule in place, re-arms immediately |
remove(name) | boolean | unregister and clear its timer |
nextRuns(name, count) | Date[] | preview upcoming fire times, without arming anything |
state(name) / states() | CronJobState | undefined / CronJobState[] | paused, running, run/failure counts, next/last run, last error |
testing
Cron doesn't touch ctx — createCron() + trigger() is enough for most job logic. for schedule-driven behavior, drive @yaebal/test's virtual clock instead of real sleep(); cronAdmin() touches ctx, so test it through a
real bot and createTestEnv.
import { createCron } from "@yaebal/cron";
import { installTestClock } from "@yaebal/test";
const jobs = createCron().job("digest", 60_000, sendDigest);
await jobs.trigger("digest"); // run it once, right now, bypassing the schedule — resolves "ran"
// schedule-driven behavior (start(), timers, retry delays): drive the virtual clock
const clock = installTestClock();
try {
jobs.start();
await clock.advance(60_000);
assert.equal(jobs.state("digest")?.runs, 1);
} finally {
clock.restore();
}store only needs get/set (sync or async) — the same shape as @yaebal/sklad's StorageAdapter<T>,
so any of its adapters work without an explicit dependency on the package.@yaebal/panel is
a chat-inbox ui without a plugin/widget extension point, so job management ships as cronAdmin's bot commands instead of a panel page — it works with or without the panel
installed, and on every runtime yaebal supports (including edge/serverless).