Scheduler
Cron, RRule, one-shot, and repeating interval schedules
Scheduler
OqronKit provides two scheduling APIs: Cron() for fixed infrastructure jobs, and Schedule() for dynamic, data-driven scheduling.
Cron vs Schedule
Cron() | Schedule() | |
|---|---|---|
| Trigger | Time-driven (expression/every) | Data-driven (API-triggered) |
| Payload | None | Typed generic payload |
| Dynamic dispatch | No | .trigger() / .schedule() |
| Conditions | No | condition: async (ctx) => boolean |
| Best for | Sweeps, cleanup, metrics | Drip campaigns, delayed actions |
How It Works
Both Cron() and Schedule() share the same engine architecture — a leader-elected tick loop that scans for due definitions and fires handlers.
Tick Loop Lifecycle
OqronKit.init()
│
├─ Leader Election: compete for "scheduler:leader" lock
│ (only leader runs the tick loop; followers are standby)
│
│ ┌──── Tick Loop (every tickInterval ms) ────────────┐
│ │ │
├──┤ 1. Am I still leader? (renew leader lock) │
│ │ 2. Event loop healthy? (lag monitor check) │
│ │ 3. Query Storage for records where nextRunAt ≤ now │
│ │ 4. Sort by priority (lower = fires first) │
│ │ │
│ │ For each due record: │
│ │ ├─ Is it paused? → Apply disabledBehavior │
│ │ ├─ Condition guard? → shouldFire() check │
│ │ ├─ Rate limiter? → rateLimiter.check() │
│ │ ├─ Overlap policy? → Skip if already running │
│ │ │ │
│ │ ├─ FIRE: spawn handler execution │
│ │ └─ Advance nextRunAt to next occurrence │
│ └────────────────────────────────────────────────────┘
│
│ ┌──── Fire Execution ───────────────────────────────┐
│ │ │
├──┤ 1. Acquire heartbeat lock (guaranteedWorker) │
│ │ 2. Create job record in Storage (status: "active") │
│ │ 3. Start heartbeat renewal interval │
│ │ 4. Run beforeRun hook │
│ │ 5. Execute handler(ctx) │
│ │ 6. Run afterRun hook │
│ │ 7. Mark job "completed" or "failed" │
│ │ 8. Stop heartbeat, release lock │
│ │ 9. Apply retention policy (keepHistory) │
│ └────────────────────────────────────────────────────┘Leader Election
In multi-node deployments, only one node runs the tick loop. All nodes compete for a scheduler:leader lock key with a TTL:
- The winner becomes the Master Poller — only it checks for due schedules
- If the leader crashes, the lock expires within ~3 seconds
- A standby node acquires the lock and takes over
- Handlers still execute on the leader node (not distributed to workers)
Cron
Expression-based
import { Cron, type ICronContext } from "oqronkit";
export const dailyReport = Cron({
name: "daily-analytics-report",
expression: "0 8 * * *", // Every day at 8 AM
timezone: "Asia/Kolkata",
priority: 1, // Lower = fires first among simultaneous crons
version: 2, // Bump to trigger config migration
missedFire: "run-once", // Recover missed fires
overlap: "skip", // Skip if previous run is still active
guaranteedWorker: true, // Heartbeat crash-safety
timeout: 120_000,
tags: ["analytics", "reporting"],
keepHistory: 30,
keepFailedHistory: true,
hooks: {
beforeRun: async (ctx) => {
ctx.log.info("📊 Report generation starting...");
},
afterRun: async (ctx, result) => {
ctx.log.info(`✅ Report completed in ${ctx.duration}ms`);
},
onError: async (ctx, error) => {
ctx.log.error(`🔥 Report FAILED: ${error.message}`);
},
onMissedFire: async (ctx, missedAt) => {
ctx.log.warn(
`⏰ Missed fire recovered, missed at: ${missedAt.toISOString()}`,
);
},
},
handler: async (ctx: ICronContext) => {
ctx.progress(10, "Querying raw events");
ctx.progress(50, "Aggregating metrics");
ctx.progress(100, "Done");
return { rowsProcessed: 154_200, tenants: 42 };
},
});Interval-based (every)
export const healthCheck = Cron({
name: "health-check-ping",
every: { seconds: 10 },
jitterMs: 3_000, // Prevent thundering herd across cluster
priority: 100, // Low priority — never block critical crons
missedFire: "skip",
overlap: "run",
keepHistory: false,
handler: async (ctx: ICronContext) => {
ctx.log.debug("💓 Health check ping");
return { status: "ok" };
},
});Schedule
One-Shot (runAt)
import { Schedule, type IScheduleContext } from "oqronkit";
export const dataMigration = Schedule({
name: "data-migration-v2",
runAt: new Date("2026-12-01T00:00:00Z"),
guaranteedWorker: true,
priority: 0,
handler: async (ctx: IScheduleContext) => {
ctx.progress(50, "Migrating rows");
return { rowsMigrated: 54_000 };
},
});Repeating interval (every)
export const metricsAgg = Schedule({
name: "metrics-aggregation",
every: { minutes: 5 },
jitterMs: 15_000,
priority: 20,
overlap: "skip",
handler: async (ctx) => {
ctx.log("info", "📈 Aggregating metrics");
return { eventsProcessed: 142_000 };
},
});Recurring calendar (recurring)
export const quarterlyReview = Schedule({
name: "quarterly-financial-review",
recurring: {
frequency: "monthly",
dayOfMonth: 1,
at: { hour: 9, minute: 0 },
months: [1, 4, 7, 10],
},
timezone: "Europe/London",
condition: async (ctx) => {
const day = new Date().getDay();
return day > 0 && day < 6; // Skip weekends
},
handler: async (ctx) => {
return { mrr: 284_500, churnRate: 2.1 };
},
});iCalendar (rrule)
export const payrollRun = Schedule({
name: "payroll-processing",
rrule: "FREQ=MONTHLY;BYDAY=-1FR", // Last Friday of every month
guaranteedWorker: true,
priority: 0,
handler: async (ctx) => {
ctx.progress(40, "Calculating taxes");
ctx.progress(100, "Payroll complete");
return { employeesPaid: 324 };
},
});Dynamic Templates (.trigger())
Define a schedule with no timing — it only fires when you call .trigger() with a payload:
export const onboardingEmail = Schedule<{
userId: string;
template: string;
email: string;
}>({
name: "onboarding-email",
// No every/runAt/recurring/rrule — this is a TEMPLATE
handler: async (ctx) => {
const { userId, template, email } = ctx.payload;
ctx.log("info", `Sending ${template} to ${email}`);
return { sent: true };
},
});
// Usage from your application:
await onboardingEmail.trigger({
payload: { userId: "u_123", template: "welcome", email: "user@ex.com" },
});
// Schedule dynamically for the future using nameSuffix:
await onboardingEmail.trigger({
nameSuffix: "u_123-tips",
every: { days: 3 },
payload: { userId: "u_123", template: "day3-tips", email: "user@ex.com" },
});Durable fires (crash-safe scheduling)
By default a scheduled handler runs in-process, at-most-once: if the server dies mid-handler, that execution is lost (the schedule itself and all history survive — only the in-flight work is gone). For fires that must not be lost, set durable: true:
Cron({
name: "nightly-billing",
expression: "0 0 * * *",
durable: true, // ← every fire becomes a crash-safe job
retries: { max: 3, strategy: "exponential", baseDelay: 5_000 },
handler: runBilling,
});What changes
Instead of executing the handler, the scheduler enqueues a job — with an id derived from the slot's scheduled time — and the same at-least-once pipeline that powers queues executes it:
tick (leader) ──► ① enqueue job id = "cron:nightly-billing:<slotTs>" (deduplicated)
──► ② advance schedule pointer
│
▼ standard job pipeline (any node)
claim → run → heartbeat → retries → ack | dead-letterEvery crash window is covered:
| Crash at… | Recovery |
|---|---|
| Before enqueue | Slot unfired → missedFire policy applies as usual |
| Between enqueue and pointer-advance | Replay re-enqueues the same slot id → deduplicated, no double job |
| Job enqueued, node dies | Boot reconciler re-enqueues it to the broker |
| Mid-handler | Claim TTL expires → another node reclaims and retries |
| Retries exhausted | Job dead-letters (queue_dlq:cron:<name>); every attempt is a run |
No double execution
The scheduler never runs a durable handler in-process — one execution path, always. Slot-derived job ids make crash replays converge on the same job. cron: and schedule: are reserved queue prefixes: user Queue/Worker/Webhook names can't collide with the internal fire queues (enforced at definition time).
Semantics to know
- Per-node FIFO, cross-node at-least-once. On one node, fires of a definition serialize (concurrency 1). Across nodes, consecutive slots may overlap — durable handlers should be idempotent, exactly like queue handlers.
overlap/maxConcurrentare ignored in durable mode. - Latency: one durable write + one broker hop (a few ms) between the slot time and handler start.
- History: runs are recorded by the pipeline under
cron:<name>withmodule: 'cron'— attribution stays with the scheduler. - Works for
Scheduletoo, including dynamic.trigger()one-shots (they route through the base definition's durable queue).
Rule of thumb: monitoring pings, metrics rollups → default (at-most-once is
fine, zero overhead). Billing, emails, data mutations → durable: true.
Configuration Reference
| Option | Type | Description |
|---|---|---|
name | string | Unique schedule identifier |
expression | string | UNIX cron expression (cron only) |
every | EveryConfig | Interval: weeks, days, hours, minutes, seconds |
runAt | Date | One-shot execution at a specific time |
recurring | ScheduleRecurring | Semantic calendar builder |
rrule | string | RFC 5545 recurrence rule |
timezone | string | IANA timezone |
missedFire | 'skip' | 'run-once' | 'run-all' | Behavior for missed fires |
overlap | 'skip' | 'run' | Overlap handling |
jitterMs | number | Random jitter to prevent thundering herd |
priority | number | Lower = fires first |
version | number | Bump to trigger config migration |
rateLimiter | { check() } | Optional rate limit gate |
condition | (ctx) => boolean | Conditional execution (schedule only) |
maxConcurrent | number | Max parallel runs |
durable | boolean | Crash-safe at-least-once fires via the job pipeline (see Durable fires) |
environments | string[] | Environment allow-list — the definition is inert elsewhere (see Environments) |
logs | LogsOption | Opt-in persisted logs (see Observability) |
Missed Fire Policies
When a scheduled fire is missed (e.g. server was down during the scheduled time), the missedFire option controls recovery:
| Policy | Behavior |
|---|---|
'skip' | Ignore missed fires entirely |
'run-once' | Fire once for the most recent missed occurrence |
'run-all' | Fire once for each missed occurrence (capped by maxMissedRuns, default: 100) |
v0.0.2: missedFire: "run-all" now correctly enumerates all missed
occurrences using MissedFireHandler. In v0.0.1, it only fired once
regardless of how many ticks were missed.
Next Steps
- Rate Limiter — Protect schedules with rate limits
- Crash Safety — How schedules survive worker crashes