OqronKitOqronKit

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()
TriggerTime-driven (expression/every)Data-driven (API-triggered)
PayloadNoneTyped generic payload
Dynamic dispatchNo.trigger() / .schedule()
ConditionsNocondition: async (ctx) => boolean
Best forSweeps, cleanup, metricsDrip 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:

  1. The winner becomes the Master Poller — only it checks for due schedules
  2. If the leader crashes, the lock expires within ~3 seconds
  3. A standby node acquires the lock and takes over
  4. Handlers still execute on the leader node (not distributed to workers)

Cron

Expression-based

triggers/crons.ts
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)

triggers/scheduler.ts
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-letter

Every crash window is covered:

Crash at…Recovery
Before enqueueSlot unfired → missedFire policy applies as usual
Between enqueue and pointer-advanceReplay re-enqueues the same slot id → deduplicated, no double job
Job enqueued, node diesBoot reconciler re-enqueues it to the broker
Mid-handlerClaim TTL expires → another node reclaims and retries
Retries exhaustedJob 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/maxConcurrent are 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> with module: 'cron' — attribution stays with the scheduler.
  • Works for Schedule too, 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

OptionTypeDescription
namestringUnique schedule identifier
expressionstringUNIX cron expression (cron only)
everyEveryConfigInterval: weeks, days, hours, minutes, seconds
runAtDateOne-shot execution at a specific time
recurringScheduleRecurringSemantic calendar builder
rrulestringRFC 5545 recurrence rule
timezonestringIANA timezone
missedFire'skip' | 'run-once' | 'run-all'Behavior for missed fires
overlap'skip' | 'run'Overlap handling
jitterMsnumberRandom jitter to prevent thundering herd
prioritynumberLower = fires first
versionnumberBump to trigger config migration
rateLimiter{ check() }Optional rate limit gate
condition(ctx) => booleanConditional execution (schedule only)
maxConcurrentnumberMax parallel runs
durablebooleanCrash-safe at-least-once fires via the job pipeline (see Durable fires)
environmentsstring[]Environment allow-list — the definition is inert elsewhere (see Environments)
logsLogsOptionOpt-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:

PolicyBehavior
'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

On this page