ha-job-scheduler
Version:
Highly available cron job scheduler using Redis
188 lines (187 loc) • 8 kB
JavaScript
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.jobScheduler = void 0;
const node_schedule_1 = __importDefault(require("node-schedule"));
const ioredis_1 = __importDefault(require("ioredis"));
const ms_1 = __importDefault(require("ms"));
const debug_1 = __importDefault(require("debug"));
const util_1 = require("./util");
const eventemitter3_1 = __importDefault(require("eventemitter3"));
const debug = (0, debug_1.default)('ha-job-scheduler');
/**
* Uses Redis to scheduling recurring jobs or delayed jobs.
*/
const jobScheduler = (opts) => {
const redis = opts ? new ioredis_1.default(opts) : new ioredis_1.default();
const stopFns = [];
const emitter = new eventemitter3_1.default();
const emit = (event, data) => {
emitter.emit(event, { type: event, ...data });
};
/**
* Attempt to get a lock for the lock key, `lockKey`, lasting `lockExpireMs`.
*/
const getLock = (lockKey, lockExpireMs = (0, ms_1.default)('1m')) => redis.set(lockKey, process.pid, 'PX', lockExpireMs, 'NX');
const scheduleRecurring = (id, rule, runFn, options = {}) => {
const { lockExpireMs, persistScheduledMs } = options;
const key = `recurring:${id}`;
let deferred;
// Should invocations be persisted to Redis
const shouldPersistInvocations = typeof persistScheduledMs === 'number' && persistScheduledMs > 0;
const persistKey = `${key}:lastRun`;
// Called for each invocation
const runJob = async (date) => {
deferred = (0, util_1.defer)();
emit('recurring:schedule', { id, rule, date });
const scheduledTime = date.getTime();
const lockKey = `${key}:${scheduledTime}:lock`;
// Attempt to get an exclusive lock.
const locked = await getLock(lockKey, lockExpireMs);
// Lock was obtained
if (locked) {
debug('lock obtained - id: %s date: %s', id, date);
// Persist invocation
if (shouldPersistInvocations) {
await redis.set(persistKey, scheduledTime, 'PX', persistScheduledMs);
}
// Run job
await runFn(date);
emit('recurring:run', { id, rule, date });
}
deferred.done();
};
// Schedule last missed job if needed
if (shouldPersistInvocations) {
// Get previous invocation date
const date = (0, util_1.getPreviousDate)(rule);
const expectedLastRunTime = date.getTime();
// Get last run time
redis.get(persistKey).then((val) => {
// Last run time exists and job was run prior to expected last run
if (val && Number.parseInt(val, 10) < expectedLastRunTime) {
debug('missed job - id: %s date: %s', id, date);
// Run job with previous invocation date
runJob(date);
}
});
}
// Schedule recurring job
const schedule = node_schedule_1.default.scheduleJob(rule, runJob);
/**
* Stop the scheduler. Awaits the completion of the current invocation
* before resolving.
*/
const stop = () => {
schedule.cancel();
return deferred?.promise;
};
stopFns.push(stop);
return { schedule, stop };
};
const getDelayedKey = (id) => `delayed:${id}`;
const scheduleDelayed = async (id, data, scheduleFor) => {
const key = getDelayedKey(id);
// Timestamp for delivery
const score = typeof scheduleFor === 'number'
? new Date().getTime() + scheduleFor
: scheduleFor.getTime();
// Add data to sorted set
const res = await redis.zadd(key, score, Buffer.from(data));
const success = res === 1;
if (success) {
emit('delayed:schedule', { id, scheduleFor });
}
return success;
};
const runDelayed = (id, runFn, options = {}) => {
const { rule = '* * * * *', lockExpireMs, limit = 100 } = options;
const key = getDelayedKey(id);
let deferred;
/**
* Get delayed items where the delayed timestamp is <= now.
* Returns up to limit number of items.
*/
const getItems = (upper) => redis.zrangebyscoreBuffer(key, '-inf', upper, 'LIMIT', 0, limit);
// Poll Redis according to rule frequency
const schedule = node_schedule_1.default.scheduleJob(rule, async (date) => {
deferred = (0, util_1.defer)();
const scheduledTime = date.getTime();
const lockKey = `${key}:${scheduledTime}:lock`;
// Attempt to get an exclusive lock. Lock expires in 1 minute.
const locked = await getLock(lockKey, lockExpireMs);
if (locked) {
debug('lock obtained - id: %s date: %s', id, date);
const now = new Date().getTime();
const items = await getItems(now);
if (items.length) {
debug('delayed items found - id: %s num: %d', id, items.length);
// Call run fn
// TODO: Maybe return an array of tuples with the data and the score (scheduled time)
await runFn(items);
// Remove delayed items
await redis.zremrangebyscore(key, '-inf', now);
emit('delayed:run', { id, items: items.length });
}
}
deferred.done();
});
/**
* Stop the scheduler. Awaits the completion of the current invocation
* before resolving.
*/
const stop = () => {
schedule.cancel();
return deferred?.promise;
};
stopFns.push(stop);
return { schedule, stop };
};
const stop = async () => {
await Promise.all(stopFns.map((stop) => stop()));
redis.disconnect();
};
return {
/**
* Schedule a recurring job. `runFn` will be called for every invocation of the rule.
*
* Set `persistScheduledMs` to a value greater than the frequency of the cron
* rule to guarantee that the last missed job will be run. This is useful for
* infrequent jobs that cannot be missed. For example, if you have a job that runs
* at 6am daily, you might want to set `persistScheduledMs` to `ms('25h')` so that
* a missed run will be attempted up to one hour past the scheduled invocation.
*
* The `id` should be unique for the rule/runFn pair.
*
* Guarantees at most one delivery.
*/
scheduleRecurring,
/**
* Schedule data to be delivered at a later date. Duplicate payloads
* will be ignored. `scheduleFor` accepts a number of milliseconds
* in the future or a date. Use in conjunction with `runDelayed`.
*
* Returns a boolean indicating if the item was successfully scheduled.
*/
scheduleDelayed,
/**
* Check for delayed items according to the recurrence rule. Default
* interval is every minute. Calls `runFn` for the batch of items where
* the delayed timestamp is <= now. The default number of items to
* retrieve at one time is 100.
*
* The `id` parameter should match the `id` passed to `scheduleDelayed`.
*
* Guarantees at least one delivery.
*/
runDelayed,
/**
* Call stop on all schedulers and close the Redis connection.
*/
stop,
emitter,
};
};
exports.jobScheduler = jobScheduler;
;