JCC Express

Digging Deeper

Task Scheduling

Introduction

JCC Express MVC ships with a Laravel-inspired task scheduler. Define recurring tasks — callbacks, Artisan commands, or raw shell commands — using a fluent builder, then drive them with a single long-running process.

Tasks are defined in app/Console/Schedule.ts and started via the Artisan CLI. The scheduler checks for due tasks every 60 seconds. When at least one sub-minute task is registered, the tick automatically drops to every 1 second.


Defining tasks

Open app/Console/Schedule.ts and register tasks inside the exported schedule function:

TypeScript
import { Schedule } from "../../jcc-express-mvc/Core/Schedule";

export function schedule(schedule: Schedule) {
  schedule.call(async () => {
    await cleanExpiredSessions();
  }).everyMinute().description("Clean expired sessions");

  schedule.artisan("queue:work").hourly();

  schedule.exec("node scripts/backup.js").dailyAt("2:30");
}

The file is loaded automatically by bun artisanNode schedule:run.


Starting the scheduler

Bash
# Runs continuously — checks every 60 s (1 s when sub-minute tasks exist)
bun artisanNode schedule:run

# List all registered tasks without running them
bun artisanNode schedule:list

# Run once and exit (useful for system cron)
bun artisanNode schedule:run:once

For production you can also drive it from the system crontab:

* * * * * cd /path/to/app && bun artisanNode schedule:run:once

Registering tasks

Four methods are available on the Schedule instance:

TypeScript
// Async callback
schedule.call(async () => { ... });

// Artisan command — prepends "bun artisanNode" automatically
schedule.artisan("cache:clear");
schedule.artisan("queue:work");

// Artisan command with args
schedule.command("make:model", ["User"]);

// Raw shell command — no artisanNode prefix
schedule.exec("node scripts/export.js");
schedule.exec("node", ["scripts/export.js", "--force"]);

Frequency options

All methods are chainable on the builder returned by call(), command(), artisan(), or exec().

Sub-minute

Sub-minute tasks switch the scheduler tick to every 1 second automatically.

TypeScript
schedule.call(fn).everySecond();
schedule.call(fn).everyTwoSeconds();
schedule.call(fn).everyFiveSeconds();
schedule.call(fn).everyTenSeconds();
schedule.call(fn).everyFifteenSeconds();
schedule.call(fn).everyTwentySeconds();
schedule.call(fn).everyThirtySeconds();

Minutes

TypeScript
schedule.call(fn).everyMinute();
schedule.call(fn).everyTwoMinutes();
schedule.call(fn).everyThreeMinutes();
schedule.call(fn).everyFourMinutes();
schedule.call(fn).everyFiveMinutes();
schedule.call(fn).everyTenMinutes();
schedule.call(fn).everyFifteenMinutes();
schedule.call(fn).everyThirtyMinutes();
schedule.call(fn).everyMinutes(7);      // every 7 minutes (arbitrary)

Hours

TypeScript
schedule.call(fn).hourly();
schedule.call(fn).hourlyAt(17);         // at :17 of every hour
schedule.call(fn).everyOddHour();       // 1am, 3am, 5am … 11pm
schedule.call(fn).everyOddHour(30);     // :30 past every odd hour
schedule.call(fn).everyTwoHours();
schedule.call(fn).everyTwoHours(15);    // :15 past every 2 hours
schedule.call(fn).everyThreeHours();
schedule.call(fn).everyFourHours();
schedule.call(fn).everySixHours();

Days

TypeScript
schedule.call(fn).daily();
schedule.call(fn).dailyAt("13:00");
schedule.call(fn).twiceDaily(1, 13);            // 01:00 and 13:00
schedule.call(fn).twiceDailyAt(1, 13, 15);      // 01:15 and 13:15

Day of month

TypeScript
schedule.call(fn).daysOfMonth([1, 10, 20]);      // 1st, 10th, and 20th
schedule.call(fn).lastDayOfMonth();              // last day of every month
schedule.call(fn).lastDayOfMonth("15:00");       // last day at 15:00

Weeks

TypeScript
schedule.call(fn).weekly();                      // Sunday at 00:00
schedule.call(fn).weeklyOn(1, "8:00");           // Monday at 08:00

Months

TypeScript
schedule.call(fn).monthly();                     // 1st at 00:00
schedule.call(fn).monthlyOn(4, "15:00");         // 4th at 15:00
schedule.call(fn).twiceMonthly(1, 16, "13:00");  // 1st and 16th at 13:00

Quarters and years

TypeScript
schedule.call(fn).quarterly();                   // 1st of Jan, Apr, Jul, Oct
schedule.call(fn).quarterlyOn(4, "14:00");       // 4th day of each quarter
schedule.call(fn).yearly();                      // 1 January at 00:00
schedule.call(fn).yearlyOn(6, 1, "17:00");       // 1 June at 17:00

Custom cron expression

TypeScript
schedule.call(fn).cron("0 6,12,18 * * *");  // 06:00, 12:00, 18:00
schedule.call(fn).cron("30 8 * * 1-5");     // 08:30 on weekdays

Day constraints

Named day shortcuts

TypeScript
schedule.call(fn).daily().sundays();
schedule.call(fn).daily().mondays();
schedule.call(fn).daily().tuesdays();
schedule.call(fn).daily().wednesdays();
schedule.call(fn).daily().thursdays();
schedule.call(fn).daily().fridays();
schedule.call(fn).daily().saturdays();

Day groups

TypeScript
schedule.call(fn).daily().weekdays();         // Monday–Friday
schedule.call(fn).daily().weekends();         // Saturday and Sunday
schedule.call(fn).daily().days([1, 3, 5]);   // Mon, Wed, Fri

Time of day — at()

Override just the hour and minute on the current expression:

TypeScript
schedule.call(fn).weekly().at("10:30");       // every week at 10:30
schedule.call(fn).weekdays().at("9:00");      // weekdays at 09:00

Time window constraints

between(startHour, endHour)

Only run while the current hour is inside the window:

TypeScript
// Run every 15 minutes but only between 09:00 and 17:00
schedule.call(fn).everyFifteenMinutes().between(9, 17);

unlessBetween(startHour, endHour)

Skip the task while the current hour is inside the window. Supports windows that wrap midnight:

TypeScript
// Run every minute, but skip between 23:00 and 06:00
schedule.call(fn).everyMinute().unlessBetween(23, 6);

// Skip during business hours
schedule.call(fn).everyFiveMinutes().unlessBetween(9, 17);

Conditional execution

when(condition)

Only run when the condition returns true. Accepts both sync and async functions:

TypeScript
// Sync
schedule.call(fn).daily().when(() => isFeatureEnabled("reports"));

// Async
schedule.call(fn).hourly().when(async () => {
  const pending = await DB.table("jobs").where("status", "pending").count();
  return pending > 0;
});

If the condition returns false the task is skipped entirely — no locks are acquired.


Environment constraints

environments(...envs)

Restrict a task to specific environments. Reads APP_ENV, falling back to NODE_ENV, case-insensitive:

TypeScript
// Only run in production
schedule.call(fn).daily().environments("production");

// Run in production or staging
schedule.call(fn).hourly().environments("production", "staging");

Timezones

Evaluate the task's schedule in a specific timezone:

TypeScript
schedule.call(fn).dailyAt("8:00").timezone("America/New_York");

Preventing overlaps

By default a task can start again even if the previous run is still in progress. withoutOverlapping() skips the new run instead:

TypeScript
schedule.call(longRunningJob).everyMinute().withoutOverlapping();

A lock file at storage/framework/schedule/overlap-<hash>.lock holds the running PID. If the process died without releasing the lock, the scheduler detects the stale PID and takes over automatically.


Background execution

Run a task without blocking the scheduler loop:

TypeScript
schedule.exec("node scripts/heavy.js").hourly().runInBackground();

Combine with withoutOverlapping() — the overlap lock is still acquired and released correctly:

TypeScript
schedule.call(fn).hourly().withoutOverlapping().runInBackground();

Single-server tasks

In a multi-server setup, ensure only one server runs the task:

TypeScript
schedule.call(fn).daily().onOneServer();

Uses storage/framework/schedule/server-<hash>.lock. Other servers see the file and skip the task as long as the owning PID is alive.

Combine with withoutOverlapping() — both locks must be acquired:

TypeScript
schedule.call(fn).hourly().onOneServer().withoutOverlapping();

Task descriptions

Add a label used in logs and schedule:list:

TypeScript
schedule.call(fn).daily().description("Generate daily report");

How locks work

Lock typeFileReleased when
overlapstorage/framework/schedule/overlap-<hash>.lockTask finishes (success or error)
serverstorage/framework/schedule/server-<hash>.lockTask finishes (success or error)

Acquisition steps:

  1. Create the file atomically (O_EXCL).
  2. If the file already exists, read its PID and check if that process is alive.
  3. If the process is dead, overwrite the stale lock and proceed.

Locks are also released on graceful shutdown (SIGINT / SIGTERM).


Full example

TypeScript
// app/Console/Schedule.ts
import { Schedule } from "../../jcc-express-mvc/Core/Schedule";

export function schedule(schedule: Schedule) {
  // Health-check ping every 30 seconds
  schedule.call(async () => {
    await ping("https://health.example.com");
  }).everyThirtySeconds().description("Health check");

  // Purge tokens every 5 minutes, skip overlapping runs
  schedule.call(async () => {
    await purgeExpiredTokens();
  })
    .everyFiveMinutes()
    .withoutOverlapping()
    .description("Purge expired tokens");

  // Daily report at 06:00, only on one server, only in production
  schedule.artisan("report:generate")
    .dailyAt("6:00")
    .onOneServer()
    .environments("production")
    .description("Daily report");

  // Heavy sync — runs in background, no overlaps
  schedule.exec("node scripts/sync.js")
    .hourly()
    .runInBackground()
    .withoutOverlapping()
    .description("Hourly data sync");

  // Morning digest — weekdays at 09:00, only when there are subscribers
  schedule.call(async () => {
    await sendMorningDigest();
  })
    .daily()
    .at("9:00")
    .weekdays()
    .when(async () => {
      const count = await DB.table("subscribers").where("active", true).count();
      return count > 0;
    })
    .description("Morning digest email");

  // Midnight maintenance window — skip during business hours
  schedule.call(async () => {
    await rebuildSearchIndex();
  })
    .everyThirtyMinutes()
    .unlessBetween(9, 17)
    .description("Search index rebuild");

  // End-of-month rollup
  schedule.call(async () => {
    await generateMonthlyRollup();
  })
    .lastDayOfMonth("23:30")
    .description("Monthly rollup");
}