EXPRESSER CRON

Handle scheduled cron jobs. You can use intervals (seconds) or specific times to trigger jobs, and the module will take care of setting the proper timers. Jobs are added using “job” objects with id, schedule, callback and other options.

class Cron

    events = require "./events.coffee"
    fs = require "fs"
    lodash = require "lodash"
    logger = require "./logger.coffee"
    moment = require "moment"
    path = require "path"
    settings = require "./settings.coffee"
    utils = require "./utils.coffee"

@property [Array] The jobs collection, please do not edit this object manually!

    jobs: []

CONSTRUCTOR AND INIT

Cron constructor.

    constructor: ->
        @setEvents() if settings.events.enabled

Bind event listeners.

    setEvents: =>
        events.on "cron.start", @start
        events.on "cron.stop", @stop
        events.on "cron.add", @add
        events.on "cron.remove", @remove

Init the cron manager. If loadOnInit setting is true, the cron.json file will be parsed and loaded straight away (if there’s one).

    init: (options) =>
        logger.debug "Cron.init"
        @load true if settings.cron.loadOnInit

Load jobs from the cron.json file. If autoStart is true, it will automatically call the start method after loading. @param [String] filename Path to the JSON file containing jobs, optional, default is “cron.json”. @param [Object] options Options to be passed when loading cron jobs. @option options [String] basePath Sets the base path of modules when requiring them. @option options [Boolean] autoStart If true, call “start” after loading.

    load: (filename, options) =>
        logger.debug "Cron.load", filename, options

Set default options.

        options = {} if not options?
        options = lodash.defaults options, {autoStart: false, basePath: ""}

        if lodash.isBoolean filename
            filename = null
            options.autoStart = filename

        if not filename? or filename is false or filename is ""
            filename = "cron.json"
            doNotWarn = true

Get full path to the passed json file.

        filepath = utils.getFilePath filename
        basename = path.basename filepath

Found the cron.json file? Read it.

        if filepath?
            cronJson = fs.readFileSync filepath, {encoding: settings.general.encoding}
            cronJson = utils.minifyJson cronJson

Iterate jobs, but do not add if job’s enabled is false.

            for key, data of cronJson
                module = require(options.basePath + key)
                for d in data
                    if not d.enabled? or d.enabled
                        cb = module[d.callback]
                        job = d
                        job.module = key
                        job.id = key + "." + d.callback
                        job.callback = cb
                        @add job
                    else
                        logger.debug "Cron.load", filename, key, d.callback, "Enabled is false. Skip!"

Start all jobs automatically if autoStart is true.

            @start() if options.autoStart

            logger.info "Cron.load", "#{basename} loaded."
        else if not doNotWarn
            logger.warn "Cron.load", "#{basename} not found."

METHODS

Start the specified cron job. If no id is specified, all jobs will be started. A filter can also be passed as an object. For example to start all jobs for the module “email”, use start({module: “email”}). @param [String] idOrFilter The job id or filter, optional (if not specified, start everything).

    start: (idOrFilter) =>
        if not idOrFilter?
            logger.info "Cron.start", "All jobs"
            arr = @jobs
        if lodash.isString idOrFilter or lodash.isNumber idOrFilter
            logger.info "Cron.start", idOrFilter
            arr = lodash.find @jobs, {id: idOrFilter.toString()}
        else
            logger.info "Cron.start", idOrFilter
            arr = lodash.find @jobs, idOrFilter

        if not arr? or arr.length < 1
            logger.debug "Cron.start", "Job #{idOrFilter} does not exist. Abort!"
        else
            for job in arr
                clearTimeout job.timer if job.timer?
                setTimer job

Stop the specified cron job. If no id is specified, all jobs will be stopped. A filter can also be passed as an object. For example to stop all jobs for the module “mymodule”, use stop({module: “mymodule”}). @param [String] idOrFilter The job id or filter, optional (if not specified, stop everything).

    stop: (idOrFilter) =>
        if not idOrFilter?
            logger.info "Cron.stop", "All jobs"
            arr = @jobs
        if lodash.isString idOrFilter or lodash.isNumber idOrFilter
            logger.info "Cron.stop", idOrFilter
            arr = lodash.find @jobs, {id: idOrFilter.toString()}
        else
            logger.info "Cron.stop", idOrFilter
            arr = lodash.find @jobs, idOrFilter

        if not arr? or arr.length < 1
            logger.debug "Cron.stop", "Job #{idOrFilter} does not exist. Abort!"
        else
            for job in arr
                clearTimeout job.timer if job.timer?
                job.timer = null

Add a scheduled job to the cron, passing an id and job. You can also pass only the job if it has an id property. @param [String] id The job ID, optional, overrides job.id in case it has one. @param [Object] job The job object. @option job [String] id The job ID, optional. @option job [Integer, Array] schedule If a number assume it’s the interval in seconds, otherwise a times array. @option job [Method] callback The callback (job) to be triggered. @option job [Boolean] once If true, the job will be triggered only once no matter which schedule it has. @return [Object] Returns {error, job}, where job is the job object and error is the error message (if any).

    add: (id, job) =>
        logger.debug "Cron.add", id, job

        if id? and not job?
            job = id
            id = null

If no id is passed, try getting it directly from the job object.

        id = job.id if not id?

Throw error if no id was provided or callback is invalid.

        if not id? or id is ""
            errorMsg = "No 'id' was passed. Abort!"
            logger.error "Cron.add", errorMsg
            return {error: errorMsg}

Throw error if job callback is not a valid function.

        if not lodash.isFunction job.callback
            errorMsg = "The job #{id} callback is not a valid function. Abort!"
            logger.error "Cron.add", errorMsg
            return {error: errorMsg}

Find existing job.

        existing = lodash.find @jobs, {id: id}

Handle existing jobs.

        if existing?
            if settings.cron.allowReplacing
                clearTimeout existing.timer if existing.timer?
                existing.timer = null
            else
                errorMsg = "Job #{id} already exists and 'allowReplacing' is false. Abort!"
                logger.error "Cron.add", errorMsg
                return {error: errorMsg}

Set startTime and endTime if not set.

        job.startTime = moment 0 if not job.startTime?
        job.endTime = moment 0 if not job.endTime?

Only create the timer if autoStart is not false.

        setTimer job if job.autoStart isnt false

Add to the jobs list.

        job.id = id
        @jobs.push job

        return {job: job}

Remove and stop a current job. If job does not exist, a warning will be logged. @param [String] id The job ID.

    remove: (id) =>
        existing = lodash.find @jobs, {id: id}

Job exists?

        if not existing?
            logger.debug "Cron.remove", "Job #{id} does not exist. Abort!"
            return false

Clear timer and remove job from array.

        clearTimeout existing.timer if existing.timer?
        @jobs.splice existing

HELPERS

Helper to get the timeout value (ms) to the next job callback.

    getTimeout = (job) ->
        now = moment()
        nextDate = moment()

If schedule is not an array, parse it as integer / seconds.

        if lodash.isNumber job.schedule or lodash.isString job.schedule
            timeout = moment().add("s", job.schedule).valueOf() - now.valueOf()
        else
            minTime = "99:99:99"
            nextTime = "99:99:99"

Get the next and minimum times from schedule.

            for sc in job.schedule
                minTime = sc if sc < minTime
                nextTime = sc if sc < nextTime and sc > nextDate.format("HH:mm:ss")

If no times were found for today then set for tomorrow, minimum time.

            if nextTime is "99:99:99"
                nextDate = nextDate.add "d", 1
                nextTime = minTime

Return the timeout.

            arr = nextTime.split ":"
            dateValue = [nextDate.year(), nextDate.month(), nextDate.date(), parseInt(arr[0]), parseInt(arr[1]), parseInt(arr[2])]
            timeout = moment(dateValue).valueOf() - now.valueOf()

        return timeout

Helper to prepare and get a job callback function.

    getCallback = (job) ->
        callback = ->
            logger.debug "Cron", "Job #{job.id} trigger."
            job.timer = null
            job.startTime = moment()
            job.endTime = moment()
            job.callback job

Only reset timer if once is not true.

            setTimer job if not job.once

Return generated callback.

        return callback

Helper to get a timer / interval based on the defined options.

    setTimer = (job) ->
        callback = getCallback job

Get the correct schedule / timeout value.

        schedule = job.schedule
        schedule = moment.duration(schedule).asMilliseconds() if not lodash.isNumber schedule

Make sure timer is not running.

        clearTimeout job.timer if job.timer?

Set the timeout based on the defined schedule.

        timeout = getTimeout job
        job.timer = setTimeout callback, timeout
        job.nextRun = moment().add "ms", timeout

        logger.debug "Cron.setTimer", job.id, timeout

Singleton implementation.

Cron.getInstance = ->
    @instance = new Cron() if not @instance?
    return @instance

module.exports = exports = Cron.getInstance()
h