ha-job-scheduler
Version:
Highly available cron job scheduler using Redis
210 lines (193 loc) • 6.92 kB
text/typescript
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,
}
}