A tiny, dependency-free advisory lock backed by MongoDB, for cron jobs registered on every instance of a multi-process deploy (PM2 cluster, multiple servers) that must only actually run on one instance at a time.
No new infrastructure — it stores one small document per lock in a MongoDB collection you already have a connection to.
Acquiring a lock is a single atomic upsert: it succeeds when no lock document exists, or the existing one has expired (steal-on-expiry, so a crashed holder can't deadlock the job forever). If a live, unexpired lock already exists, the upsert hits a duplicate-key error, which is treated as "not acquired" — no exception bubbles up to your job.
npm install mongo-job-lockWorks with either the native MongoDB driver or Mongoose — you just need to
hand it a Db instance.
const mongoose = require('mongoose');
const { createJobLock } = require('mongo-job-lock');
const withJobLock = createJobLock({ db: mongoose.connection.db });
// In your cron handler, registered on every instance:
cron.schedule('30 2 * * *', async () => {
const { ran } = await withJobLock('nightly-report-email', 30 * 60 * 1000, async () => {
await sendNightlyReports();
});
if (!ran) console.log('another instance is already running this job');
});const { MongoClient } = require('mongodb');
const { createJobLock } = require('mongo-job-lock');
const client = await MongoClient.connect(uri);
const withJobLock = createJobLock({ db: client.db('myapp') });| Option | Type | Default | Description |
|---|---|---|---|
db |
Db |
required | A MongoDB Db instance. |
collectionName |
string |
'joblocks' |
Collection used to store lock documents. |
onInfo |
(msg) => void |
no-op | Called when a run is skipped because another instance holds the lock. |
onError |
(msg) => void |
no-op | Called if releasing the lock fails (best-effort; never thrown). |
Returns withJobLock(name, ttlMs, fn):
name— a unique lock name (e.g.'nightly-report-email').ttlMs— lock lifetime; should comfortably exceed the job's max runtime, so a slow-but-alive run doesn't get its lock stolen out from under it.fn— the async work to run while holding the lock.
Returns { ran: boolean, result?: any } — ran is false when another
instance already holds the lock (fn was never called); otherwise result is
whatever fn resolved to. If fn throws, withJobLock rejects with that same
error (after still releasing the lock).
Pick ttlMs a comfortable margin above how long the job normally takes —
long enough that a healthy run never has its lock stolen mid-flight, short
enough that a crashed instance doesn't block the job for too long before the
next scheduled run steals it back. A job that runs every 24h and normally
takes 5 minutes might use a 30-60 minute TTL, for example.
MIT