JoSk
"JoSk" is a Node.js task manager for horizontally scaled apps and apps that would need to scale horizontally quickly at some point of growth.
"JoSk" mimics the native API of setTimeout and setInterval and supports CRON expressions. All queued tasks are synced between all running application instances via Redis, MongoDB, or a custom adapter.
The "JoSk" package is made for a variety of horizontally scaled apps, such as clusters, multi-servers, and multi-threaded Node.js instances, that are running either on the same or different machines or even different data centers. "JoSk" ensures exactly-one execution of each task across all running instances of the application.
"JoSk" is not just for multi-instance apps. It seamlessly integrates with single-instance applications as well, showcasing its versatility and adaptability.
Note: JoSk is the server-only package.
ToC
- Main features
- Prerequisites
- Install as NPM package
- API
- Execution semantics
- TypeScript
- Examples
- Prefix mapping per adapter
- Operational FAQ
- Migration guide (v4 → v5)
- Migration guide (v5 → v6)
- Important notes
- ~99% tests coverage
- Why it's named "JoSk"
- Support Section
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
redis-server@>=5.0.0or KeyDB — for RedisAdapter (requiresredisNPM package, bothredis@^4andredis@^5are supported). KeyDB and Valkey are supported with the same single-writer topology.mongod@>=4.0.0— for MongoAdapter (requires the officialmongodbNPM package; the adapter is tested only against the official driver)postgres@>=12— for PostgresAdapter (requirespgNPM package)node@>=20.9.0— Node.js versionbun@>=1.1.0— optional, runs the same package and the same Jest suite under Bun viabun:test(see Bun runtime section)
Older releases compatibility
node@<20.9.0— usejosk@^5mongod@<4.0.0— usejosk@=1.1.0node@<14.20.0— usejosk@=3.0.2node@<8.9.0— usejosk@=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. The package is pure ESM, has no Node-only globals beyond node:crypto.randomUUID() (which Bun ships natively), and the official mongodb, pg, and redis drivers all work under Bun. Install with bun add josk and import the same way:
1import { JoSk, RedisAdapter, MongoAdapter, PostgresAdapter } from 'josk';
The full Jest test suite (test/jest/) doubles as the Bun test suite — bun test ./test/jest/ runs every core and adapter test under Bun's bun:test runner. See Running Tests.
Notes:
- Use the same adapter packages as on Node (
mongodb,pg,redis). - Schedulers running across mixed Node and Bun processes coexist under the same prefix; lease acquisition and task claiming are storage-level operations and runtime-agnostic.
- Bun's standalone executables (
bun build --compile) bundle JoSk like any ESM library.
Agent Skill
JoSk ships an Agent Skill — the open, cross-tool standard for teaching AI coding agents about a library. The source lives in skills/josk/ and follows the standard SKILL.md + references/ layout, so it installs into 50+ supported agents from one command via the npx skills CLI.
Install into every supported agent on your machine in one go:
npx skills add veliovgroup/josk
Detected and supported agents include Claude Code, Codex CLI, Cursor, Windsurf, GitHub Copilot, Cline, Continue, Roo Code, OpenCode, Goose, Aider, Gemini CLI, Kimi CLI, Tabnine, Qwen Code, Antigravity, Replit, Devin, and many others. The CLI auto-detects which are installed and drops the skill into each agent's native skills directory (.claude/skills/, .cursor/skills/, .codex/skills/, …). No per-agent format conversion — the same SKILL.md is read by every host.
Once installed, the agent loads the full public API, adapter setup, execution semantics, CRON and handler patterns, Meteor integration, and the operational FAQ as context whenever you write or review JoSk-related code. Triggers include: JoSk by name, scheduled / recurring jobs, cron-style tasks, setInterval / setTimeout work in clustered Node.js or Bun deployments, the RedisAdapter / MongoAdapter / PostgresAdapter, the Meteor ostrio:cron-jobs package, exactly-once / at-most-once execution, zombie-task recovery, or scheduler tuning (zombieTime, execute, concurrency).
Alternative install paths:
# From a local clone of this repo (offline / pre-publish) npx skills add ./skills/josk # Browse and pick interactively first npx skills add veliovgroup/josk --list
The skill source is not shipped in the npm tarball — it's distributed via GitHub and consumed only by AI tooling.
API:
Constructor options for JoSk, RedisAdapter, MongoAdapter, PostgresAdapter
new JoSk(opts)
opts.adapter{RedisAdapter|MongoAdapter|PostgresAdapter} - [Required] Instance of adapter or customopts.debug{Boolean} - [Optional] Enable debugging messages, useful during developmentopts.autoClear{Boolean} - [Optional] Remove (Clear) obsolete tasks (any tasks which are not found in the instance memory (runtime), but exists in the database). Obsolete tasks may appear in cases when it wasn't cleared from the database on process shutdown, and/or was removed/renamed in the app. Obsolete tasks may appear if multiple app instances running different codebase within the same database, and the task may not exist on one of the instances. Default:falseopts.zombieTime{Number} - [Optional] time in milliseconds, after this time - task will be interpreted as "zombie". This parameter allows to rescue task from "zombie mode" in case when:ready()wasn't called, exception during runtime was thrown, or caused by bad logic. WhileresetOnInitoption helps to make sure tasks aredoneon startup,zombieTimeoption helps to solve same issue, but during runtime. Default value is900000(15 minutes). It's not recommended to set this value to below60000(one minute)opts.execute{String} - [Optional] due-task execution mode. Useoneto claim and run one task per scheduler lease, orbatchto drain all currently due tasks under same lease. Default:batchopts.concurrency{Number} - [Optional] maximum number of task handlers that can run in parallel. Use a positive integer to cap parallelism (useful when handlers share rate-limited resources like the same DB the adapter uses); useInfinityto disable throttling. Default:Infinityopts.lockOwnerId{String} - [Optional] stable owner id for scheduler lease tokens. Useful for observability (lease IDs include this prefix) and for re-claiming this instance's leases after a planned restart. Default: auto-generated perJoSkinstance viacrypto.randomUUID()opts.minRevolvingDelay{Number} - [Optional] Minimum revolving delay — the minimum delay between tasks executions in milliseconds. Default:128opts.maxRevolvingDelay{Number} - [Optional] Maximum revolving delay — the maximum delay between tasks executions in milliseconds. Default:768opts.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} - Internaluid, suitable for.clearInterval()and.clearTimeout()
opts.onExecuted{Function} - [Optional] Informational hook, called when task is finished. Default:false. Called with two arguments:uid{String} -uidpassed into.setImmediate(),.setTimeout(), orsetInterval()methodsdetails{Object}details.uid{String} - Internaluid, suitable for.clearInterval()and.clearTimeout()details.date{Date} - Execution timestamp as JS {Date}details.delay{Number} - Executiondelay(e.g.intervalfor.setInterval())details.timestamp{Number} - Execution timestamp as unix {Number}
new RedisAdapter(opts)
Since v5.0.0
opts.client{RedisClient} - [Required]RedisClientinstance, like one returned fromawait redis.createClient().connect()methodopts.prefix{String} - [Optional] use to create multiple named instancesopts.resetOnInit{Boolean} - [Optional] (use with caution) make sure all old tasks are completed during initialization. Useful for single-instance apps to clean up unfinished that occurred due to intermediate shutdown, reboot, or exception. Default:false
new MongoAdapter(opts)
Since v5.0.0
opts.db{Db} - [Required] Mongo'sDbinstance, like one returned fromMongoClient#db()methodopts.prefix{String} - [Optional] use to create multiple named instancesopts.lockCollectionName{String} - [Optional] By default all JoSk instances use the same__JobTasks__.lockcollection for lockingopts.resetOnInit{Boolean} - [Optional] (use with caution) make sure all old tasks are completed during initialization. Useful for single-instance apps to clean up unfinished that occurred due to intermediate shutdown, reboot, or exception. Default:false
new PostgresAdapter(opts)
Since v5.2.0
opts.client{Pool|Client} - [Required]pgclient with.query()method.Poolis recommended for long-running applicationsopts.prefix{String} - [Optional] use to create multiple isolated scheduler namespaces in same database. Default:defaultopts.resetOnInit{Boolean} - [Optional] (use with caution) deletes tasks and locks for currentprefixduring initialization. Useful for local development and single-instance startup recovery. Default:false
Initialization
JoSk is storage-agnostic (since v4.0.0). Shipped with Redis, MongoDB, and PostgreSQL adapters. Extend via custom adapter
Redis Adapter
JoSk has no dependencies, hence make sure redis NPM package is installed in order to support Redis Storage Adapter. RedisAdapter stores due timestamps in sorted set and task payloads in hash, then claims due work atomically via Lua scripts. RedisAdapter is compatible with Redis-like databases with Lua + sorted-set support, and was well-tested with Redis and KeyDB
KeyDB guidelines:
- Use a single writable KeyDB primary or a KeyDB Cluster endpoint with all JoSk keys on the same hash slot. Adapter keys use hash tags:
josk:{prefix}:schedule,josk:{prefix}:tasks,josk:{prefix}:lock. - Do not route JoSk reads or writes to replicas. Scheduler correctness depends on immediate visibility of lock and task-claim writes.
- Avoid KeyDB active-replication/multi-master mode for JoSk exactly-once execution. Conflict resolution and eventual convergence can allow duplicate task claims across writers.
- For multi-DC exactly-once scheduling, use a strongly consistent storage topology, or prefer 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 }), 13 onError(reason, details) { 14 // Use onError hook to catch runtime exceptions 15 // thrown inside scheduled tasks 16 console.log(reason, details.error); 17 } 18});
MongoDB Adapter
JoSk has no dependencies, hence make sure mongodb NPM package is installed in order to support MongoDB Storage Adapter. Note: this package will add two new MongoDB collections per each new JoSk(). One collection for tasks and second for "Read Locking" with .lock suffix
1import { JoSk, MongoAdapter } from 'josk'; 2import { MongoClient } from 'mongodb'; 3 4const client = new MongoClient('mongodb://127.0.0.1:27017'); 5// To avoid "DB locks" — it's a good idea to use separate DB from the "main" DB 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 // Use onError hook to catch runtime exceptions 14 // thrown inside scheduled tasks 15 console.log(reason, details.error); 16 } 17});
PostgreSQL Adapter
Since v5.2.0
JoSk has no dependencies, hence make sure pg NPM package (npm i pg) is installed. PostgreSQL >=12 is recommended. Adapter auto-creates and migrates josk_tasks and josk_locks tables on init, using current database/schema from the provided client.
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 // Use onError hook to catch runtime exceptions 15 // thrown inside scheduled tasks 16 console.log(reason, details.error); 17 } 18});
PostgreSQL guidelines:
- Use
pg.Poolfor application runtime. Share same pool when scheduler tasks also use PostgreSQL, or use a small dedicated pool when you want scheduler isolation. - Use one writable primary endpoint. Do not route JoSk reads or writes to read replicas; task claims must be immediately visible across app instances.
- Use same
prefixon instances that must share one schedule. Use differentprefixvalues for isolated tenants, environments, or test suites. - Prefer a dedicated database or schema when possible. Adapter creates
josk_tasksandjosk_locks; table names are fixed, isolation is byprefix. - Keep
resetOnInit: falsein clustered production.truedeletes current-prefix tasks and lock rows during adapter initialization. execute: 'batch'is default. It claims due tasks in batches usingFOR UPDATE SKIP LOCKED, then iterates returned task list in memory. Best for draining backlogs with fewer DB round-trips.execute: 'one'claims one due task per scheduler lease withLIMIT 1. Useful when you want smaller execution bursts or tighter fairness between instances.- Tune
minRevolvingDelayandmaxRevolvingDelaywith pool capacity and task runtime. Lower delays poll more often and increase database writes.
Create the first task
After JoSk initialized simply call JoSk#setInterval to create recurring task
1const jobs = new JoSk({ /*...*/ }); 2 3jobs.setInterval((ready) => { 4 /* ...code here... */ 5 ready(); 6}, 60 * 60000, 'task1h'); // every hour 7 8jobs.setInterval((ready) => { 9 /* ...code here... */ 10 asyncCall(() => { 11 /* ...more code here...*/ 12 ready(); 13 }); 14}, 15 * 60000, 'asyncTask15m'); // every 15 mins 15 16/** 17 * no need to call ready() inside async function 18 */ 19jobs.setInterval(async () => { 20 try { 21 await asyncMethod(); 22 } catch (err) { 23 console.log(err) 24 } 25}, 30 * 60000, 'asyncAwaitTask30m'); // every 30 mins 26 27/** 28 * no need to call ready() when call returns Promise 29 */ 30jobs.setInterval(() => { 31 return asyncMethod(); // <-- returns Promise 32}, 2 * 60 * 60000, 'asyncAwaitTask2h'); // every two hours
Note: This library relies on job ID. Always use different uid, even for the same task:
1const task = function (ready) { 2 //... code here 3 ready(); 4}; 5 6jobs.setInterval(task, 60000, 'task-1m'); // every minute 7jobs.setInterval(task, 2 * 60000, 'task-2m'); // every two minutes
setInterval(func, delay, uid)
func{Function} - Function to call on scheduledelay{Number} - Delay for the first run and interval between further executions in millisecondsuid{String} - Unique app-wide task id- Returns: {
Promise<string>}
Set task into interval execution loop. ready() callback is passed as the first argument into a task function.
In the example below, the next task will not be scheduled until the current is ready:
1jobs.setInterval(function (ready) { 2 /* ...run sync code... */ 3 ready(); 4}, 60 * 60000, 'syncTask1h'); // will execute every hour + time to execute the task 5 6jobs.setInterval(async function () { 7 try { 8 await asyncMethod(); 9 } catch (err) { 10 console.log(err) 11 } 12}, 60 * 60000, 'asyncAwaitTask1h'); // will execute every hour + time to execute the task
In the example below, the next task will not wait for the current task to finish:
1jobs.setInterval(function (ready) { 2 ready(); 3 /* ...run sync code... */ 4}, 60 * 60000, 'syncTask1h'); // will execute every hour 5 6jobs.setInterval(async function () { 7 /* ...task re-scheduled instantly here... */ 8 process.nextTick(async () => { 9 await asyncMethod(); 10 }); 11}, 60 * 60000, 'asyncAwaitTask1h'); // will execute every hour
In the next example, a long running task is executed in a loop without delay after the full execution:
1jobs.setInterval(function (ready) { 2 asyncCall((error, result) => { 3 if (error) { 4 ready(); // <-- Always run `ready()`, even if call was unsuccessful 5 } else { 6 anotherCall(result.data, ['param'], (error, response) => { 7 if (error) { 8 ready(); // <-- Always run `ready()`, even if call was unsuccessful 9 return; 10 } 11 12 waitForSomethingElse(response, () => { 13 ready(); // <-- End of the full execution 14 }); 15 }); 16 } 17 }); 18}, 0, 'longRunningAsyncTask'); // run in a loop as soon as previous run is finished
Same task combining await/async and callbacks
1jobs.setInterval(function (ready) { 2 process.nextTick(async () => { 3 try { 4 const result = await asyncCall(); 5 const response = await anotherCall(result.data, ['param']); 6 7 waitForSomethingElse(response, () => { 8 ready(); // <-- End of the full execution 9 }); 10 } catch (err) { 11 console.log(err) 12 ready(); // <-- Always run `ready()`, even if call was unsuccessful 13 } 14 }); 15}, 0, 'longRunningAsyncTask'); // run in a loop as soon as previous run is finished
setTimeout(func, delay, uid)
func{Function} - Function to call afterdelaydelay{Number} - Delay in millisecondsuid{String} - Unique app-wide task id- Returns: {
Promise<string>}
Run a task after delay in ms. setTimeout is useful for cluster - when you need to make sure task executed only once. ready() callback is passed as the first argument into a task function.
1jobs.setTimeout(function (ready) { 2 /* ...run sync code... */ 3 ready(); 4}, 60000, 'syncTaskIn1m'); // will run only once across the cluster in a minute 5 6jobs.setTimeout(function (ready) { 7 asyncCall(function () { 8 /* ...run async code... */ 9 ready(); 10 }); 11}, 60000, 'asyncTaskIn1m'); // will run only once across the cluster in a minute 12 13jobs.setTimeout(async function () { 14 try { 15 /* ...code here... */ 16 await asyncMethod(); 17 /* ...more code here...*/ 18 } catch (err) { 19 console.log(err) 20 } 21}, 60000, 'asyncAwaitTaskIn1m'); // will run only once across the cluster in a minute
setImmediate(func, uid)
func{Function} - Function to executeuid{String} - Unique app-wide task id- Returns: {
Promise<string>}
Immediate execute the function, and only once. setImmediate is useful for cluster - when you need to execute function immediately and only once across all servers. ready() is passed as the first argument into the task function.
1jobs.setImmediate(function (ready) { 2 //...run sync code 3 ready(); 4}, 'syncTask'); // will run immediately and only once across the cluster 5 6jobs.setImmediate(function (ready) { 7 asyncCall(function () { 8 //...run more async code 9 ready(); 10 }); 11}, 'asyncTask'); // will run immediately and only once across the cluster 12 13jobs.setImmediate(async function () { 14 try { 15 /* ...code here... */ 16 await asyncMethod(); 17 } catch (err) { 18 console.log(err) 19 } 20}, 'asyncTask'); // will run immediately and only once across the cluster
clearInterval(timerId)
timerId{String|Promise<string>} — Timer id returned fromJoSk#setInterval()method- Returns: {
Promise<boolean>}truewhen task is successfully cleared, orfalsewhen task was not found
Cancel current interval timer.
1const timer = await jobs.setInterval(func, 34789, 'unique-taskid'); 2await jobs.clearInterval(timer);
clearTimeout(timerId)
timerId{String|Promise<string>} — Timer id returned fromJoSk#setTimeout()method- Returns: {
Promise<boolean>}truewhen task is successfully cleared, orfalsewhen task was not found
Cancel current timeout timer.
1const timer = await jobs.setTimeout(func, 34789, 'unique-taskid'); 2await jobs.clearTimeout(timer);
destroy()
- Returns: {boolean}
trueif instance successfully destroyed,falseif instance already destroyed
Destroy JoSk instance. This method shouldn't be called in normal circumstances. Stop internal interval timer. After JoSk is destroyed — calling public methods would end up logged to stdout or if onError hook was passed to JoSk it would receive an error. Only permitted methods are clearTimeout and clearInterval.
1// EXAMPLE: DESTROY JoSk INSTANCE UPON SERVER PROCESS TERMINATION 2const jobs = new JoSk({ /* ... */ }); 3 4const cleanUpBeforeTermination = function () { 5 /* ...CLEAN UP AND STOP OTHER THINGS HERE... */ 6 jobs.destroy(); 7 process.exit(1); 8}; 9 10process.stdin.resume(); 11process.on('uncaughtException', cleanUpBeforeTermination); 12process.on('exit', cleanUpBeforeTermination); 13process.on('SIGHUP', cleanUpBeforeTermination);
ping()
- Returns: {
Promise<object>}
Ping JoSk instance. Check scheduler readiness and its connection to the "storage adapter"
1const jobs = new JoSk({ /* ... */ }); 2 3const pingResult = await jobs.ping(); 4console.log(pingResult) 5/** 6In case of the successful response 7{ 8 status: 'OK', 9 code: 200, 10 statusCode: 200, 11} 12 13Failed response 14{ 15 status: 'Error reason', 16 code: 500, 17 statusCode: 500, 18 error: ErrorObject 19} 20*/
Execution semantics
Different scheduling methods have different at-least-once / at-most-once / exactly-once guarantees. Pick the one that matches your tolerance for missed or duplicated runs.
| Method | Guarantee | Notes |
|---|---|---|
setImmediate(func, uid) | Exactly-once across the cluster | One claim per task, no retry once claimed. |
setTimeout(func, delay, uid) | At-most-once across the cluster | Task is removed from storage before the handler runs. If the process dies between removal and completion, the run is lost. |
setInterval(func, delay, uid) | At-least-once per scheduled tick (until cleared) | Storage row stays during execution. If ready() is not called within zombieTime, the task is re-claimed and may run again. Make your handler idempotent. |
zombieTime is the safety net for stuck handlers. Choose it long enough to cover your slowest legitimate handler, plus storage round-trip overhead. Default 900000 ms (15 minutes).
execute controls how the scheduler drains the work queue under a single lease:
batch(default) claims due tasks in batches under one lease — best throughput.oneclaims a single due task per lease — smaller bursts, tighter fairness across instances.
concurrency caps how many handlers run in parallel inside this JoSk instance. Default is unbounded (Infinity), which matches setInterval/setTimeout semantics from Node's standard library. Set a finite cap if handlers share resources (DB connections, external API rate limits).
TypeScript
JoSk ships with TypeScript declarations for both ESM (index.d.ts) and CommonJS (index.d.cts). The JoSkAdapter interface is exported so custom adapters can be type-checked against the public contract.
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
Use cases and usage examples
CRON
Use JoSk to invoke synchronized tasks by CRON schedule, and the cron-parser package to parse CRON expressions. The example below uses cron-parser@^5 (v5 renamed the entrypoint to the CronExpressionParser.parse() static method).
1import { CronExpressionParser } from 'cron-parser'; 2 3const jobsCron = new JoSk({ 4 adapter: new RedisAdapter({ 5 client: await createClient({ url: 'redis://127.0.0.1:6379' }).connect(), 6 prefix: 'cron-scheduler' 7 }), 8 minRevolvingDelay: 512, // Adjust revolving delays to higher values 9 maxRevolvingDelay: 1000, // as CRON schedule defined to seconds 10}); 11 12// CRON HELPER FUNCTION 13const setCron = async (uniqueName, cronTask, task) => { 14 const next = CronExpressionParser.parse(cronTask).next().toDate(); 15 // Guard against clock skew: parsed "next" can land in the recent past. 16 const initialDelay = Math.max(0, +next - Date.now()); 17 18 return await jobsCron.setInterval(function (ready) { 19 const upcoming = CronExpressionParser.parse(cronTask).next().toDate(); 20 ready(upcoming); 21 task(); 22 }, initialDelay, uniqueName); 23}; 24 25setCron('Run every two seconds cron', '*/2 * * * * *', function () { 26 console.log(new Date()); 27});
Pass arguments
Passing arguments can be done via wrapper function
1const jobs = new JoSk({ /* ... */ }); 2const myVar = { key: 'value' }; 3let myLet = 'Some top level or env.variable (can get changed during runtime)'; 4 5const task = function (arg1, arg2, ready) { 6 //... code here 7 ready(); 8}; 9 10jobs.setInterval((ready) => { 11 task(myVar, myLet, ready); 12}, 60 * 60000, 'taskA'); 13 14jobs.setInterval((ready) => { 15 task({ otherKey: 'Another Value' }, 'Some other string', ready); 16}, 60 * 60000, 'taskB');
Async/Await with ready() callback
For long-running async tasks, or with callback-apis it might be needed to call ready() explicitly. Wrap task's body into process.nextTick to enjoy await/async combined with classic callback-apis
1jobs.setInterval((ready) => { 2 process.nextTick(async () => { 3 try { 4 const result = await asyncCall(); 5 waitForSomethingElse(async (error, data) => { 6 if (error) { 7 ready(); // <-- Always run `ready()`, even if call was unsuccessful 8 return; 9 } 10 11 await saveCollectedData(result, [data]); 12 ready(); // <-- End of the full execution 13 }); 14 } catch (err) { 15 console.log(err) 16 ready(); // <-- Always run `ready()`, even if call was unsuccessful 17 } 18 }); 19}, 60 * 60000, 'longRunningTask1h'); // once every hour
Clean up old tasks
During development and tests you may want to clean up Adapter's Storage
Clean up Redis
To clean up old tasks via Redis CLI use the next query pattern:
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
Clean up MongoDB
To clean up old tasks via MongoDB use the next query pattern:
1// Run directly in MongoDB console: 2db.getCollection('__JobTasks__').remove({}); 3// If you're using multiple JoSk instances with prefix: 4db.getCollection('__JobTasks__PrefixHere').remove({});
Clean up PostgreSQL
To clean up old tasks and lock for current prefix via 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
1// Recommended MongoDB connection options 2// When used with ReplicaSet 3const options = { 4 writeConcern: { 5 j: true, 6 w: 'majority', 7 wtimeout: 30000 8 }, 9 readConcern: { 10 level: 'majority' 11 }, 12 readPreference: 'primary' 13}; 14 15MongoClient.connect('mongodb://url', options, (error, client) => { 16 // To avoid "DB locks" — it's a good idea to use separate DB from "main" application DB 17 const db = client.db('dbName'); 18 const jobs = new JoSk({ 19 adapter: new MongoAdapter({ 20 db: db, 21 }) 22 }); 23});
Prefix mapping
prefix isolates scheduler state per adapter. Same prefix = same shared queue; different prefixes = isolated namespaces. The default value is default across all adapters.
| Adapter | Storage layout for prefix: 'app' | Notes |
|---|---|---|
| Redis | Keys: josk:{app}:schedule, josk:{app}:tasks, josk:{app}:lock | Hash tags {app} 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. |
| MongoDB | Collection __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. |
| PostgreSQL | Rows 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?
Set a long-running task to throw or skip ready() past zombieTime. The onError hook fires with 'One of your tasks is missing' (only if autoClear: false). For active observability, query the storage directly: Redis HLEN josk:{prefix}:tasks, Mongo db.__JobTasks__<prefix>.countDocuments({ executeAt: { $lt: new Date() } }), Postgres SELECT COUNT(*) FROM josk_tasks WHERE prefix='<prefix>' AND execute_at < (EXTRACT(EPOCH FROM CURRENT_TIMESTAMP) * 1000)::BIGINT.
How do I handle storage restarts?
JoSk swallows adapter errors and retries on the next tick. The scheduler self-recovers once the connection is healthy. Locks held by crashed nodes self-expire (Redis: PEXPIRE, Mongo: TTL index, Postgres: locked_until compared against server time on next claim).
one vs batch execute mode?
Use batch for normal throughput — it claims due tasks in chunks and reduces storage round-trips. Switch to one if you need smaller execution bursts per instance, finer fairness between cluster members, or if your handlers contend on the same downstream resource.
Jitter: Why is my interval running every delay + maxRevolvingDelay ms?
JoSk polls between minRevolvingDelay and maxRevolvingDelay. The effective interval is delay + (poll latency). Lower maxRevolvingDelay for tighter intervals at the cost of more storage reads.
What about clock skew between nodes?
Lease tokens use storage-server time where possible (Redis PX TTL, Postgres CURRENT_TIMESTAMP, Mongo TTL index). JS clocks are used only for relative scheduling within a single process. It's recommended to ensure NTP is healthy on the database host — that single clock anchors lease ownership across the cluster.
Migration guide (v4 → v5)
v5.0.0 reworked storage adapters APIs as separate instances.
- Adapters now require their own constructor. v4:
new JoSk({ db, prefix }). v5:new JoSk({ adapter: new MongoAdapter({ db, prefix }) }). - Shipped with
RedisAdapterandMongoAdapter. RedisAdapterstores tasks in a sorted set (josk:{prefix}:schedule) plus hash (josk:{prefix}:tasks). Oldjosk:{prefix}:task:*keys are scanned and removed only whenresetOnInit: true. To migrate a running cluster, plan a brief downtime: stop all instances, run one withresetOnInit: true, then redeploy.
Migration guide (v5 → v6)
v6.0.0 reworked storage adapters around owner-bound lease tokens, added atomic due-task claiming, and raised the runtime floor.
- Breaking: minimum runtime is now
node@>=20.9.0(LTS) orbun@>=1.1.0. Stay onjosk@^5if you cannot upgrade Node yet. RedisAdapteraccepts bothredis@^4andredis@^5clients.- New adapter:
PostgresAdapter. PostgresAdapteruses composite(prefix, uid)primary key. The adapter auto-migrates the table on startup, but the migration runs DDL — use a low-traffic deployment window.MongoAdapterpreviously defaulted the prefix to'', producing the collection__JobTasks__. v6 defaults to'default', producing__JobTasks__default. If you used the implicit empty prefix in v4/v5, passprefix: ''explicitly to preserve the collection name, or migrate data:db.__JobTasks__.renameCollection('__JobTasks__default').- Lock release now checks lease ownership; a JoSk instance can no longer release a foreign lease. If you have custom adapters, follow the adapter API contract.
- If you use
cron-parser— bump to^5and switch fromparser.parseExpression(...)toCronExpressionParser.parse(...).
Notes
- This package is perfect when you have multiple horizontally scaled servers for load-balancing, durability, an array of micro-services or any other solution with multiple running copies of code running repeating tasks that needs to run only once per application/cluster, not per server/instance;
- Recommended floor — unique tasks shorter than ~2 seconds may overlap with the storage round-trip plus revolving delay; tasks ≥2s have stable execution gaps. Example tasks: Email, SMS queue, Long-polling requests, Periodic application logic operations or Periodic data fetch, sync, etc.
- Accuracy — Delay of each task depends on storage round-trip and jitter window. Trusted execution range is
task_delay ± (maxRevolvingDelay + Storage_Request_Delay). With defaultminRevolvingDelay: 128/maxRevolvingDelay: 768, expect ±0.8s + storage latency. Tighten the bounds for stricter timing at the cost of more storage reads. - Use
opts.minRevolvingDelayandopts.maxRevolvingDelayto set the range for random delays between executions. Revolving range acts as a safety control to make sure different servers not picking the same task at the same time. Default values (128and768) are the best for 3-server setup (the most common topology). Tune these options to match needs of your project. Higheropts.minRevolvingDelaywill reduce storage read/writes; - This package implements scheduler locking via Redis key, MongoDB
.lockcollection, or PostgreSQLjosk_lockstable. Task claims are adapter-level atomic operations.
Running Tests
- Clone this package
- In Terminal (Console) go to directory where package is cloned
- Then run:
# Before running tests make sure NODE_ENV === development # Install NPM dependencies npm install --save-dev # Before running full tests you need MongoDB, Redis, and PostgreSQL servers. # Required URLs: # - REDIS_URL: Redis connection string # - MONGO_URL: MongoDB connection string # - PG_URL: PostgreSQL connection string REDIS_URL="redis://127.0.0.1:6379" MONGO_URL="mongodb://127.0.0.1:27017/npm-josk-test-001" PG_URL="postgres://postgres:postgres@localhost:5432/npm-josk-test-001" npm test # Run Jest suite for core plus live adapter contract tests REDIS_URL="redis://127.0.0.1:6379" MONGO_URL="mongodb://127.0.0.1:27017/npm-josk-test-001" PG_URL="postgres://localhost:5432/josk-tests" npm run test:jest # Run the same Jest suite under Bun (bun:test) REDIS_URL="redis://127.0.0.1:6379" MONGO_URL="mongodb://127.0.0.1:27017/npm-josk-test-001" PG_URL="postgres://localhost:5432/josk-tests" npm run test:bun # Coverage report (Jest only — Mocha suites add to coverage when run separately) npm run test:coverage # TypeScript declaration smoke test npm run test:types # If previous run has errors — add "debug" to output extra logs DEBUG=true REDIS_URL="redis://127.0.0.1:6379" MONGO_URL="mongodb://127.0.0.1:27017/npm-josk-test-001" PG_URL="postgres://postgres:postgres@localhost:5432/npm-josk-test-001" npm test # Be patient, tests are taking around 6 mins
Run Redis tests only
Run Redis-related tests only
# Before running Redis tests you need to have Redis server installed and running REDIS_URL="redis://127.0.0.1:6379" npm run test:redis # Be patient, tests are taking around 3 mins
Run MongoDB tests only
Run MongoDB-related tests only
# Before running Mongo tests you need to have MongoDB server installed and running MONGO_URL="mongodb://127.0.0.1:27017/npm-josk-test" npm run test:mongo # Be patient, tests are taking around 3 mins
Run PostgreSQL tests only
# Before running, have PostgreSQL server running and create DB, e.g. npm-josk-test # PG_URL is required for PostgreSQL tests. # Install pg if not: npm install --save-dev pg PG_URL="postgres://postgres:postgres@localhost:5432/npm-josk-test" npm run test:postgres # Be patient, tests are taking around 3 mins
Why JoSk?
JoSk is Job-Task - Is randomly generated name by "uniq" project
Support our open source contribution:
- Upload and share files using ☄️ meteor-files.com — Continue interrupted file uploads without losing any progress. There is nothing that will stop Meteor from delivering your file to the desired destination
- Use ▲ ostr.io for Server Monitoring, Web Analytics, WebSec, Web-CRON and SEO Pre-rendering of a website
- Star on GitHub
- Star on NPM
- Star on Atmosphere
- Sponsor via GitHub
- Support via PayPal