UNPKG

node-cron

Version:

Job scheduling for Node.js with overlap prevention, distributed coordination, and background tasks. Zero dependencies, written in TypeScript.

1,084 lines (1,063 loc) 37.1 kB
import { EventEmitter } from 'events'; import { randomUUID } from 'node:crypto'; function createID() { return randomUUID(); } const levelColors = { INFO: '\x1b[36m', WARN: '\x1b[33m', ERROR: '\x1b[31m', DEBUG: '\x1b[35m', }; const GREEN = '\x1b[32m'; const RESET = '\x1b[0m'; function log(level, message, extra) { const timestamp = new Date().toISOString(); const color = levelColors[level] ?? ''; const prefix = `[${timestamp}] [PID: ${process.pid}] ${GREEN}[NODE-CRON]${GREEN} ${color}[${level}]${RESET}`; const output = `${prefix} ${message}`; switch (level) { case 'ERROR': console.error(output, extra ?? ''); break; case 'DEBUG': console.debug(output, extra ?? ''); break; case 'WARN': console.warn(output); break; case 'INFO': default: console.info(output); break; } } const defaultLogger = { info(message) { log('INFO', message); }, warn(message) { log('WARN', message); }, error(message, err) { if (message instanceof Error) { log('ERROR', message.message, message); } else { log('ERROR', message, err); } }, debug(message, err) { if (message instanceof Error) { log('DEBUG', message.message, message); } else { log('DEBUG', message, err); } }, }; const noopLogger = { info() { }, warn() { }, error() { }, debug() { }, }; let activeLogger = defaultLogger; function setLogger(logger) { activeLogger = logger ?? defaultLogger; } const logger = { info: (message) => activeLogger.info(message), warn: (message) => activeLogger.warn(message), error: (message, err) => activeLogger.error(message, err), debug: (message, err) => activeLogger.debug(message, err), }; class TrackedPromise { promise; error; state; value; constructor(executor) { this.state = 'pending'; this.promise = new Promise((resolve, reject) => { executor((value) => { this.state = 'fulfilled'; this.value = value; resolve(value); }, (error) => { this.state = 'rejected'; this.error = error; reject(error); }); }); } getPromise() { return this.promise; } getState() { return this.state; } isPending() { return this.state === 'pending'; } isFulfilled() { return this.state === 'fulfilled'; } isRejected() { return this.state === 'rejected'; } getValue() { return this.value; } getError() { return this.error; } then(onfulfilled, onrejected) { return this.promise.then(onfulfilled, onrejected); } catch(onrejected) { return this.promise.catch(onrejected); } finally(onfinally) { return this.promise.finally(onfinally); } } function planBeat(expected, now, toleranceMs, getNextMatch) { const missed = []; let slot = expected; while (true) { const nowMs = now.getTime(); const slotMs = slot.getTime(); if (nowMs < slotMs) { return { missed, next: slot }; } const next = getNextMatch(slot); if (next.getTime() <= slotMs) { return { missed, next: getNextMatch(now) }; } const gap = next.getTime() - slotMs; const lateBy = nowMs - slotMs; if (lateBy <= toleranceMs && lateBy < gap) { return { missed, run: slot, next }; } missed.push(slot); slot = next; } } const DEFAULT_MISSED_EXECUTION_TOLERANCE = 1000; function emptyOnFn() { } function emptySkipFn() { } function emptyHookFn() { return true; } const DEFAULT_COORDINATOR_TTL = 30000; class Runner { timeMatcher; onMatch; noOverlap; maxExecutions; maxRandomDelay; missedExecutionTolerance; runCount; running; heartBeatTimeout; logger; onMissedExecution; onOverlap; onError; beforeRun; onFinished; onMaxExecutions; runCoordinator; coordinatorKeyPrefix; coordinatorTtl; onSkipped; constructor(timeMatcher, onMatch, options) { this.timeMatcher = timeMatcher; this.onMatch = onMatch; this.noOverlap = options == undefined || options.noOverlap === undefined ? false : options.noOverlap; this.maxExecutions = options?.maxExecutions; this.maxRandomDelay = options?.maxRandomDelay || 0; this.missedExecutionTolerance = options?.missedExecutionTolerance ?? DEFAULT_MISSED_EXECUTION_TOLERANCE; this.logger = options?.logger || logger; this.onMissedExecution = options?.onMissedExecution || emptyOnFn; this.onOverlap = options?.onOverlap || emptyOnFn; this.onError = options?.onError || ((date, error) => this.logger.error('Task failed with error!', error)); this.onFinished = options?.onFinished || emptyHookFn; this.beforeRun = options?.beforeRun || emptyHookFn; this.onMaxExecutions = options?.onMaxExecutions || emptyOnFn; this.runCoordinator = options?.runCoordinator; this.coordinatorKeyPrefix = options?.coordinatorKeyPrefix || ''; this.coordinatorTtl = options?.coordinatorTtl ?? DEFAULT_COORDINATOR_TTL; this.onSkipped = options?.onSkipped || emptySkipFn; this.runCount = 0; this.running = false; } onErrorFallback = (date, error) => { this.logger.error('Task failed with error!', error); }; async runCoordinated(slot, run) { if (!this.runCoordinator) { await run(); return; } const key = `${this.coordinatorKeyPrefix}:${slot.toISOString()}`; let allowed; try { allowed = await this.runCoordinator.shouldRun(key, this.coordinatorTtl); } catch (err) { this.logger.error('Run coordinator failed; skipping execution (fail-closed)', err); this.emitSkipped(slot, 'coordinator-error'); return; } if (!allowed) { this.emitSkipped(slot, 'not-elected'); return; } try { await run(); } finally { try { await this.runCoordinator.onComplete?.(key); } catch (err) { this.logger.error('Run coordinator onComplete failed', err); } } } emitSkipped(slot, reason) { Promise.resolve(this.onSkipped(slot, reason)).catch((err) => this.onErrorFallback(slot, err)); } start() { this.running = true; let lastExecution; let expectedNextExecution = this.timeMatcher.getNextMatch(nowWithoutMs()); const armHeartBeat = () => { if (this.running) { clearTimeout(this.heartBeatTimeout); this.heartBeatTimeout = setTimeout(heartBeat, getDelay(expectedNextExecution)); } }; const runTask = (date) => { return new Promise(async (resolve) => { const execution = { id: createID(), reason: 'scheduled' }; const shouldExecute = await this.beforeRun(date, execution); const randomDelay = Math.floor(Math.random() * this.maxRandomDelay); if (shouldExecute) { const execute = async () => { try { this.runCount++; execution.startedAt = new Date(); const result = await this.onMatch(date, execution); execution.finishedAt = new Date(); execution.result = result; this.onFinished(date, execution); if (this.maxExecutions && this.runCount >= this.maxExecutions) { this.onMaxExecutions(date); this.stop(); } } catch (error) { execution.finishedAt = new Date(); execution.error = error; this.onError(date, error, execution); } resolve(true); }; if (randomDelay > 0) { setTimeout(execute, randomDelay); } else { execute(); } } else { resolve(true); } }); }; const heartBeat = async () => { const currentDate = nowWithoutMs(); const plan = planBeat(expectedNextExecution, currentDate, this.missedExecutionTolerance, (date) => this.timeMatcher.getNextMatch(date)); expectedNextExecution = plan.next; for (const missedSlot of plan.missed) { runAsync(this.onMissedExecution, missedSlot, this.onErrorFallback); } if (plan.run) { if (lastExecution && lastExecution.getState() === 'pending') { runAsync(this.onOverlap, plan.run, this.onErrorFallback); if (this.noOverlap) { this.logger.warn('task still running, new execution blocked by overlap prevention!'); armHeartBeat(); return; } } const slot = plan.run; lastExecution = new TrackedPromise(async (resolve, reject) => { try { await this.runCoordinated(slot, () => runTask(slot)); resolve(true); } catch (err) { reject(err); } }); } armHeartBeat(); }; armHeartBeat(); } nextRun() { return this.timeMatcher.getNextMatch(new Date()); } stop() { this.running = false; if (this.heartBeatTimeout) { clearTimeout(this.heartBeatTimeout); this.heartBeatTimeout = undefined; } } isStarted() { return !!this.heartBeatTimeout && this.running; } isStopped() { return !this.isStarted(); } async execute() { const date = new Date(); const execution = { id: createID(), reason: 'invoked' }; try { const shouldExecute = await this.beforeRun(date, execution); if (shouldExecute) { this.runCount++; execution.startedAt = new Date(); const result = await this.onMatch(date, execution); execution.finishedAt = new Date(); execution.result = result; this.onFinished(date, execution); } } catch (error) { execution.finishedAt = new Date(); execution.error = error; this.onError(date, error, execution); } } } async function runAsync(fn, date, onError) { try { await fn(date); } catch (error) { onError(date, error); } } function getDelay(nextRun) { const maxDelay = 86400000; const now = new Date(); const delay = nextRun.getTime() - now.getTime(); if (delay > maxDelay) { return maxDelay; } return Math.max(0, delay); } function nowWithoutMs() { const date = new Date(); date.setMilliseconds(0); return date; } var monthNamesConversion = (() => { const months = ['january', 'february', 'march', 'april', 'may', 'june', 'july', 'august', 'september', 'october', 'november', 'december']; const shortMonths = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec']; function convertMonthName(expression, items) { for (let i = 0; i < items.length; i++) { expression = expression.replace(new RegExp(items[i], 'gi'), i + 1); } return expression; } function interpret(monthExpression) { monthExpression = convertMonthName(monthExpression, months); monthExpression = convertMonthName(monthExpression, shortMonths); return monthExpression; } return interpret; })(); var weekDayNamesConversion = (() => { const weekDays = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday']; const shortWeekDays = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat']; function convertWeekDayName(expression, items) { for (let i = 0; i < items.length; i++) { expression = expression.replace(new RegExp(items[i], 'gi'), i); } return expression; } function convertWeekDays(expression) { expression = expression.replace('7', '0'); expression = convertWeekDayName(expression, weekDays); return convertWeekDayName(expression, shortWeekDays); } return convertWeekDays; })(); var convertAsterisksToRanges = (() => { function convertAsterisk(expression, replecement) { if (expression.indexOf('*') !== -1) { return expression.replace('*', replecement); } return expression; } function convertAsterisksToRanges(expressions) { expressions[0] = convertAsterisk(expressions[0], '0-59'); expressions[1] = convertAsterisk(expressions[1], '0-59'); expressions[2] = convertAsterisk(expressions[2], '0-23'); expressions[3] = convertAsterisk(expressions[3], '1-31'); expressions[4] = convertAsterisk(expressions[4], '1-12'); expressions[5] = convertAsterisk(expressions[5], '0-6'); return expressions; } return convertAsterisksToRanges; })(); var convertRanges = (() => { function replaceWithRange(expression, text, init, end, stepTxt) { const step = parseInt(stepTxt); const numbers = []; let last = parseInt(end); let first = parseInt(init); if (first > last) { last = parseInt(init); first = parseInt(end); } for (let i = first; i <= last; i += step) { numbers.push(i); } return expression.replace(new RegExp(text, 'i'), numbers.join()); } function convertRange(expression) { const rangeRegEx = /(\d+)-(\d+)(\/(\d+)|)/; let match = rangeRegEx.exec(expression); while (match !== null && match.length > 0) { expression = replaceWithRange(expression, match[0], match[1], match[2], match[4] || '1'); match = rangeRegEx.exec(expression); } return expression; } function convertAllRanges(expressions) { for (let i = 0; i < expressions.length; i++) { expressions[i] = convertRange(expressions[i]); } return expressions; } return convertAllRanges; })(); var convertExpression = (() => { function appendSecondExpression(expressions) { if (expressions.length === 5) { return ['0'].concat(expressions); } return expressions; } function removeSpaces(str) { return str.replace(/\s{2,}/g, ' ').trim(); } function normalizeIntegers(expressions) { for (let i = 0; i < expressions.length; i++) { const numbers = expressions[i].split(','); for (let j = 0; j < numbers.length; j++) { const token = String(numbers[j]).trim(); if (/^l$/i.test(token)) { numbers[j] = 'L'; } else if (/^[0-7]l$/i.test(token)) { numbers[j] = token.toUpperCase(); } else if (token.indexOf('#') !== -1) { numbers[j] = token; } else { numbers[j] = parseInt(numbers[j]); } } expressions[i] = numbers; } return expressions; } function interpret(expression) { let expressions = removeSpaces(`${expression}`).split(' '); expressions = appendSecondExpression(expressions); expressions[4] = monthNamesConversion(expressions[4]); expressions[5] = weekDayNamesConversion(expressions[5]); expressions = convertAsterisksToRanges(expressions); expressions = convertRanges(expressions); expressions = normalizeIntegers(expressions); return expressions; } return interpret; })(); class LocalizedTime { timestamp; parts; timezone; constructor(date, timezone) { this.timestamp = date.getTime(); this.timezone = timezone; this.parts = buildDateParts(date, timezone); } toDate() { return new Date(this.timestamp); } toISO() { const gmt = this.parts.gmt.replace(/^GMT/, ''); const offset = gmt ? gmt : 'Z'; const pad = (n) => String(n).padStart(2, '0'); return `${this.parts.year}-${pad(this.parts.month)}-${pad(this.parts.day)}` + `T${pad(this.parts.hour)}:${pad(this.parts.minute)}:${pad(this.parts.second)}` + `.${String(this.parts.millisecond).padStart(3, '0')}` + offset; } getParts() { return this.parts; } } function getOffsetMinutes(date, timezone) { const offset = parseOffsetMinutes(getTimezoneGMT(date, timezone).replace(/^GMT/, '') || 'Z'); return offset ?? 0; } function readsBackTo(timestamp, parts, timezone) { const p = buildDateParts(new Date(timestamp), timezone); return p.year === parts.year && p.month === parts.month && p.day === parts.day && p.hour === parts.hour && p.minute === parts.minute && p.second === parts.second; } function localTimeToTimestamp(parts, timezone) { const guess = Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second, parts.millisecond); const firstOffset = getOffsetMinutes(new Date(guess), timezone); const candidate1 = guess - firstOffset * 60000; const secondOffset = getOffsetMinutes(new Date(candidate1), timezone); if (secondOffset === firstOffset) { return candidate1; } const candidate2 = guess - secondOffset * 60000; if (readsBackTo(candidate1, parts, timezone)) return candidate1; if (readsBackTo(candidate2, parts, timezone)) return candidate2; return Math.max(candidate1, candidate2); } const partsFormatterCache = new Map(); const offsetFormatterCache = new Map(); function getPartsFormatter(timezone) { const key = timezone ?? ''; let formatter = partsFormatterCache.get(key); if (!formatter) { const dftOptions = { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', weekday: 'short', hour12: false }; if (timezone) { dftOptions.timeZone = timezone; } formatter = new Intl.DateTimeFormat('en-US', dftOptions); partsFormatterCache.set(key, formatter); } return formatter; } function getOffsetFormatter(timezone) { const key = timezone ?? ''; let formatter = offsetFormatterCache.get(key); if (!formatter) { formatter = new Intl.DateTimeFormat('en-US', { timeZone: timezone, timeZoneName: 'shortOffset' }); offsetFormatterCache.set(key, formatter); } return formatter; } function buildDateParts(date, timezone) { const dateFormat = getPartsFormatter(timezone); const parts = dateFormat.formatToParts(date).filter(part => { return part.type !== 'literal'; }).reduce((acc, part) => { acc[part.type] = part.value; return acc; }, {}); const result = { day: parseInt(parts.day), month: parseInt(parts.month), year: parseInt(parts.year), hour: parts.hour === '24' ? 0 : parseInt(parts.hour), minute: parseInt(parts.minute), second: parseInt(parts.second), millisecond: date.getMilliseconds(), weekday: parts.weekday }; let gmt; Object.defineProperty(result, 'gmt', { enumerable: true, configurable: true, get() { return gmt ??= getTimezoneGMT(date, timezone); } }); return result; } function parseOffsetMinutes(isoString) { if (isoString.endsWith('Z')) return 0; const match = isoString.match(/([+-])(\d{2}):(\d{2})$/); if (!match) return null; const sign = match[1] === '+' ? 1 : -1; return sign * (parseInt(match[2]) * 60 + parseInt(match[3])); } function getTimezoneGMT(date, timezone) { const fmt = getOffsetFormatter(timezone); const parts = fmt.formatToParts(date); const tzPart = parts.find(p => p.type === 'timeZoneName'); if (!tzPart) return 'Z'; const tzValue = tzPart.value; if (tzValue === 'GMT') return 'Z'; const match = tzValue.match(/^GMT([+-])(\d{1,2})(?::(\d{2}))?$/); if (!match) return 'Z'; const sign = match[1]; const hoursNum = parseInt(match[2]); const minutesNum = parseInt(match[3] || '0'); if (hoursNum === 0 && minutesNum === 0) return 'Z'; const hours = match[2].padStart(2, '0'); const minutes = (match[3] || '00').padStart(2, '0'); return `GMT${sign}${hours}:${minutes}`; } const LAST_DAY_TOKEN = 'L'; function lastDayOfMonth(year, month) { return new Date(Date.UTC(year, month, 0)).getUTCDate(); } function matchesDayOfMonth(field, year, month, day) { if (field.includes(day)) return true; if (field.includes(LAST_DAY_TOKEN) && day === lastDayOfMonth(year, month)) return true; return false; } const LAST_WEEKDAY_REGEX = /^([0-7])L$/i; const NTH_WEEKDAY_REGEX = /^([0-7])#([1-5])$/; function parseLastWeekdayToken(value) { if (typeof value !== 'string') return null; const match = LAST_WEEKDAY_REGEX.exec(value); if (!match) return null; const weekday = parseInt(match[1], 10); return weekday === 7 ? 0 : weekday; } function isLastWeekdayOfMonth(year, month, day) { const date = new Date(Date.UTC(year, month - 1, day)); const inSevenDays = new Date(date.getTime()); inSevenDays.setUTCDate(inSevenDays.getUTCDate() + 7); return inSevenDays.getUTCMonth() + 1 !== month; } function isNthWeekdayToken(value) { return typeof value === 'string' && NTH_WEEKDAY_REGEX.test(value); } function parseNthWeekday(value) { if (typeof value !== 'string') return null; const match = NTH_WEEKDAY_REGEX.exec(value); if (!match) return null; const weekday = parseInt(match[1], 10) % 7; const nth = parseInt(match[2], 10); return { weekday, nth }; } function occurrenceInMonth(day) { return Math.floor((day - 1) / 7) + 1; } function matchesNthWeekday(token, year, month, day) { const parsed = parseNthWeekday(token); if (!parsed) return false; const weekday = new Date(Date.UTC(year, month - 1, day)).getUTCDay(); if (weekday !== parsed.weekday) return false; return occurrenceInMonth(day) === parsed.nth; } function matchesDayOfWeek(field, year, month, day, weekday) { for (const value of field) { if (value === weekday) return true; if (isNthWeekdayToken(value)) { if (matchesNthWeekday(value, year, month, day)) return true; continue; } const lastWeekday = parseLastWeekdayToken(value); if (lastWeekday !== null && lastWeekday === weekday && isLastWeekdayOfMonth(year, month, day)) { return true; } } return false; } const MAX_DAYS = 366 * 100; class MatcherWalker { baseDate; timeMatcher; timezone; seconds; minutes; hours; days; months; weekdays; constructor(timeMatcher, baseDate, timezone) { this.baseDate = baseDate; this.timeMatcher = timeMatcher; this.timezone = timezone; const expressions = timeMatcher.expressions; this.seconds = sortedAsc(expressions[0]); this.minutes = sortedAsc(expressions[1]); this.hours = sortedAsc(expressions[2]); this.days = expressions[3]; this.months = expressions[4]; this.weekdays = expressions[5]; } isMatching() { return this.timeMatcher.match(this.baseDate); } matchNext() { const months = this.months; const days = this.days; const baseMs = Math.floor(this.baseDate.getTime() / 1000) * 1000; const baseParts = new LocalizedTime(new Date(baseMs), this.timezone).getParts(); let { year, month, day } = baseParts; for (let i = 0; i < MAX_DAYS; i++) { if (months.includes(month) && matchesDayOfMonth(days, year, month, day) && this.matchesWeekday(year, month, day)) { const lowerBound = i === 0 ? baseParts : null; const found = this.firstTimeOnDay(year, month, day, lowerBound, baseMs); if (found !== null) { return new LocalizedTime(new Date(found), this.timezone); } } ({ year, month, day } = nextDay(year, month, day)); } throw new Error('Could not find next matching date within reasonable time range'); } firstTimeOnDay(year, month, day, lowerBound, baseMs) { const { seconds, minutes, hours } = this; for (const hour of hours) { if (lowerBound && hour < lowerBound.hour) continue; for (const minute of minutes) { for (const second of seconds) { if (lowerBound && !isLaterInDay(hour, minute, second, lowerBound)) continue; const ts = localTimeToTimestamp({ year, month, day, hour, minute, second, millisecond: 0 }, this.timezone); if (ts > baseMs && this.timeMatcher.match(new Date(ts))) { return ts; } } } } return null; } matchesWeekday(year, month, day) { const weekday = new Date(Date.UTC(year, month - 1, day)).getUTCDay(); return matchesDayOfWeek(this.weekdays, year, month, day, weekday); } } function nextDay(year, month, day) { const d = new Date(Date.UTC(year, month - 1, day + 1)); return { year: d.getUTCFullYear(), month: d.getUTCMonth() + 1, day: d.getUTCDate() }; } function sortedAsc(values) { return [...values].sort((a, b) => a - b); } function isLaterInDay(hour, minute, second, bound) { return hour * 3600 + minute * 60 + second > bound.hour * 3600 + bound.minute * 60 + bound.second; } function matchValue(allowedValues, value) { return allowedValues.indexOf(value) !== -1; } class TimeMatcher { timezone; pattern; expressions; constructor(pattern, timezone) { this.timezone = timezone; this.pattern = pattern; this.expressions = convertExpression(pattern); } match(date) { const localizedTime = new LocalizedTime(date, this.timezone); const parts = localizedTime.getParts(); const runOnSecond = matchValue(this.expressions[0], parts.second); const runOnMinute = matchValue(this.expressions[1], parts.minute); const runOnHour = matchValue(this.expressions[2], parts.hour); const runOnDay = matchesDayOfMonth(this.expressions[3], parts.year, parts.month, parts.day); const runOnMonth = matchValue(this.expressions[4], parts.month); const weekday = parseInt(weekDayNamesConversion(parts.weekday)); const runOnWeekDay = matchesDayOfWeek(this.expressions[5], parts.year, parts.month, parts.day, weekday); return runOnSecond && runOnMinute && runOnHour && runOnDay && runOnMonth && runOnWeekDay; } getNextMatch(date) { const walker = new MatcherWalker(this, date, this.timezone); const next = walker.matchNext(); return next.toDate(); } } const allowedTransitions = { 'stopped': ['stopped', 'idle', 'destroyed'], 'idle': ['idle', 'running', 'stopped', 'destroyed'], 'running': ['running', 'idle', 'stopped', 'destroyed'], 'destroyed': ['destroyed'] }; class StateMachine { state; constructor(initial = 'stopped') { this.state = initial; } changeState(state) { if (allowedTransitions[this.state].includes(state)) { this.state = state; } else { throw new Error(`invalid transition from ${this.state} to ${state}`); } } } class EnvVarRunCoordinator { envName; constructor(envName = 'NODE_CRON_RUN') { this.envName = envName; this.read(); } shouldRun() { return this.read(); } read() { const value = process.env[this.envName]; if (value !== 'true' && value !== 'false') { throw new Error(`node-cron: a \`distributed\` task needs ${this.envName} set to 'true' or 'false'. ` + `Set it to 'true' on exactly one instance and 'false' on the others, ` + `or provide a coordinator via cron.setRunCoordinator(...).`); } return value === 'true'; } } let globalRunCoordinator; function setRunCoordinator(coordinator) { globalRunCoordinator = coordinator; } function resolveRunCoordinator(perTask) { return perTask ?? globalRunCoordinator ?? new EnvVarRunCoordinator(); } class TaskEmitter extends EventEmitter { } class InlineScheduledTask { emitter; cronExpression; timeMatcher; runner; id; name; stateMachine; timezone; logger; suppressMissedWarning; _lastRun = null; constructor(cronExpression, taskFn, options) { this.emitter = new TaskEmitter(); this.cronExpression = cronExpression; this.id = createID(); this.name = options?.name || this.id; this.timezone = options?.timezone; this.logger = options?.logger || logger; this.suppressMissedWarning = options?.suppressMissedWarning || false; this.timeMatcher = new TimeMatcher(cronExpression, options?.timezone); this.stateMachine = new StateMachine(); const runnerOptions = { timezone: options?.timezone, noOverlap: options?.noOverlap, maxExecutions: options?.maxExecutions, maxRandomDelay: options?.maxRandomDelay, missedExecutionTolerance: options?.missedExecutionTolerance, logger: this.logger, beforeRun: (date, execution) => { if (execution.reason === 'scheduled') { this.changeState('running'); } this.emitter.emit('execution:started', this.createContext(date, execution)); return true; }, onFinished: (date, execution) => { if (execution.reason === 'scheduled') { this.changeState('idle'); } this.recordLastRun(execution); this.emitter.emit('execution:finished', this.createContext(date, execution)); return true; }, onError: (date, error, execution) => { this.logger.error(error); this.recordLastRun(execution); this.emitter.emit('execution:failed', this.createContext(date, execution)); this.changeState('idle'); }, onOverlap: (date) => { this.emitter.emit('execution:overlap', this.createContext(date)); }, onMissedExecution: (date) => { const handled = this.emitter.listenerCount('execution:missed') > 0; if (!this.suppressMissedWarning && !handled) { this.logger.warn(`missed execution at ${date}! Possible blocking IO or high CPU user at the same process used by node-cron.`); } this.emitter.emit('execution:missed', this.createContext(date)); }, onMaxExecutions: (date) => { this.emitter.emit('execution:maxReached', this.createContext(date)); this.destroy(); }, runCoordinator: options?.distributed ? resolveRunCoordinator(options?.runCoordinator) : undefined, coordinatorKeyPrefix: this.name, coordinatorTtl: options?.distributedLease, onSkipped: (date, reason) => { this.emitter.emit('execution:skipped', this.createContext(date, undefined, reason)); } }; this.runner = new Runner(this.timeMatcher, (date, execution) => { return taskFn(this.createContext(date, execution)); }, runnerOptions); } getNextRun() { if (this.stateMachine.state !== 'stopped') { return this.runner.nextRun(); } return null; } getNextRuns(count) { const runs = []; let from = new Date(); for (let i = 0; i < count; i++) { from = this.timeMatcher.getNextMatch(from); runs.push(from); } return runs; } match(date) { return this.timeMatcher.match(date); } msToNext() { const next = this.getNextRun(); return next ? next.getTime() - Date.now() : null; } isBusy() { return this.getStatus() === 'running'; } runsLeft() { if (this.runner.maxExecutions == null) return undefined; return Math.max(0, this.runner.maxExecutions - this.runner.runCount); } getPattern() { return this.cronExpression; } lastRun() { return this._lastRun; } recordLastRun(execution) { const date = execution.finishedAt; const lastRun = { date }; if (execution.error) { lastRun.error = execution.error; } else { lastRun.result = execution.result; } this._lastRun = lastRun; } changeState(state) { if (this.runner.isStarted()) { this.stateMachine.changeState(state); } } start() { if (this.runner.isStopped()) { this.runner.start(); this.stateMachine.changeState('idle'); this.emitter.emit('task:started', this.createContext(new Date())); } } stop() { if (this.runner.isStarted()) { this.runner.stop(); this.stateMachine.changeState('stopped'); this.emitter.emit('task:stopped', this.createContext(new Date())); } } getStatus() { return this.stateMachine.state; } destroy() { if (this.stateMachine.state === 'destroyed') return; this.stop(); this.stateMachine.changeState('destroyed'); this.emitter.emit('task:destroyed', this.createContext(new Date())); } execute() { return new Promise((resolve, reject) => { const onFail = (context) => { this.off('execution:finished', onFinished); reject(context.execution?.error); }; const onFinished = (context) => { this.off('execution:failed', onFail); resolve(context.execution?.result); }; this.once('execution:finished', onFinished); this.once('execution:failed', onFail); this.runner.execute(); }); } on(event, fun) { this.emitter.on(event, fun); } off(event, fun) { this.emitter.off(event, fun); } once(event, fun) { this.emitter.once(event, fun); } createContext(executionDate, execution, reason) { const localTime = new LocalizedTime(executionDate, this.timezone); const ctx = { date: localTime.toDate(), dateLocalIso: localTime.toISO(), triggeredAt: new Date(), task: this, execution: execution }; if (reason) ctx.reason = reason; return ctx; } } export { InlineScheduledTask as I, LocalizedTime as L, StateMachine as S, TimeMatcher as T, createID as a, setLogger as b, convertExpression as c, isNthWeekdayToken as i, logger as l, noopLogger as n, resolveRunCoordinator as r, setRunCoordinator as s }; //# sourceMappingURL=_shared.js.map