UNPKG

ha-job-scheduler

Version:

Highly available cron job scheduler using Redis

210 lines (193 loc) 6.92 kB
import nodeSchedule from 'node-schedule' import Redis from 'ioredis' import ms from 'ms' import _debug from 'debug' import { RunDelayed, Deferred, Delayed, Recurring, StopFn, Events, } from './types' import { defer, getPreviousDate } from './util' import { RedisOptions } from 'ioredis' import EventEmitter from 'eventemitter3' const debug = _debug('ha-job-scheduler') /** * Uses Redis to scheduling recurring jobs or delayed jobs. */ export const jobScheduler = (opts?: RedisOptions) => { const redis = opts ? new Redis(opts) : new Redis() const stopFns: StopFn[] = [] const emitter = new EventEmitter<Events>() const emit = (event: Events, data: object) => { emitter.emit(event, { type: event, ...data }) } /** * Attempt to get a lock for the lock key, `lockKey`, lasting `lockExpireMs`. */ const getLock = (lockKey: string, lockExpireMs = ms('1m')) => redis.set(lockKey, process.pid, 'PX', lockExpireMs, 'NX') const scheduleRecurring: Recurring = (id, rule, runFn, options = {}) => { const { lockExpireMs, persistScheduledMs } = options const key = `recurring:${id}` let deferred: Deferred<void> // 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: Date) => { deferred = 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 = 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 = nodeSchedule.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: string) => `delayed:${id}` const scheduleDelayed: Delayed = 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: RunDelayed = (id, runFn, options = {}) => { const { rule = '* * * * *', lockExpireMs, limit = 100 } = options const key = getDelayedKey(id) let deferred: Deferred<void> /** * Get delayed items where the delayed timestamp is <= now. * Returns up to limit number of items. */ const getItems = (upper: number) => redis.zrangebyscoreBuffer(key, '-inf', upper, 'LIMIT', 0, limit) // Poll Redis according to rule frequency const schedule = nodeSchedule.scheduleJob(rule, async (date) => { deferred = 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, } }