ostrio:cron-jobs

v6.5.0•Published 2 weeks ago

npm version npm downloads CI minified size Coverage License: BSD-3-Clause Node.js TypeScript Bun Meteor.js zero dependencies Sponsor Donate

JoSk

"JoSk" is a Node.js task manager for horizontally scaled apps: clusters, multi-server setups, and multi-threaded instances on one or many machines or data centers. It works the same in a single-instance app.

"JoSk" mimics the native setTimeout and setInterval API and supports CRON expressions. Tasks sync between all app instances through Redis, MongoDB, PostgreSQL, or a custom adapter. Storage-level leases and atomic claims give each due tick to one instance; delivery guarantees depend on the scheduling method.

Note: JoSk is the server-only package.

ToC

Main features

  • 🏢 Synchronize single task across multiple servers;
  • 🔏 Read locking to avoid simultaneous task executions across complex infrastructure;
  • 📦 Zero dependencies, written from scratch for top performance;
  • 👨‍🔬 ~97% tests coverage;
  • 💪 Bulletproof design, built-in retries, and "zombie" task recovery 🧟🔫.

Prerequisites

  • node@>=14.21.3. Newer drivers need newer Node; see tested runtimes
  • redis-server@>=5.0.0 or a single-writer KeyDB/Valkey server for RedisAdapter, with redis@^4 or redis@^5. CI targets selected standalone images; see the Redis guide for exact coverage.
  • mongod@>=4.4 for MongoAdapter, with the official mongodb NPM package (only the official driver is tested)
  • postgres@>=12 for PostgresAdapter, with pg@>=8.0.3 (pg@7 does not connect on Node 14+; pg@8.20 declares Node 16+)
  • bun@>=1.1.0 (optional), see Bun runtime

Older releases compatibility

  • node@<14.21.3 — use josk@^5 (Node 12 fails to parse ?. in 6.x)
  • mongod@<4.0.0 — use josk@=1.1.0
  • node@<14.20.0 — use josk@=3.0.2
  • node@<8.9.0 — use josk@=1.1.0

Install:

npm install josk --save
1// ES Module Style
2import { JoSk, RedisAdapter, MongoAdapter, PostgresAdapter } from 'josk';
3
4// CommonJS
5const { JoSk, RedisAdapter, MongoAdapter, PostgresAdapter } = require('josk');

Bun runtime

Since v6.0.0

JoSk runs unmodified on Bun >=1.1.0 with the same mongodb, pg, and redis drivers. Install with bun add josk. Node and Bun processes can share one prefix, since leases and claims live in storage. npm run test:bun runs the Jest suite under bun:test, see Running tests.

Agent Skill

JoSk ships an Agent Skill with the public API, adapter setup, execution semantics, CRON and handler patterns, and common pitfalls. It works with 50+ AI coding agents; pass -a claude-code to target one.

# Install the JoSk skill globally:
npx skills add veliovgroup/josk -g

# Or install the JoSk skill into the current project:
npx skills add veliovgroup/josk

The skill is distributed via GitHub and is not part of the npm tarball.

API:

new JoSk(opts)

  • opts.adapter {RedisAdapter|MongoAdapter|PostgresAdapter} - [Required] Instance of adapter or custom
  • opts.debug {Boolean} - [Optional] Enable debugging messages, useful during development
  • opts.autoClear {Boolean} - [Optional] Remove stored tasks that have no handler registered in this instance. Such tasks appear after a task is renamed or removed from code, or when instances with different codebases share one storage. Default: false
  • opts.zombieTime {Number} - [Optional] Interval recovery hold in milliseconds, used when a handler never calls ready() or its process dies. Caught handler errors go to onError and complete the run; they do not wait for zombie recovery. Keep it above the slowest legitimate handler runtime plus margin; below 60000 is not recommended. Default: 900000 (15 minutes)
  • opts.lockLeaseTime {Number} - [Optional] Scheduler lease TTL in milliseconds. Default: min(zombieTime, 30000). Any value is raised to at least 2 * maxRevolvingDelay + 1000; a non-positive or non-finite value throws. See v6.3 migration notes
  • opts.execute {String} - [Optional] batch drains all due tasks under one scheduler lease; one claims one task per lease. Default: batch
  • opts.concurrency {Number} - [Optional] Maximum handlers running in parallel in this instance. Use a positive integer when handlers share rate-limited resources; Infinity disables the cap. Default: Infinity
  • opts.lockOwnerId {String} - [Optional] Stable owner id used as the prefix of scheduler lease tokens, for observability. Default: josk- plus a random UUID per instance
  • opts.minRevolvingDelay {Number} - [Optional] Minimum delay between scheduler polls in milliseconds. Default: 128
  • opts.maxRevolvingDelay {Number} - [Optional] Maximum delay between scheduler polls in milliseconds. Default: 768
  • opts.onError {Function} - [Optional] Informational hook, called instead of throwing exceptions. Default: false. Called with two arguments:
    • title {String}
    • details {Object}
    • details.description {String}
    • details.error {Mix}
    • details.uid {String|null} - Internal uid, suitable for .clearInterval() and .clearTimeout(); null when the error is not tied to a task
    • details.task {Mix} - Present only for malformed-task errors; the offending task payload
  • opts.onExecuted {Function} - [Optional] Informational hook, called when a handler signals completion, including after a caught error. It does not indicate success. Default: false. Called with two arguments:
    • uid {String} - uid passed into .setImmediate(), .setTimeout(), or setInterval() methods
    • details {Object}
    • details.uid {String} - Internal uid, suitable for .clearInterval() and .clearTimeout()
    • details.date {Date} - Execution timestamp as JS {Date}
    • details.delay {Number} - Execution delay (e.g. interval for .setInterval())
    • details.timestamp {Number} - Execution timestamp as unix {Number}

Hook throws and async rejections are logged and isolated from scheduler execution.

new RedisAdapter(opts)

Since v5.0.0

  • opts.client {RedisClient} - [Required] Connected client, e.g. from await createClient().connect() or createCluster()
  • opts.prefix {String} - [Optional] use to create multiple named instances
  • opts.resetOnInit {Boolean} - [Optional] (use with caution) Delete all tasks for this prefix on init. For single-instance apps that need a clean start after a crash. Default: false
  • opts.useHashTags {Boolean} - [Optional] Use hash-tag keys (josk:{prefix}:*) so all adapter keys live in one slot. Required for Redis Cluster; the constructor throws for a cluster client without it. Default: false (josk:prefix:*)

new MongoAdapter(opts)

Since v5.0.0

  • opts.db {Db} - [Required] Db instance from MongoClient#db()
  • opts.prefix {String} - [Optional] use to create multiple named instances
  • opts.lockCollectionName {String} - [Optional] Lock collection name. Default: __JobTasks__.lock, shared by all JoSk instances. Use a separate name for JoSk 6 while JoSk 5 services still use the default, see MongoDB guide
  • opts.resetOnInit {Boolean} - [Optional] (use with caution) Delete all tasks for this prefix on init. For single-instance apps that need a clean start after a crash. Default: false

new PostgresAdapter(opts)

Since v6.0.0

  • opts.client {Pool|Client} - [Required] pg client with .query() method. Use Pool for long-running apps
  • opts.prefix {String} - [Optional] Isolated scheduler namespace in the same database. Default: default
  • opts.resetOnInit {Boolean} - [Optional] (use with caution) Delete tasks and locks for this prefix on init. Default: false

Initialization

JoSk has no dependencies. Install the driver for your adapter: redis, mongodb, or pg. To use other storage, write a custom adapter.

Redis Adapter

RedisAdapter keeps due timestamps in a sorted set and task payloads in a hash, and claims due tasks with Lua scripts.

  • Use one writable primary. Do not route JoSk reads or writes to replicas; claims must be visible to all instances at once.
  • For Redis Cluster, pass useHashTags: true.
  • KeyDB, Valkey, multi-data-center setups, and CI coverage are in the Redis guide.
1import { JoSk, RedisAdapter } from 'josk';
2import { createClient } from 'redis';
3
4const redisClient = await createClient({
5  url: 'redis://127.0.0.1:6379'
6}).connect();
7
8const jobs = new JoSk({
9  adapter: new RedisAdapter({
10    client: redisClient,
11    prefix: 'app-scheduler',
12    // useHashTags: true, // Redis Cluster; KeyDB/Valkey Cluster not CI-tested
13  }),
14  onError(reason, details) {
15    // Catches exceptions thrown inside scheduled tasks
16    console.log(reason, details.error);
17  }
18});

MongoDB Adapter

MongoAdapter creates one task collection per prefix (__JobTasks__<prefix>) and one lock collection (__JobTasks__.lock) shared by all prefixes.

1import { JoSk, MongoAdapter } from 'josk';
2import { MongoClient } from 'mongodb';
3
4const client = new MongoClient('mongodb://127.0.0.1:27017');
5// Use a separate DB from the "main" one to avoid lock contention
6const mongoDb = client.db('joskdb');
7const jobs = new JoSk({
8  adapter: new MongoAdapter({
9    db: mongoDb,
10    prefix: 'cluster-scheduler',
11  }),
12  onError(reason, details) {
13    // Catches exceptions thrown inside scheduled tasks
14    console.log(reason, details.error);
15  }
16});

PostgreSQL Adapter

Since v6.0.0

PostgresAdapter creates and migrates the josk_tasks, josk_locks, and josk_meta tables on init, in the client's current database and schema. Table names are fixed; prefix isolates namespaces.

  • Pass pg.Pool (recommended) or a connected pg.Client. Share the app's pool, or give the scheduler a small dedicated one. Schema setup runs in one transaction under pg_advisory_xact_lock, so it also works behind PgBouncer in transaction mode.
  • Use one writable primary. Do not route JoSk reads or writes to replicas.
  • Prefer a dedicated database or schema.
  • Keep resetOnInit: false in clustered production.
  • Both execute modes claim with FOR UPDATE SKIP LOCKED; batch takes 100 rows per query, one takes 1 row per lease.
1import { JoSk, PostgresAdapter } from 'josk';
2import { Pool } from 'pg';
3
4const pool = new Pool({
5  connectionString: 'postgres://user:pass@localhost:5432/joskdb'
6});
7
8const jobs = new JoSk({
9  adapter: new PostgresAdapter({
10    client: pool,
11    prefix: 'cluster-scheduler',
12  }),
13  onError(reason, details) {
14    // Catches exceptions thrown inside scheduled tasks
15    console.log(reason, details.error);
16  }
17});

Create the first task

1jobs.setInterval((ready) => {
2  /* ...code here... */
3  ready();
4}, 60 * 60000, 'task1h'); // every hour
5
6// No need to call ready() when the handler takes no arguments and returns a Promise
7jobs.setInterval(async () => {
8  await asyncMethod();
9}, 30 * 60000, 'asyncTask30m'); // every 30 mins

uid identifies a task across the cluster. Use a different uid for each schedule, even for the same function:

1jobs.setInterval(task, 60000, 'task-1m'); // every minute
2jobs.setInterval(task, 2 * 60000, 'task-2m'); // every two minutes

setInterval(func, delay, uid)

  • func {Function} - Function to call on schedule. Receives ready as the first argument
  • delay {Number} - Delay for the first run of a new task and interval between further executions in milliseconds
  • uid {String} - Unique app-wide task id
  • Returns: {Promise<string>}

The next run is scheduled when the handler calls ready(), so a run never overlaps the previous one:

1jobs.setInterval((ready) => {
2  /* ...code... */
3  ready();
4}, 60 * 60000, 'task1h'); // every hour + handler runtime
5
6jobs.setInterval((ready) => {
7  ready(); // schedule the next run first
8  /* ...code... */
9}, 60 * 60000, 'task1hStrict'); // every hour, the next run may start before this one ends

With callback APIs, call ready() on every path, including errors:

1jobs.setInterval((ready) => {
2  asyncCall((error, result) => {
3    if (error) {
4      ready(); // <-- Always call `ready()`, even if the call failed
5      return;
6    }
7
8    waitForSomethingElse(result, () => {
9      ready(); // <-- End of the full execution
10    });
11  });
12}, 0, 'longRunningTask'); // runs again as soon as the previous run is finished

When the app restarts and calls setInterval() again for a stored task, JoSk picks the next run from the stored state. A task is claimed while an instance runs it and has not yet called ready():

Stored taskNext run
None, or not claimed with a different delaynow + delay
Not claimed, same delayEarlier of the stored executeAt and now + delay. A past-due task runs on the first poll after boot
ClaimedUnchanged. It stays held until ready() or zombieTime expires, even if delay changed

To restart an unclaimed interval's countdown, call clearInterval() before registering it again. setTimeout() and setImmediate() always schedule from registration time.

ready() argument forms

ready returns Promise<boolean> and accepts:

  • ready() — schedule the next interval run at now + delay.
  • ready(date) — Date; schedule the next interval run at that moment. A date in the past falls back to now + delay. Use with CRON.
  • ready(timestamp) — ms-epoch number; same as ready(date).
  • ready(callback) — Node-style (error, success) => void. It fires before the storage update; await the returned Promise to wait for persistence.

Dates and timestamps only apply to setInterval. setTimeout and setImmediate tasks are removed from storage before their handler runs.

Calling ready() twice rejects the returned Promise (or passes an error to the callback) with "Resolution method is overspecified". Call it at most once per run.

When a handler takes no arguments (async () => { … } or () => doSomething()), JoSk calls ready() when its returned Promise settles.

setTimeout(func, delay, uid)

  • func {Function} - Function to call after delay. Receives ready as the first argument
  • delay {Number} - Delay in milliseconds
  • uid {String} - Unique app-wide task id
  • Returns: {Promise<string>}

Runs at most once across the cluster. Use it where a duplicate run is worse than a missed one.

1jobs.setTimeout((ready) => {
2  asyncCall(() => {
3    /* ...code... */
4    ready();
5  });
6}, 60000, 'taskIn1m');
7
8jobs.setTimeout(async () => {
9  await asyncMethod();
10}, 60000, 'asyncTaskIn1m');

setImmediate(func, uid)

  • func {Function} - Function to execute. Receives ready as the first argument
  • uid {String} - Unique app-wide task id
  • Returns: {Promise<string>}

Runs once, as soon as the next scheduler poll claims it. At most once across the cluster, like setTimeout.

1jobs.setImmediate((ready) => {
2  /* ...code... */
3  ready();
4}, 'syncTask');
5
6jobs.setImmediate(async () => {
7  await asyncMethod();
8}, 'asyncTask');

clearInterval(timerId)

  • timerId {String|Promise<string>} — Timer id returned from JoSk#setInterval() method
  • Returns: {Promise<boolean>} true when task is successfully cleared, or false when task was not found
1const timer = await jobs.setInterval(func, 34789, 'unique-taskid');
2await jobs.clearInterval(timer);

clearTimeout(timerId)

  • timerId {String|Promise<string>} — Timer id returned from JoSk#setTimeout() method
  • Returns: {Promise<boolean>} true when task is successfully cleared, or false when task was not found
1const timer = await jobs.setTimeout(func, 34789, 'unique-taskid');
2await jobs.clearTimeout(timer);

destroy()

  • Returns: {boolean} true if instance successfully destroyed, false if instance already destroyed

Stops this instance's scheduler. After destroy(), set* methods resolve '' and report to onError (or to the debug log with debug: true); pause() and resume() return false. ping(), clearTimeout(), clearInterval(), and shutdown() still work.

destroy() does not wait for running handlers. Tasks this instance claimed but had not started go back to storage. An interval killed mid-run is recovered by another instance after zombieTime; use shutdown() before process exit to avoid that wait.

shutdown(opts)

Since v6.4.0

  • opts.timeout {Number} - [Optional] Milliseconds to wait for running handlers to call ready(). Default: 10000
  • Returns: {Promise<boolean>} true if every running handler finished within timeout

Calls destroy(), waits for running handlers, then hands unfinished interval claims back to storage so another instance runs them on its next poll instead of after zombieTime. When timeout expires, JoSk reports every unfinished handler to onError (or console.error), hands current interval claims back, and abandons one-shot tasks to preserve at-most-once behavior. Keep at-most-once handlers idempotent, or set timeout longer than the longest one-shot handler; keep it below your platform's termination grace period. Repeated calls, late ready() calls, and timing headroom are covered in shutdown details.

1const shutdown = async () => {
2  await jobs.shutdown({ timeout: 10000 });
3  process.exit(0);
4};
5
6process.on('SIGTERM', shutdown);
7process.on('SIGINT', shutdown);

ping()

  • Returns: {Promise<object>}

Checks scheduler readiness and the storage connection.

1const pingResult = await jobs.ping();
2// OK:     { status: 'OK', code: 200, statusCode: 200 }
3// Failed: { status: 'Internal Server Error', code: 500, statusCode: 500, error: ErrorObject }
4// MongoAdapter can also return { status: 'Service Unavailable', code: 503, statusCode: 503, error: ErrorObject }

pause()

  • Signature: pause() or pause(timerId)
  • Returns: {boolean} true if pause state changed, false if already paused for that scope or the instance is destroyed

For multi-instance setups with long-running handlers only. Other instances keep running tasks while this one is paused. Single-instance apps and short handlers gain nothing.

  • pause() — this instance stops taking the scheduler lease. Running handlers continue, and tasks stay in storage.
  • pause(timerId) — when this instance claims that task, it hands it back due in 2 seconds without running the handler, so another instance can run it. Pass the timer id returned by set*; a bare uid throws.
1if (loadSheddingActive()) {
2  jobs.pause(); // stop competing while saturated
3}
4const heavy = await jobs.setInterval(worker, 60_000, 'heavy-sync');
5jobs.pause(heavy); // only skip this task on this instance

resume()

  • Signature: resume() or resume(timerId)
  • Returns: {boolean} true if pause cleared, false if not paused or the instance is destroyed

resume() clears the global pause; resume(timerId) clears a per-task pause. The instance competes again on its next poll.

1jobs.resume();        // global
2jobs.resume(heavy);   // per timer id from set*

Queue-polling pattern: after winning a tick, claim rows from your own queue, call pause(), call ready(), process the rows, then resume() in finally. JoSk releases the tick quickly and this instance stops competing until local work ends. Full examples: skills/josk/references/patterns.md.

Execution semantics

MethodGuaranteeNotes
setTimeout, setImmediateAt-most-once across the clusterTask is removed from storage before the handler runs. If the process dies mid-run, the run is lost.
setIntervalAt-least-once per tick (until cleared)Task stays in storage while running. If ready() is not called within zombieTime, another instance may claim and run it again. Make handlers idempotent.

zombieTime (default 15 minutes) is the safety net for stuck handlers. Set it above your slowest legitimate handler plus storage latency. Restarting an app does not shorten a running interval's hold. So after an unclean kill, recovery waits the full zombieTime, not delay, but a rolling deploy cannot start a second copy of a handler that is still running. Call shutdown() on SIGTERM to hand running claims back instead of leaving them to zombieTime. JoSk skips a late ready() write when a newer same-uid run starts in the same process. Built-in adapters also fence writes across instances; custom adapters need equivalent fencing for that guarantee. See adapter-api.md and monitoring and recovery.

  • execute: 'batch' (default) claims all due tasks under one lease, for throughput. execute: 'one' claims one task per lease, for smaller bursts and fairer spread across instances.
  • concurrency caps parallel handlers in this instance. Default Infinity, like Node's timers. Set a limit when handlers share DB connections or API rate limits.
  • pause() and pause(timerId) are per process. Combine them with concurrency and execute: 'one' when one instance should back off during long local work.

TypeScript

JoSk ships declarations for ESM (index.d.ts) and CommonJS (index.d.cts). They don't import redis or mongodb, so unused drivers are not needed. RedisAdapter and MongoAdapter keep your client's full type on adapter.client / adapter.db. The JoSkAdapter interface is exported for custom adapters, and RedisAdapterOption, MongoAdapterOption, PostgresAdapterOption for building adapter configuration.

1import { JoSk, RedisAdapter } from 'josk';
2import type { JoSkAdapter, JoSkOption, JoSkOnError } from 'josk';
3import { createClient } from 'redis';
4
5const onError: JoSkOnError = async (title, details) => {
6  console.error(title, details.error, details.uid);
7};
8
9const adapter: JoSkAdapter = new RedisAdapter({
10  client: await createClient({ url: process.env.REDIS_URL }).connect(),
11  prefix: 'cluster-scheduler'
12});
13
14const options: JoSkOption = { adapter, execute: 'batch', concurrency: 16, onError };
15const jobs = new JoSk(options);

Examples

CRON

Use cron-parser@^5 to compute each next run and pass it to ready(date). cron-parser@5 needs Node 18+; on older Node use cron-parser@4 and parser.parseExpression(cronExpr):

1import { CronExpressionParser } from 'cron-parser';
2
3const setCron = (uid, cronExpr, task) => {
4  const nextRun = () => CronExpressionParser.parse(cronExpr).next().toDate();
5
6  return jobs.setInterval(async (ready) => {
7    try {
8      await task();
9    } finally {
10      await ready(nextRun()); // schedule the next run even if the task fails
11    }
12  }, Math.max(0, +nextRun() - Date.now()), uid);
13};
14
15await setCron('daily-report', '0 9 * * *', () => sendReport());

Errors thrown by task still reach onError. For second-level CRON expressions, raise minRevolvingDelay / maxRevolvingDelay (e.g. 512 / 1000) to cut storage reads.

Pass arguments

Wrap the task in a closure:

1const task = (arg1, arg2, ready) => {
2  //... code here
3  ready();
4};
5
6jobs.setInterval((ready) => task({ key: 'value' }, 'A', ready), 60 * 60000, 'taskA');
7jobs.setInterval((ready) => task({ key: 'other' }, 'B', ready), 60 * 60000, 'taskB');

Clean up old tasks

For development and tests. Commands for Redis, MongoDB, and PostgreSQL: docs/testing.md.

MongoDB connection fine tuning

Replica-set tuning, dedicated-DB advice, the index list, and Mongo-compatible service notes: docs/mongodb.md.

Notes

  • Use JoSk when several copies of an app run the same repeating tasks and each due tick must run once cluster-wide, not once per instance. Examples: email and SMS queues, long polling, periodic sync.
  • Keep task intervals at 2 seconds or more when timing matters. Shorter tasks may overlap with the storage round-trip plus poll delay. delay: 0 is valid and runs the task again after ready().
  • Accuracy is delay ± (maxRevolvingDelay + storage latency): about ±0.8s plus storage latency with defaults. Tighter bounds cost more storage reads.
  • Poll delays are random within minRevolvingDelay..maxRevolvingDelay so instances don't hit storage at the same moment. Defaults (128..768) suit a 3-server setup. A higher minRevolvingDelay reduces storage reads and writes.

Running tests

Setup, full and targeted suites, coverage, and the Bun runner: docs/testing.md. Quickstart:

REDIS_URL="…" MONGO_URL="…" PG_URL="…" npm test

Why JoSk?

JoSk is Job-Task - Is randomly generated name by "uniq" project

Support our open source contribution: