UNPKG

ha-job-scheduler

Version:

Highly available cron job scheduler using Redis

188 lines (187 loc) 8 kB
"use strict"; 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;