ostrio:cron-jobs

v6.4.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;
  • 👨‍🔬 ~99% tests coverage;
  • 💪 Bulletproof design, built-in retries, and "zombie" task recovery 🧟🔫.

Prerequisites

  • node@>=20.9.0
  • 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 Redis Adapter 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+)
  • bun@>=1.1.0 (optional), see Bun runtime

Older releases compatibility

  • node@<20.9.0 — use josk@^5
  • 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), floored at 2 * maxRevolvingDelay + 1000. 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 and for re-claiming this instance's leases after a planned restart. Default: crypto.randomUUID() 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} - Internal uid, suitable for .clearInterval() and .clearTimeout()
    • 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
  • 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. It uses the Redis-compatible commands shared by Redis, KeyDB, and Valkey; CI targets Redis 6/7/8, eqalpha/keydb:x86_64_v6.3.4, and valkey/valkey:8.1.9-alpine in standalone mode using redis@5.

  • 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; CI tests a 3-master Redis Cluster. KeyDB/Valkey Cluster modes are not separately tested.
  • Avoid KeyDB active-replication (multi-master). Its conflict resolution can let two writers claim the same task.
  • For strict single-claim scheduling across data centers, use a strongly consistent store, or PostgreSQL with one write authority.
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 two collections per prefix: one for tasks and one with the .lock suffix for scheduler locks.

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 and josk_locks tables on init, in the client's current database and schema. Table names are fixed; prefix isolates namespaces.

  • Use pg.Pool. Share the app's pool, or give the scheduler a small dedicated one.
  • 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.
  • execute: 'batch' claims due tasks with FOR UPDATE SKIP LOCKED; execute: 'one' uses LIMIT 1 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(), only clearTimeout() and clearInterval() work; other methods report an error to onError (or stdout).

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. A handler that calls ready() after its claim was handed back does not change the schedule; onExecuted still fires because the handler ran. Repeated calls share the first shutdown attempt and timeout; when it 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. The latest superseded handler of each task also counts as unfinished, but its obsolete claim is left unchanged; older superseded handlers are not tracked. 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.

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: 'Error reason', code: 500, statusCode: 500, error: ErrorObject }

pause()

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

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 reschedules it without running the handler, so another instance can run it. Pass the timer id returned by set*, not the bare uid.
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

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.

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):

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.

Clean up Redis

redis-cli --no-auth-warning --scan --pattern "josk:default:*" | xargs redis-cli --raw --no-auth-warning DEL

# If you're using multiple JoSk instances with prefix:
redis-cli --no-auth-warning --scan --pattern "josk:prefix:*" | xargs redis-cli --raw --no-auth-warning DEL

# If useHashTags is true:
redis-cli --no-auth-warning --scan --pattern "josk:{prefix}:*" | xargs redis-cli --raw --no-auth-warning DEL

Clean up MongoDB

1// Run directly in MongoDB console (default prefix `default`):
2db.getCollection('__JobTasks__default').deleteMany({});
3// If you're using a custom prefix:
4db.getCollection('__JobTasks__PrefixHere').deleteMany({});
5// Lock collection (shared across prefixes by default):
6db.getCollection('__JobTasks__.lock').deleteMany({});

Clean up PostgreSQL

DELETE FROM josk_tasks WHERE prefix = 'default';
DELETE FROM josk_locks WHERE lock_key = 'josk-default.lock';

-- If you're using custom prefix:
DELETE FROM josk_tasks WHERE prefix = 'cluster-scheduler';
DELETE FROM josk_locks WHERE lock_key = 'josk-cluster-scheduler.lock';

MongoDB connection fine tuning

Replica-set tuning, dedicated-DB advice, the index list, and Mongo-compatible service notes: docs/mongodb.md. Cosmos DB and DocumentDB aren't part of default CI; optional endpoint tests require configured secrets.

Prefix mapping

prefix isolates scheduler state. Instances with the same prefix share one queue. Default: default.

AdapterStorage layout for prefix: 'app'Notes
RedisDefault keys: josk:app:schedule, josk:app:tasks, josk:app:lock. With useHashTags: true: josk:{app}:schedule, josk:{app}:tasks, josk:{app}:lock.Hash tags keep all keys on the same Cluster slot. Prefix must match /^[A-Za-z0-9_\-:.]+/ — special characters (notably { and }) are rejected to protect Cluster routing.
MongoDBCollection __JobTasks__app; lock collection __JobTasks__.lock (shared across prefixes, scoped by uniqueName field)Override the lock collection with lockCollectionName. Keep collection names short — Mongo's name limit is 120 characters including database name.
PostgreSQLRows in josk_tasks filtered by prefix='app'; lock row in josk_locks with lock_key='josk-app.lock'Table names are fixed. Use prefix for tenant/environment isolation.

Operational FAQ

How do I monitor stuck tasks?

An interval that never calls ready() becomes claimable again after zombieTime. This recovery does not fire onError. The 'One of your tasks is missing' error means this instance claimed a task it has no handler for. Past-due tasks show backlog, not stuck work. To find claims near their recovery deadline, see monitoring.

How do I handle storage restarts?

JoSk catches adapter errors and retries on the next poll. Locks held by crashed nodes expire on their own (Redis: PEXPIRE, Mongo: TTL index, Postgres: locked_until against server time).

one vs batch execute mode?

Use batch for throughput; it claims due tasks in chunks. Use one for smaller bursts per instance, fairer spread across instances, or when handlers contend on the same downstream resource.

Jitter: Why is my interval running every delay + maxRevolvingDelay ms?

JoSk polls every minRevolvingDelay to maxRevolvingDelay ms, so the effective interval is delay plus poll latency. Lower maxRevolvingDelay for tighter intervals at the cost of more storage reads.

What about clock skew between nodes?

Redis uses relative PX TTLs. Postgres computes lease expiry from CURRENT_TIMESTAMP, so node clock skew does not change lock lifetime. Mongo stores app-generated dates; keep Mongo app nodes time-synchronized.

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. Shorter tasks may overlap with the storage round-trip plus poll delay.
  • 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: