From 55b241605be91cd1dbb73e7f981bb3386dc474ae Mon Sep 17 00:00:00 2001 From: Kanwar Ujjaval Singh <4216199+kanwarujjaval@users.noreply.github.com> Date: Tue, 14 Jan 2025 11:21:44 +0530 Subject: [PATCH 01/90] init jobserveR --- api/utils/log.v2.js | 0 jobServer/Job.js | 94 +++++++++ jobServer/JobManager.js | 106 ++++++++++ jobServer/JobScanner.js | 246 ++++++++++++++++++++++ jobServer/JobServer.js | 189 +++++++++++++++++ jobServer/JobUtils.js | 238 +++++++++++++++++++++ jobServer/README.md | 1 + jobServer/config.js | 24 +++ jobServer/index.js | 64 ++++++ jobServer/jobRunner/IJobRunner.js | 84 ++++++++ jobServer/jobRunner/JobRunnerBullImpl.js | 95 +++++++++ jobServer/jobRunner/JobRunnerPulseImpl.js | 114 ++++++++++ jobServer/jobRunner/index.js | 39 ++++ 13 files changed, 1294 insertions(+) create mode 100644 api/utils/log.v2.js create mode 100644 jobServer/Job.js create mode 100644 jobServer/JobManager.js create mode 100644 jobServer/JobScanner.js create mode 100644 jobServer/JobServer.js create mode 100644 jobServer/JobUtils.js create mode 100644 jobServer/README.md create mode 100644 jobServer/config.js create mode 100644 jobServer/index.js create mode 100644 jobServer/jobRunner/IJobRunner.js create mode 100644 jobServer/jobRunner/JobRunnerBullImpl.js create mode 100644 jobServer/jobRunner/JobRunnerPulseImpl.js create mode 100644 jobServer/jobRunner/index.js diff --git a/api/utils/log.v2.js b/api/utils/log.v2.js new file mode 100644 index 00000000000..e69de29bb2d diff --git a/jobServer/Job.js b/jobServer/Job.js new file mode 100644 index 00000000000..56b618874d3 --- /dev/null +++ b/jobServer/Job.js @@ -0,0 +1,94 @@ +/** + * Represents a job. + */ +class Job { + + jobName = Job.name; + + logger = { + d: console.debug, + w: console.warn, + e: console.error, + i: console.info + }; + + /** + * Creates an instance of Job. + */ + constructor() { + this.doneCallback = this.doneCallback.bind(this); + } + + /** + * Sets the name of the job. + * @param {String} name The name of the job + * @returns {void} The name of the job + */ + setJobName(name) { + this.jobName = name; + } + + /** + * Sets the logger + * @param {Logger} logger The logger + */ + setLogger(logger) { + this.logger = logger; + } + + /** + * Runs the job. + * @param {Object} db The database connection + * @param {Function} done The callback function to be called when the job is done + * @throws {Error} If the method is not overridden + * @abstract + */ + async run(/*db, done*/) { + throw new Error('Job must be overridden'); + } + + /** + * Get the schedule for the job in cron format. + * @returns {string} The cron schedule + * @throws {Error} If the method is not overridden + * @abstract + */ + schedule() { + throw new Error('schedule must be overridden'); + } + + /** + * Callback function to be called when the job is done. + * @param {Error} error The error that occurred + * @param {Object} result The job that was run + * @returns {Error} The error to be returned to the job run by pulseScheduler + */ + doneCallback(error, result) { + if (error) { + this.logger.e('Job failed with error:', error); + throw error; + } + else { + this.logger.i('Job completed successfully:', result ? result : ''); + return result ? result : null; + } + } + + /** + * Runs the job. + * @param { Object } db The database + * @private + */ + _run(db) { + this.logger.d(`Job "${this.jobName}" is starting with database:`, db?._cly_debug?.db); + try { + this.run(db, this.doneCallback); + } + catch (error) { + this.logger.e(`Job "${this.jobName}" encountered an error during execution:`, error); + this.doneCallback(error, this); + } + } +} + +module.exports = Job; \ No newline at end of file diff --git a/jobServer/JobManager.js b/jobServer/JobManager.js new file mode 100644 index 00000000000..abf51d104ce --- /dev/null +++ b/jobServer/JobManager.js @@ -0,0 +1,106 @@ +const {RUNNER_TYPES, createJobRunner} = require('./JobRunner'); +const config = require("./config"); + +/** + * Manages job configurations and initialization. + */ +class JobManager { + + /** + * The logger instance + * @private + * @type {import('../api/utils/log.js').Logger} + * */ + #log; + + /** + * The database connection + * @type {import('mongodb').Db | null} + */ + #db = null; + + /** + * The job runner instance + * @type {IJobRunner | JobRunnerPulseImpl |null} + */ + #jobRunner = null; + + /** + * Creates a new JobManager instance + * @param {Object} db Database connection + * @param {function} Logger - Logger constructor + */ + constructor(db, Logger) { + this.Logger = Logger; + this.#db = db; + this.#log = Logger('jobs:manager'); + this.#log.d('Creating JobManager'); + + const runnerType = RUNNER_TYPES.PULSE; + const pulseConfig = config.PULSE; + + this.#jobRunner = createJobRunner(this.#db, runnerType, pulseConfig, Logger); + } + + /** + * Starts the job manager by loading and running jobs. + * @param {Object.} jobClasses Object containing job classes keyed by job name + * @returns {Promise} A promise that resolves once the jobs are started. + */ + async start(jobClasses) { + if (!jobClasses || Object.keys(jobClasses).length === 0) { + throw new Error('No job classes provided'); + } + if (!this.#jobRunner) { + throw new Error('Job runner not initialized'); + } + + await this.#loadJobs(jobClasses); + await this.#jobRunner.start(); + await this.#scheduleJobs(jobClasses); + this.#log.d('JobManager started successfully'); + } + + /** + * Loads the job classes into the job runner + * @param {Object.} jobClasses Object containing job classes keyed by job name + * @returns {Promise} A promise that resolves once the jobs are loaded + */ + #loadJobs(jobClasses) { + return Promise.all( + Object.entries(jobClasses) + .map(([name, JobClass]) => { + this.#jobRunner.createJob(name, JobClass); + }) + ); + } + + /** + * Schedules the jobs to run at their respective intervals + * @param { Object. } jobClasses Object containing job classes keyed by job name + * @returns {Promise[]>} A promise that resolves once the jobs are scheduled + */ + #scheduleJobs(jobClasses) { + return Promise.all( + Object.entries(jobClasses) + .map(([name, JobClass]) => { + let instance = new JobClass(name); + let schedule = instance.schedule(); + if (schedule) { + this.#jobRunner.schedule(name, schedule); + } + }) + ); + } + + /** + * Closes the JobManager and cleans up resources + * @returns {Promise} A promise that resolves once cleanup is complete + */ + async close() { + this.#log.d('JobManager closed successfully'); + await this.#jobRunner.close(); + } +} + +module.exports = JobManager; \ No newline at end of file diff --git a/jobServer/JobScanner.js b/jobServer/JobScanner.js new file mode 100644 index 00000000000..374172438fa --- /dev/null +++ b/jobServer/JobScanner.js @@ -0,0 +1,246 @@ +/** + * @fileoverview Job Scanner module - Scans for and loads job files from both core API and plugins + * @module jobs/scanner + */ + +const path = require('path'); +const fs = require('fs').promises; +const JobUtils = require('./JobUtils'); + +/** + * @typedef {Object} JobConfig + * @property {string} category - Category of the job (e.g., 'api' or plugin name) + * @property {string} dir - Directory path containing job files + */ + +/** + * @typedef {Object} JobDescriptor + * @property {string} category - Category of the job + * @property {string} name - Name of the job file (without extension) + * @property {string} file - Full path to the job file + */ + +/** + * @typedef {Object} ScanResult + * @property {Object.} files - Object storing job file paths, keyed by job name + * @property {Object.} classes - Object storing job class implementations, keyed by job name + */ + +/** + * Class responsible for scanning and loading job files from both core API and plugins + */ +class JobScanner { + /** + * @private + * @type {import('mongodb').Db} + * */ + #db; + + /** + * @private + * @type {import('../api/utils/log.js').Logger} + * */ + #log; + + /** + * @private + * @type {import('../plugins/pluginManager.js')} + */ + #pluginManager; + + /** + * @private + * @type {string} + */ + #baseJobsPath; + + /** + * @private + * @type {string} + */ + #pluginsPath; + + /** + * @private + * @type {Object.} + */ + #currentFiles = {}; + + /** + * @private + * @type {Object.} + */ + #currentClasses = {}; + + /** + * Creates a new JobScanner instance + * @param {Object} db - Database connection object + * @param {function} Logger - Logging function + * @param {pluginManager} pluginManager - Plugin manager instance + */ + constructor(db, Logger, pluginManager) { + this.#pluginManager = pluginManager; + this.#db = db; + this.#log = Logger('jobs:scanner'); + this.#baseJobsPath = path.join(__dirname, '../api/jobs'); + this.#pluginsPath = path.join(__dirname, '../plugins'); + } + + /** + * Safely reads a directory and filters for job files + * @private + * @param {JobConfig} jobConfig - Configuration for the job directory to scan + * @returns {Promise} Array of job descriptors found in directory + */ + async #scanJobDirectory(jobConfig) { + try { + const files = await fs.readdir(jobConfig.dir); + const jobFiles = []; + + for (const file of files) { + const fullPath = path.join(jobConfig.dir, file); + try { + const stats = await fs.stat(fullPath); + if (stats.isFile()) { + jobFiles.push({ + category: jobConfig.category, + name: path.basename(file, '.js'), + file: fullPath + }); + } + } + catch (err) { + this.#log.w(`Failed to stat file ${fullPath}: ${err.message}`); + } + } + + return jobFiles; + } + catch (err) { + this.#log.w(`Failed to read directory ${jobConfig.dir}: ${err.message}`); + return []; + } + } + + /** + * Loads a single job file and returns its information + * @private + * @param {JobDescriptor} job - Descriptor for the job to load + * @returns {{name: string, file: string, implementation: Function}} Job information + */ + #loadJobFile(job) { + const jobName = `${job.category}:${job.name}`; + try { + // Clear require cache to ensure fresh load + // delete require.cache[require.resolve(job.file)]; + const implementation = require(job.file); + this.#log.d(`Loaded job ${jobName} from ${job.file}`); + JobUtils.validateJobClass(implementation); + + return { + name: jobName, + file: job.file, + implementation + }; + } + catch (err) { + this.#log.e(`Failed to load job ${job.file}: ${err.message}`, err); + return null; + } + } + + /** + * Initializes plugin configurations + * @private + * @returns {Promise} A promise that resolves once the plugin configurations are loaded + */ + async #initializeConfig() { + return new Promise((resolve) => { + this.#pluginManager.loadConfigs(this.#db, () => resolve()); + }); + } + + /** + * Gets the list of directories to scan for jobs + * @private + * @param {string[]} plugins - List of plugin names + * @returns {JobConfig[]} Array of job directory configurations + */ + #getJobDirectories(plugins) { + return [ + { + category: 'api', + dir: this.#baseJobsPath + }, + ...plugins.map(plugin => ({ + category: plugin, + dir: path.join(this.#pluginsPath, plugin, 'api/jobs') + })) + ]; + } + + /** + * @private + * Stores a loaded job in the internal collections + * @param {{name: string, file: string, implementation: Function}} loaded - Loaded job information + */ + #storeLoadedJob(loaded) { + if (loaded) { + this.#currentFiles[loaded.name] = loaded.file; + this.#currentClasses[loaded.name] = loaded.implementation; + } + } + + /** + * Scans for job files and returns the loaded jobs + * @returns {Promise} Object containing job files and implementations + * @throws {Error} If plugin configuration is invalid or missing + */ + async scan() { + // Initialize plugin manager + await this.#initializeConfig(); + + const plugins = this.#pluginManager.getPlugins(true); + if (!plugins?.length) { + throw new Error('No valid plugins.json configuration found'); + } + + this.#log.i('Scanning plugins:', plugins); + + // Reset current collections + this.#currentFiles = {}; + this.#currentClasses = {}; + + // Build list of directories to scan + const jobDirs = this.#getJobDirectories(plugins); + + // Scan all directories concurrently + const jobFiles = await Promise.all( + jobDirs.map(dir => this.#scanJobDirectory(dir)) + ); + + // Load all discovered jobs and collect results + jobFiles.flat() + .filter(Boolean) + .forEach(job => { + const loaded = this.#loadJobFile(job); + this.#storeLoadedJob(loaded); + }); + + return { + files: this.#currentFiles, + classes: this.#currentClasses + }; + } + + /** + * Gets the current number of loaded jobs + * @public + * @returns {number} Number of loaded jobs + */ + get loadedJobCount() { + return Object.keys(this.#currentFiles).length; + } +} + +module.exports = JobScanner; \ No newline at end of file diff --git a/jobServer/JobServer.js b/jobServer/JobServer.js new file mode 100644 index 00000000000..a2441896944 --- /dev/null +++ b/jobServer/JobServer.js @@ -0,0 +1,189 @@ +const JobManager = require('./JobManager'); +const JobScanner = require('./JobScanner'); + +/** + * Class representing a job process. + */ +class JobServer { + + /** + * The logger instance + * @private + * @type {import('../api/utils/log.js').Logger} + * */ + #log; + + /** + * The plugin manager instance + * @private + * @type {import('../plugins/pluginManager.js')} + */ + #pluginManager; + + /** + * The Countly common object + * @private + * @type {import('../api/utils/common.js')} + */ + #common; + + /** + * The job manager instance + * @private + * @type {JobManager} + */ + #jobManager; + + /** + * The job scanner instance + * @private + * @type {JobScanner} + */ + #jobScanner; + + /** + * Flag indicating whether the job process is running + * @private + */ + #isRunning = false; + + /** + * The database connection + * @type {import('mongodb').Db | null} + */ + #db = null; + + /** + * Creates a new JobProcess instance. + * @param {Object} common Countly common + * @param {function} Logger - Logger constructor + * @param {pluginManager} pluginManager - Plugin manager instance + * @returns {Promise} A promise that resolves to a new JobProcess instance. + */ + static async create(common, Logger, pluginManager) { + const process = new JobServer(common, Logger, pluginManager); + await process.init(); + return process; + } + + /** + * Creates a new JobServer instance + * @param {Object} common Countly common + * @param {function} Logger - Logger constructor + * @param {pluginManager} pluginManager - Plugin manager instance + */ + constructor(common, Logger, pluginManager) { + this.#common = common; + this.Logger = Logger; + this.#log = Logger('jobs:server'); + this.#pluginManager = pluginManager; + } + + /** + * init the job process. + * @returns {Promise} A promise that resolves once the job process is initialized. + */ + async init() { + try { + await this.#connectToDb(); + + this.#jobManager = new JobManager(this.#db, this.Logger); + this.#jobScanner = new JobScanner(this.#db, this.Logger, this.#pluginManager); + + this.#setupSignalHandlers(); + this.#log.i('Job process init successfully'); + } + catch (error) { + this.#log.e('Failed to initialize job process:', error); + throw error; + } + } + + /** + * Starts the job process. + * @returns {Promise} A promise that resolves once the job process is started. + */ + async start() { + if (this.#isRunning) { + this.#log.w('Process is already running'); + return; + } + + try { + this.#isRunning = true; + this.#log.i('Starting job process'); + + // Load job classes + const { classes } = await this.#jobScanner.scan(); + // Start job manager + + await this.#jobManager.start(classes); + this.#log.i('Job process is running'); + } + catch (error) { + this.#log.e('Error starting job process:', error); + await this.#shutdown(1); + } + } + + /** + * Connects to the mongo database. + * @returns {Promise} A promise that resolves once the connection is established. + */ + async #connectToDb() { + try { + this.#db = await this.#pluginManager.dbConnection('countly'); + } + catch (e) { + this.#log.e('Failed to connect to database:', e); + throw e; + } + } + + /** + * Sets up signal handlers for graceful shutdown and uncaught exceptions. + */ + #setupSignalHandlers() { + // Handle graceful shutdown + process.on('SIGTERM', () => this.#shutdown()); + process.on('SIGINT', () => this.#shutdown()); + + // Handle uncaught errors + process.on('uncaughtException', (error) => { + this.#log.e('Uncaught exception:', error); + this.#shutdown(1); + }); + } + + /** + * Shuts down the job process. + * @param {number} [exitCode=0] - The exit code to use when shutting down the process. + * @returns {Promise} A promise that resolves once the job process is shut down. + */ + async #shutdown(exitCode = 0) { + if (!this.#isRunning) { + this.#log.w('Shutdown called but process is not running'); + process.exit(exitCode); + return; + } + + this.#log.i('Shutting down job process...'); + this.#isRunning = false; + + try { + if (this.#jobManager) { + await this.#jobManager.close(); + } + this.#log.i('Job process shutdown complete'); + } + catch (error) { + this.#log.e('Error during shutdown:', error); + exitCode = 1; + } + finally { + process.exit(exitCode); + } + } +} + +module.exports = JobServer; \ No newline at end of file diff --git a/jobServer/JobUtils.js b/jobServer/JobUtils.js new file mode 100644 index 00000000000..2b69927ae7b --- /dev/null +++ b/jobServer/JobUtils.js @@ -0,0 +1,238 @@ +const Job = require('./Job'); +const {isValidCron} = require('cron-validator'); +const later = require('@breejs/later'); + +/** + * Class responsible for validating job classes. + */ +class JobUtils { + + /** + * Validates if a given job class is valid. + * @param {Function} JobClass - The job class to validate. + * @param {Function} [BaseClass=Job] - The base class that the job class should extend. + * @returns {boolean} True if the job class is valid. + * @throws {Error} If the job class is not a constructor or does not extend the base class. + */ + static validateJobClass(JobClass, BaseClass = Job) { + // Check if it's a class/constructor + if (typeof JobClass !== 'function') { + throw new Error('Job must be a class constructor'); + } + + // Check if it inherits from the base class + if (!(JobClass.prototype instanceof BaseClass)) { + throw new Error(`Job class must extend ${BaseClass.name}`); + } + + // Additional checks can be added here + return true; + } + + /** + * Converts a later.js schedule to a cron string. + * @param {String} laterString - The later.js schedule string. + * @constructor + * @retuns {String} The cron string. + * + * @note + * TESTS NEEDED + * + * "every 5 minutes" + * "at 01:01 am every 1 day" + * "every seconds" + * "at 3:00 am every 7 days" + * "every 1 hour on the 1st min" + * "every 1 day" + * "every 1 day" + * "every 1 hour starting on the 0 min" + * "once in 2 hours" + * "0 0 * * *" + * "every 5 minutes" + * "at 10:15 am every weekday" + * "at 00:01 am every 1 day" + * "every 1 day" + * "every 1 day" + * "every 1 day" + * "every 1 day" + * "every 5 minutes" + * "every 10 minutes" + * "every 1 minute" + * "every 1 day" + * "every 10 minutes" + * "every 30 minutes" + * "every 1 minutes" + * "every 2 minutes" + * "every 5 minutes" + * "every 5 minute" + * "every 1 hour" + * "at 00:30 am every 1 day" + * "every 5 minutes" + * "every 12 hours" + * "every 5 minutes" + * "every 1 year" + * "every 1 day" + * "every 1 hour" + * "at 03:25 am every 1 day" + * "every 1 hour" + * "every 5 minutes" + */ + static LaterToCron(laterString) { + // Handle direct cron expressions + if (isValidCron(laterString)) { + return laterString; + } + + // Parse the schedule + const schedule = later.parse.text(laterString); + if (schedule.error !== -1) { + throw new Error(`Invalid schedule string: ${laterString}`); + } + + + /** + * Get the values of a component from the schedule + * @param {Object} component - The component to extract values from. + * @returns {any[]|*[]} The values of the component. + */ + function getValues(component) { + if (!schedule.schedules || schedule.schedules.length === 0) { + return []; + } + const values = new Set(); + schedule.schedules.forEach(s => { + if (s[component]) { + s[component].forEach(val => values.add(val)); + } + }); + return Array.from(values).sort((a, b) => a - b); + } + + // Extract schedule components + const minutes = getValues('m'); + const hours = getValues('h'); + const daysOfMonth = getValues('D'); + const months = getValues('M'); + const daysOfWeek = getValues('d'); + + // Common pattern detection + const isEveryMinute = laterString.match(/every\s+(\d+)\s*minute/); + const isEveryHour = laterString.match(/every\s+(\d+)\s*hour/); + const isSpecificTime = laterString.match(/at\s+(\d{1,2}):(\d{2})\s*(am|pm)?/i); + const isEveryDay = laterString.includes('every 1 day') || laterString.includes('every day'); + const isEveryYear = laterString.includes('every 1 year') || laterString.includes('yearly'); + const isWeekday = laterString.toLowerCase().includes('weekday'); + const isEveryNDays = laterString.match(/every\s+(\d+)\s+days?/); + const isLastDayOfMonth = laterString.includes("last day of the month"); + const isOnceInHours = laterString.match(/once in (\d+) hours?/); + + // Handle specific patterns + if (isEveryYear) { + return '0 0 1 1 *'; // Midnight on January 1st + } + + if (isEveryMinute) { + const interval = parseInt(isEveryMinute[1]); + return `*/${interval} * * * *`; + } + + if (isEveryHour) { + const interval = parseInt(isEveryHour[1]); + return laterString.includes('on the 1st min') + ? `1 */${interval} * * *` + : `0 */${interval} * * *`; + } + + if (isSpecificTime && isEveryDay) { + let [, hour, minute, meridiem] = isSpecificTime; + hour = parseInt(hour); + minute = parseInt(minute); + if (meridiem) { + hour = meridiem.toLowerCase() === 'pm' && hour < 12 ? hour + 12 : hour; + hour = meridiem.toLowerCase() === 'am' && hour === 12 ? 0 : hour; + } + return `${minute} ${hour} * * *`; + } + + if (isWeekday) { + if (isSpecificTime) { + let [, hour, minute, meridiem] = isSpecificTime; + hour = parseInt(hour); + minute = parseInt(minute); + if (meridiem) { + hour = meridiem.toLowerCase() === 'pm' && hour < 12 ? hour + 12 : hour; + hour = meridiem.toLowerCase() === 'am' && hour === 12 ? 0 : hour; + } + return `${minute} ${hour} * * 1-5`; + } + return '0 0 * * 1-5'; + } + + if (isEveryNDays) { + const interval = parseInt(isEveryNDays[1]); + if (isSpecificTime) { + let [, hour, minute, meridiem] = isSpecificTime; + hour = parseInt(hour); + minute = parseInt(minute); + if (meridiem) { + hour = meridiem.toLowerCase() === 'pm' && hour < 12 ? hour + 12 : hour; + hour = meridiem.toLowerCase() === 'am' && hour === 12 ? 0 : hour; + } + return `${minute} ${hour} */${interval} * *`; + } + return `0 0 */${interval} * *`; + } + + if (isLastDayOfMonth) { + if (isSpecificTime) { + let [, hour, minute, meridiem] = isSpecificTime; + hour = parseInt(hour); + minute = parseInt(minute); + if (meridiem) { + hour = meridiem.toLowerCase() === 'pm' && hour < 12 ? hour + 12 : hour; + hour = meridiem.toLowerCase() === 'am' && hour === 12 ? 0 : hour; + } + return `${minute} ${hour} L * *`; + } + return `0 0 L * *`; + } + + if (isOnceInHours) { + const interval = parseInt(isOnceInHours[1]); + return `0 */${interval} * * *`; + } + + /** + * Formats a component of the cron string. + * @param {String} values - The values of the component. + * @param {Number} total - The total number of possible + * @returns {string|*} The formatted component. + */ + function formatComponent(values, total) { + if (values.length === 0) { + return '*'; + } + if (values.length === 1) { + return values[0]; + } + if (values.length === total) { + return '*'; + } + + const interval = values.length > 1 && values[1] - values[0]; + const isInterval = values.every((v, i) => i === 0 || v - values[i - 1] === interval); + return isInterval ? `*/${interval}` : values.join(','); + } + + const minutePart = formatComponent(minutes, 60); + const hourPart = formatComponent(hours, 24); + const dayPart = formatComponent(daysOfMonth, 31); + const monthPart = formatComponent(months, 12); + const dayOfWeekPart = formatComponent(daysOfWeek, 7); + + return `${minutePart} ${hourPart} ${dayPart} ${monthPart} ${dayOfWeekPart}`; + } + +} + +module.exports = JobUtils; \ No newline at end of file diff --git a/jobServer/README.md b/jobServer/README.md new file mode 100644 index 00000000000..c858d63ada6 --- /dev/null +++ b/jobServer/README.md @@ -0,0 +1 @@ +# Job Server \ No newline at end of file diff --git a/jobServer/config.js b/jobServer/config.js new file mode 100644 index 00000000000..acb82171fbc --- /dev/null +++ b/jobServer/config.js @@ -0,0 +1,24 @@ +/** + * Default configuration for Pulse jobs + * @type {import('@pulsecron/pulse').PulseConfig} + */ +const DEFAULT_PULSE_CONFIG = { + name: 'core', // Name of the Pulse instance + processEvery: '10 seconds', // Frequency to check for new jobs + maxConcurrency: 5, // Maximum number of jobs that can run concurrently + defaultConcurrency: 3, // Default number of jobs that can run concurrently + lockLimit: 3, // Maximum number of jobs that can be locked at the same time + defaultLockLimit: 2, // Default number of jobs that can be locked at the same time + defaultLockLifetime: 55 * 60 * 1000, // 55 minutes, time in milliseconds for how long a job should be locked + sort: { nextRunAt: 1, priority: -1 }, // Sorting order for job execution + disableAutoIndex: false, // Whether to disable automatic index creation + resumeOnRestart: true, // Whether to resume jobs on restart + db: { + collection: 'pulseJobs', // MongoDB collection to store jobs + } +}; + +module.exports = { + PULSE: DEFAULT_PULSE_CONFIG, + BULL: {} +}; \ No newline at end of file diff --git a/jobServer/index.js b/jobServer/index.js new file mode 100644 index 00000000000..c551753bed4 --- /dev/null +++ b/jobServer/index.js @@ -0,0 +1,64 @@ +/** + * @module jobServer + * @version 2.0 + * @author Countly + * + * @note + * Dependencies like common utilities and plugin manager should only be imported in this entry file + * and injected into the respective modules via their constructors or create methods. + * + * @description + * This module provides the job management system for countly. + * It handles job scheduling, execution, and management through a flexible API. + * + * + * @property {typeof import('./Job')} Job - Class for creating and managing individual jobs + * @property {typeof import('./JobServer')} JobServer - Class for running jobs in a separate process + * + * @throws {Error} When database connection fails during initialization + * @throws {Error} When job definition is invalid + * + * @requires './Job' + * @requires './JobServer' + * + * @external Common + * @see {@import ../api/utils/common.js|common} + * + * @external PluginManager + * @see {@import ../plugins/pluginManager.js|PluginManager} + * + * @execution + * This module can be run directly as a standalone process: + * + * ```bash + * node index.js + * ``` + * When run directly, it will: + * 1. Create a new JobServer instance + * 2. Initialize it with the common utilities and plugin manager + * 3. Start the job processing + * 4. Handle process signals (SIGTERM, SIGINT) for graceful shutdown + * + */ + +const JobServer = require('./JobServer'); +const Job = require('./Job'); + +// Start the process if this file is run directly +if (require.main === module) { + + const common = require('../api/utils/common.js'); + const pluginManager = require('../plugins/pluginManager.js'); + const Logger = common.log; + + JobServer.create(common, Logger, pluginManager) + .then(process => process.start()) + .catch(error => { + console.error('Failed to start job process:', error); + process.exit(1); + }); +} + +module.exports = { + Job: Job +}; \ No newline at end of file diff --git a/jobServer/jobRunner/IJobRunner.js b/jobServer/jobRunner/IJobRunner.js new file mode 100644 index 00000000000..86fd08daf59 --- /dev/null +++ b/jobServer/jobRunner/IJobRunner.js @@ -0,0 +1,84 @@ +/** + * Interface for job runner implementations + */ +class IJobRunner { + + db; + + config; + + log; + + /** + *@param {Object} db Database connection + * @param {Object} config Configuration + * @param {function} Logger - Logger constructor + */ + constructor(db, config, Logger) { + this.log = Logger('jobs:runner'); + this.db = db; + this.config = config; + } + + /** + * Defines a new job + * @param {String} jobName The name of the job + * @param {function} jobRunner The job runner function + * @param {Object} [jobOptions=null] The job options + * @returns {Promise} A promise that resolves once the job is defined + */ + async createJob(/*jobName, jobRunner, jobOptions=null*/) { + throw new Error('Method not implemented'); + } + + /** + * Schedules jobs + * @param {String} name The name of the job + * @param {String} schedule Cron string for the job schedule + * @param {Object} data Data to pass to the job + * @returns {Promise} A promise that resolves once the job is scheduled + */ + async schedule(/*name, schedule, data*/) { + throw new Error('Method not implemented'); + } + + /** + * Runs a job once + * @param {String} name The name of the job + * @param {Date} date The schedule + * @param {Object} data Data to pass to the job + * @returns {Promise} A promise that resolves once the job is scheduled + */ + async once(/*name, date, data*/) { + throw new Error('Method not implemented'); + } + + /** + * Runs a job now + * @param {String} name The name of the job + * @param {Object} data Data to pass to the job + * @returns {Promise} A promise that resolves once the job is run + */ + async now(/*name, data*/) { + throw new Error('Method not implemented'); + } + + /** + * Starts the job runner + * @param {Object.} jobClasses Object containing job classes keyed by job name + * @returns {Promise} A promise that resolves once the runner is started + */ + async start(/*jobClasses*/) { + throw new Error('Method not implemented'); + } + + /** + * Closes the job runner and cleans up resources + * @returns {Promise} A promise that resolves once the runner is closed + */ + async close() { + throw new Error('Method not implemented'); + } +} + +module.exports = IJobRunner; \ No newline at end of file diff --git a/jobServer/jobRunner/JobRunnerBullImpl.js b/jobServer/jobRunner/JobRunnerBullImpl.js new file mode 100644 index 00000000000..826e0ae7497 --- /dev/null +++ b/jobServer/jobRunner/JobRunnerBullImpl.js @@ -0,0 +1,95 @@ +const IJobRunner = require('./IJobRunner'); +const { Queue, Worker } = require('bullmq'); + +/** + * BullMQ implementation of the job runner + */ +class JobRunnerBullImpl extends IJobRunner { + /** + * Map of queue names to BullMQ Queue instances + * @type {Map} + */ + #queues = new Map(); + + /** + * Map of queue names to BullMQ Worker instances + * @type {Map} + */ + #workers = new Map(); + + #bullConfig; + + #redisConnection; + + #Queue; + + #Worker; + + /** + * Creates a new BullMQ job runner + * @param {Object} db Database connection + * @param {Object} config Configuration object + */ + constructor(db, config) { + super(db, config); + this.#Queue = Queue; + this.#Worker = Worker; + this.#redisConnection = config.redis; + this.#bullConfig = config.bullConfig; + } + + /** + * @param {Object.} jobClasses Object containing job classes keyed by job name + */ + async start(jobClasses) { + + // Create queues and workers for each job + for (const [name, JobClass] of Object.entries(jobClasses)) { + // Create queue + const queue = new this.#Queue(name, { + connection: this.#redisConnection, + ...this.#bullConfig + }); + this.#queues.set(name, queue); + + // Create worker + const worker = new this.#Worker(name, async(job) => { + const instance = new JobClass(name); + await instance.run(job); + }, { + connection: this.#redisConnection, + ...this.#bullConfig + }); + this.#workers.set(name, worker); + + // Handle worker events + worker.on('completed', (job) => { + console.log(`Job ${job.id} completed`); + }); + + worker.on('failed', (job, err) => { + console.error(`Job ${job.id} failed:`, err); + }); + } + } + + /** + * Closes all queues and workers + * @returns {Promise} A promise that resolves once all queues and workers are closed + */ + async close() { + // Close all workers + for (const worker of this.#workers.values()) { + await worker.close(); + } + this.#workers.clear(); + + // Close all queues + for (const queue of this.#queues.values()) { + await queue.close(); + } + this.#queues.clear(); + } +} + +module.exports = JobRunnerBullImpl; \ No newline at end of file diff --git a/jobServer/jobRunner/JobRunnerPulseImpl.js b/jobServer/jobRunner/JobRunnerPulseImpl.js new file mode 100644 index 00000000000..c68b6afedd2 --- /dev/null +++ b/jobServer/jobRunner/JobRunnerPulseImpl.js @@ -0,0 +1,114 @@ +const IJobRunner = require('./IJobRunner'); +const { Pulse, JobPriority } = require('@pulsecron/pulse'); +const {isValidCron} = require('cron-validator'); + +/** + * Pulse implementation of the job runner + */ +class JobRunnerPulseImpl extends IJobRunner { + /** + * The Pulse runner instance + * @type {import('@pulsecron/pulse').Pulse} + */ + #pulseRunner; + + /** + * Creates a new Pulse job runner + * @param {Object} db Database connection + * @param {Object} config Configuration object + * @param {function} Logger - Logger constructor + */ + constructor(db, config, Logger) { + super(db, config, Logger); + this.log = Logger('jobs:runner:pulse'); + this.#pulseRunner = new Pulse({ + ...config, + mongo: this.db, + }); + } + + /** + * Starts the Pulse runner + */ + async start() { + if (!this.#pulseRunner) { + throw new Error('Pulse runner not initialized'); + } + await this.#pulseRunner.start(); + } + + /** + * Creates a new job with the given class + * @param {string} jobName The name of the job + * @param {Function} JobClass The job class to create + * @returns {Promise} A promise that resolves once the job is created + */ + async createJob(jobName, JobClass) { + const instance = new JobClass(jobName); + instance.setLogger(this.log); + // const schedule = instance.schedule(); + + this.#pulseRunner.define( + jobName, + instance._run.bind(instance, this.db), + { + priority: JobPriority.normal, + concurrency: 1, + lockLifetime: 10000, + shouldSaveResult: true, + attempts: 3, + backoff: { + type: 'exponential', + delay: 2000 + } + } + ); + // + // const job = new PulseJob( + // { + // name: jobName, + // pulse: this.#pulseRunner, + // } + // ); + // + // job.unique({'name': jobName}); + // + // job.repeatEvery( + // schedule, + // { + // timezone: 'Asia/Kolkata', + // } + // ); + // + // job.setShouldSaveResult(true); + // await job.save(); + } + + + /** + * Schedules jobs + * @param {String} name The name of the job + * @param {String} schedule Cron string for the job schedule + * @param {Object} data Data to pass to the job + * @returns {Promise} A promise that resolves once the job is scheduled + */ + async schedule(name, schedule, data) { + if (!isValidCron(schedule)) { + throw new Error('Invalid cron schedule'); + } + await this.#pulseRunner.every(schedule, name, data); + } + + /** + * Closes the Pulse runner + * @returns {Promise} A promise that resolves once the runner is closed + */ + async close() { + if (this.#pulseRunner) { + await this.#pulseRunner.close(); + this.#pulseRunner = null; + } + } +} + +module.exports = JobRunnerPulseImpl; \ No newline at end of file diff --git a/jobServer/jobRunner/index.js b/jobServer/jobRunner/index.js new file mode 100644 index 00000000000..24e62197db3 --- /dev/null +++ b/jobServer/jobRunner/index.js @@ -0,0 +1,39 @@ +const IJobRunner = require('./IJobRunner'); +// const JobRunnerBullImpl = require('./JobRunnerBullImpl'); +const JobRunnerPulseImpl = require('./JobRunnerPulseImpl'); + +/** + * JobRunner implementation types + * @enum {string} + */ +const RUNNER_TYPES = { + // BULL: 'bull', + PULSE: 'pulse' +}; + +/** + * Job Runner factory + * + * @param {Object} db The database connection + * @param {string} [type='pulse'] The type of runner to create ('bull' or 'pulse') + * @param {Object} [config={}] Configuration specific to the runner implementation + * @param {function} Logger - Logger constructor + * @returns {IJobRunner} An instance of the specified JobRunner implementation + * @throws {Error} If an invalid runner type is specified + */ +function createJobRunner(db, type = RUNNER_TYPES.PULSE, config = {}, Logger) { + + switch (type.toLowerCase()) { + // case RUNNER_TYPES.BULL: + // return new JobRunnerBullImpl(db, config, Logger); + case RUNNER_TYPES.PULSE: + return new JobRunnerPulseImpl(db, config, Logger); + default: + throw new Error(`Invalid runner type: ${type}. Must be one of: ${Object.values(RUNNER_TYPES).join(', ')} and implementation of ` + IJobRunner.name); + } +} + +module.exports = { + createJobRunner, + RUNNER_TYPES +}; \ No newline at end of file From e9bb5b8b3722ec1473ba46ce031b66c1d7265e00 Mon Sep 17 00:00:00 2001 From: Kanwar Ujjaval Singh <4216199+kanwarujjaval@users.noreply.github.com> Date: Tue, 14 Jan 2025 11:26:12 +0530 Subject: [PATCH 02/90] lint and deps --- .eslintrc.json | 1 + package.json | 3 +++ 2 files changed, 4 insertions(+) diff --git a/.eslintrc.json b/.eslintrc.json index ce496cb3e45..52cb1685c42 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -254,6 +254,7 @@ { "files": [ "api/**/*.js", + "jobServer/**/*.js", "frontend/express/*.js", "frontend/express/libs/*.js", "plugins/pluginManager.js", diff --git a/package.json b/package.json index 934a89a0f01..59570d343f0 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,8 @@ "test": "grunt --verbose" }, "dependencies": { + "@breejs/later": "^4.2.0", + "@pulsecron/pulse": "^1.6.7", "all-the-cities": "3.1.0", "argon2": "0.41.1", "async": "3.2.6", @@ -45,6 +47,7 @@ "countly-root": "file:api/utils/countly-root", "countly-sdk-nodejs": "*", "countly-sdk-web": "*", + "cron-validator": "^1.3.1", "csurf": "^1.11.0", "csvtojson": "2.0.10", "ejs": "3.1.10", From 2e2e2f44930ad6974c97c5a1d5555c9bf073fb9a Mon Sep 17 00:00:00 2001 From: Kanwar Ujjaval Singh <4216199+kanwarujjaval@users.noreply.github.com> Date: Tue, 14 Jan 2025 13:29:02 +0530 Subject: [PATCH 03/90] deepscan errors --- jobServer/JobManager.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/jobServer/JobManager.js b/jobServer/JobManager.js index abf51d104ce..1ec76a463d1 100644 --- a/jobServer/JobManager.js +++ b/jobServer/JobManager.js @@ -70,7 +70,7 @@ class JobManager { return Promise.all( Object.entries(jobClasses) .map(([name, JobClass]) => { - this.#jobRunner.createJob(name, JobClass); + return this.#jobRunner.createJob(name, JobClass); }) ); } @@ -83,11 +83,11 @@ class JobManager { #scheduleJobs(jobClasses) { return Promise.all( Object.entries(jobClasses) - .map(([name, JobClass]) => { + .map(async([name, JobClass]) => { let instance = new JobClass(name); - let schedule = instance.schedule(); + let schedule = await instance.schedule(); if (schedule) { - this.#jobRunner.schedule(name, schedule); + return this.#jobRunner.schedule(name, schedule); } }) ); From 8918bcadc5b1300e78c8cbc019b77d10bd676cf5 Mon Sep 17 00:00:00 2001 From: Kanwar Ujjaval Singh <4216199+kanwarujjaval@users.noreply.github.com> Date: Tue, 14 Jan 2025 14:32:53 +0530 Subject: [PATCH 04/90] test config --- jobServer/config.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/jobServer/config.js b/jobServer/config.js index acb82171fbc..fea20aa24c8 100644 --- a/jobServer/config.js +++ b/jobServer/config.js @@ -3,12 +3,12 @@ * @type {import('@pulsecron/pulse').PulseConfig} */ const DEFAULT_PULSE_CONFIG = { - name: 'core', // Name of the Pulse instance - processEvery: '10 seconds', // Frequency to check for new jobs - maxConcurrency: 5, // Maximum number of jobs that can run concurrently - defaultConcurrency: 3, // Default number of jobs that can run concurrently - lockLimit: 3, // Maximum number of jobs that can be locked at the same time - defaultLockLimit: 2, // Default number of jobs that can be locked at the same time + // name: 'core', // Name of the Pulse instance + processEvery: '3 seconds', // Frequency to check for new jobs + maxConcurrency: 1, // Maximum number of jobs that can run concurrently + defaultConcurrency: 1, // Default number of jobs that can run concurrently + lockLimit: 1, // Maximum number of jobs that can be locked at the same time + defaultLockLimit: 1, // Default number of jobs that can be locked at the same time defaultLockLifetime: 55 * 60 * 1000, // 55 minutes, time in milliseconds for how long a job should be locked sort: { nextRunAt: 1, priority: -1 }, // Sorting order for job execution disableAutoIndex: false, // Whether to disable automatic index creation From 3010da01113079e51a609f786696498815d5df45 Mon Sep 17 00:00:00 2001 From: Kanwar Ujjaval Singh <4216199+kanwarujjaval@users.noreply.github.com> Date: Tue, 14 Jan 2025 14:33:24 +0530 Subject: [PATCH 05/90] handle promise/callback/fn function for job run --- jobServer/Job.js | 63 ++++++++++++++++++++++++++++++------------------ 1 file changed, 40 insertions(+), 23 deletions(-) diff --git a/jobServer/Job.js b/jobServer/Job.js index 56b618874d3..e9b7c63ddda 100644 --- a/jobServer/Job.js +++ b/jobServer/Job.js @@ -16,7 +16,7 @@ class Job { * Creates an instance of Job. */ constructor() { - this.doneCallback = this.doneCallback.bind(this); + this.logger.d(`Job instance"${this.jobName}" created`); } /** @@ -58,35 +58,52 @@ class Job { } /** - * Callback function to be called when the job is done. - * @param {Error} error The error that occurred - * @param {Object} result The job that was run - * @returns {Error} The error to be returned to the job run by pulseScheduler - */ - doneCallback(error, result) { - if (error) { - this.logger.e('Job failed with error:', error); - throw error; - } - else { - this.logger.i('Job completed successfully:', result ? result : ''); - return result ? result : null; - } - } - - /** - * Runs the job. - * @param { Object } db The database + * Runs the job and handles both Promise and callback patterns. + * @param {Object} db The database + * @param {Object} job The job instance + * @param {Function} done Callback to be called when job completes * @private */ - _run(db) { + async _run(db, job, done) { this.logger.d(`Job "${this.jobName}" is starting with database:`, db?._cly_debug?.db); + try { - this.run(db, this.doneCallback); + // Call run() and handle both Promise and callback patterns + const result = await new Promise((resolve, reject) => { + try { + // Call the run method and capture its return value + // If run() uses callback pattern, resolve/reject the promise when the callback is called + const runResult = this.run(db, (error, callbackResult) => { + if (error) { + reject(error); + } + else { + resolve(callbackResult); + } + }); + + // If run() returns a Promise, handle it + if (runResult instanceof Promise) { + runResult.then(resolve).catch(reject); + } + // If run() returns a value directly + else if (runResult !== undefined) { + resolve(runResult); + } + } + catch (error) { + reject(error); + } + }); + + // Log success and call the job runner's callback + this.logger.i(`Job "${this.jobName}" completed successfully:`, result || ''); + done(null, result); } catch (error) { + // Log error and call the job runner's callback with the error this.logger.e(`Job "${this.jobName}" encountered an error during execution:`, error); - this.doneCallback(error, this); + done(error); } } } From 807434e3b63a1553883fe91170c43c1efdb963ff Mon Sep 17 00:00:00 2001 From: Kanwar Ujjaval Singh <4216199+kanwarujjaval@users.noreply.github.com> Date: Tue, 14 Jan 2025 14:35:54 +0530 Subject: [PATCH 06/90] Job runner improvements --- jobServer/jobRunner/JobRunnerPulseImpl.js | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/jobServer/jobRunner/JobRunnerPulseImpl.js b/jobServer/jobRunner/JobRunnerPulseImpl.js index c68b6afedd2..385e0355b65 100644 --- a/jobServer/jobRunner/JobRunnerPulseImpl.js +++ b/jobServer/jobRunner/JobRunnerPulseImpl.js @@ -25,6 +25,21 @@ class JobRunnerPulseImpl extends IJobRunner { ...config, mongo: this.db, }); + + // Monitor for progress + this.#pulseRunner.on('touch', (job) => { + console.debug(`PULSE_EVENT_LISTENER: Lock extended for job ${job?.attrs?.name}`); + }); + + // Monitor for failures + this.#pulseRunner.on('fail', (err, job) => { + console.error(`PULSE_EVENT_LISTENER: Job ${job?.attrs?.name} failed:`, err); + }); + + // Monitor for stalled jobs + this.#pulseRunner.on('stalled', (job) => { + console.warn(`PULSE_EVENT_LISTENER: Job ${job?.attrs?.name} has stalled`); + }); } /** @@ -45,7 +60,8 @@ class JobRunnerPulseImpl extends IJobRunner { */ async createJob(jobName, JobClass) { const instance = new JobClass(jobName); - instance.setLogger(this.log); + // instance.setLogger(this.log); + instance.setJobName(jobName); // const schedule = instance.schedule(); this.#pulseRunner.define( From 05d3d4a3fd50228a43305ed77d550052914ee83d Mon Sep 17 00:00:00 2001 From: Kanwar Ujjaval Singh <4216199+kanwarujjaval@users.noreply.github.com> Date: Tue, 14 Jan 2025 14:39:02 +0530 Subject: [PATCH 07/90] redundant log here, job baseclass already does this --- jobServer/jobRunner/JobRunnerPulseImpl.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/jobServer/jobRunner/JobRunnerPulseImpl.js b/jobServer/jobRunner/JobRunnerPulseImpl.js index 385e0355b65..c9cd19d97fa 100644 --- a/jobServer/jobRunner/JobRunnerPulseImpl.js +++ b/jobServer/jobRunner/JobRunnerPulseImpl.js @@ -32,9 +32,9 @@ class JobRunnerPulseImpl extends IJobRunner { }); // Monitor for failures - this.#pulseRunner.on('fail', (err, job) => { - console.error(`PULSE_EVENT_LISTENER: Job ${job?.attrs?.name} failed:`, err); - }); + // this.#pulseRunner.on('fail', (err, job) => { + // console.error(`PULSE_EVENT_LISTENER: Job ${job?.attrs?.name} failed:`, err); + // }); // Monitor for stalled jobs this.#pulseRunner.on('stalled', (job) => { From 3485290c2159410522cb105f8e61602c5ca3c546 Mon Sep 17 00:00:00 2001 From: Kanwar Ujjaval Singh <4216199+kanwarujjaval@users.noreply.github.com> Date: Tue, 14 Jan 2025 14:40:39 +0530 Subject: [PATCH 08/90] hard fix the pulse version, expecting changes in the development --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e780bc67cbc..1484dba212c 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ }, "dependencies": { "@breejs/later": "^4.2.0", - "@pulsecron/pulse": "^1.6.7", + "@pulsecron/pulse": "1.6.7", "all-the-cities": "3.1.0", "argon2": "0.41.1", "async": "3.2.6", From bdf6252fb76b36c485d099dd4a19fe55c021e30b Mon Sep 17 00:00:00 2001 From: Kanwar Ujjaval Singh <4216199+kanwarujjaval@users.noreply.github.com> Date: Tue, 14 Jan 2025 14:48:01 +0530 Subject: [PATCH 09/90] define node engine in packagejson --- package.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/package.json b/package.json index 1484dba212c..8625887ce9f 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,9 @@ "type": "git", "url": "git://github.com/countly/countly-server.git" }, + "engines": { + "node": "^20.0.0 || ^22.0.0" + }, "devDependencies": { "@stylistic/eslint-plugin": "^2.11.0", "docdash": "^2.0.1", From d1eb6a58dbd72a1daeba898f1b16ccefbda52baf Mon Sep 17 00:00:00 2001 From: Kanwar Ujjaval Singh <4216199+kanwarujjaval@users.noreply.github.com> Date: Tue, 14 Jan 2025 15:09:57 +0530 Subject: [PATCH 10/90] events dont exist :) --- jobServer/jobRunner/JobRunnerPulseImpl.js | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/jobServer/jobRunner/JobRunnerPulseImpl.js b/jobServer/jobRunner/JobRunnerPulseImpl.js index c9cd19d97fa..795ff699a00 100644 --- a/jobServer/jobRunner/JobRunnerPulseImpl.js +++ b/jobServer/jobRunner/JobRunnerPulseImpl.js @@ -25,21 +25,6 @@ class JobRunnerPulseImpl extends IJobRunner { ...config, mongo: this.db, }); - - // Monitor for progress - this.#pulseRunner.on('touch', (job) => { - console.debug(`PULSE_EVENT_LISTENER: Lock extended for job ${job?.attrs?.name}`); - }); - - // Monitor for failures - // this.#pulseRunner.on('fail', (err, job) => { - // console.error(`PULSE_EVENT_LISTENER: Job ${job?.attrs?.name} failed:`, err); - // }); - - // Monitor for stalled jobs - this.#pulseRunner.on('stalled', (job) => { - console.warn(`PULSE_EVENT_LISTENER: Job ${job?.attrs?.name} has stalled`); - }); } /** From ed21279820c861693e5f7539e56ea4fa1d5c13f3 Mon Sep 17 00:00:00 2001 From: Kanwar Ujjaval Singh <4216199+kanwarujjaval@users.noreply.github.com> Date: Tue, 14 Jan 2025 15:28:15 +0530 Subject: [PATCH 11/90] remove obsolete --- jobServer/jobRunner/JobRunnerPulseImpl.js | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/jobServer/jobRunner/JobRunnerPulseImpl.js b/jobServer/jobRunner/JobRunnerPulseImpl.js index 795ff699a00..5b11542941d 100644 --- a/jobServer/jobRunner/JobRunnerPulseImpl.js +++ b/jobServer/jobRunner/JobRunnerPulseImpl.js @@ -64,25 +64,6 @@ class JobRunnerPulseImpl extends IJobRunner { } } ); - // - // const job = new PulseJob( - // { - // name: jobName, - // pulse: this.#pulseRunner, - // } - // ); - // - // job.unique({'name': jobName}); - // - // job.repeatEvery( - // schedule, - // { - // timezone: 'Asia/Kolkata', - // } - // ); - // - // job.setShouldSaveResult(true); - // await job.save(); } From 3d4201dd0c08d92be8968c2e2c06c0a31d397cae Mon Sep 17 00:00:00 2001 From: Kanwar Ujjaval Singh <4216199+kanwarujjaval@users.noreply.github.com> Date: Tue, 14 Jan 2025 22:17:19 +0530 Subject: [PATCH 12/90] support enable/disable of jobs externally --- jobServer/JobManager.js | 41 ++++++++++++++++++++++------------------- 1 file changed, 22 insertions(+), 19 deletions(-) diff --git a/jobServer/JobManager.js b/jobServer/JobManager.js index 1ec76a463d1..f26ddacb233 100644 --- a/jobServer/JobManager.js +++ b/jobServer/JobManager.js @@ -57,10 +57,31 @@ class JobManager { await this.#loadJobs(jobClasses); await this.#jobRunner.start(); - await this.#scheduleJobs(jobClasses); this.#log.d('JobManager started successfully'); } + /** + * Enable a job + * @param {string} jobName Name of the job to enable + */ + async enableJob(jobName) { + if (!this.#jobRunner) { + throw new Error('Job runner not initialized'); + } + await this.#jobRunner.enableJob(jobName); + } + + /** + * Disable a job + * @param {string} jobName Name of the job to disable + */ + async disableJob(jobName) { + if (!this.#jobRunner) { + throw new Error('Job runner not initialized'); + } + await this.#jobRunner.disableJob(jobName); + } + /** * Loads the job classes into the job runner * @param {Object.} jobClasses Object containing job classes keyed by job name @@ -75,24 +96,6 @@ class JobManager { ); } - /** - * Schedules the jobs to run at their respective intervals - * @param { Object. } jobClasses Object containing job classes keyed by job name - * @returns {Promise[]>} A promise that resolves once the jobs are scheduled - */ - #scheduleJobs(jobClasses) { - return Promise.all( - Object.entries(jobClasses) - .map(async([name, JobClass]) => { - let instance = new JobClass(name); - let schedule = await instance.schedule(); - if (schedule) { - return this.#jobRunner.schedule(name, schedule); - } - }) - ); - } - /** * Closes the JobManager and cleans up resources * @returns {Promise} A promise that resolves once cleanup is complete From 24125d8e9d281eb38533e6e14edb534eb923f9ed Mon Sep 17 00:00:00 2001 From: Kanwar Ujjaval Singh <4216199+kanwarujjaval@users.noreply.github.com> Date: Tue, 14 Jan 2025 22:20:16 +0530 Subject: [PATCH 13/90] self contained better job scheduling --- jobServer/jobRunner/IJobRunner.js | 28 +++- jobServer/jobRunner/JobRunnerPulseImpl.js | 151 +++++++++++++++++----- 2 files changed, 144 insertions(+), 35 deletions(-) diff --git a/jobServer/jobRunner/IJobRunner.js b/jobServer/jobRunner/IJobRunner.js index 86fd08daf59..258298000ae 100644 --- a/jobServer/jobRunner/IJobRunner.js +++ b/jobServer/jobRunner/IJobRunner.js @@ -32,13 +32,15 @@ class IJobRunner { } /** - * Schedules jobs + * Schedules a job based on its configuration * @param {String} name The name of the job - * @param {String} schedule Cron string for the job schedule - * @param {Object} data Data to pass to the job + * @param {Object} scheduleConfig Schedule configuration object + * @param {('once'|'schedule'|'now')} scheduleConfig.type Type of schedule + * @param {string|Date} [scheduleConfig.value] Cron string or Date object + * @param {Object} [data] Data to pass to the job * @returns {Promise} A promise that resolves once the job is scheduled */ - async schedule(/*name, schedule, data*/) { + async schedule(/*name, scheduleConfig, data*/) { throw new Error('Method not implemented'); } @@ -79,6 +81,24 @@ class IJobRunner { async close() { throw new Error('Method not implemented'); } + + /** + * Enable a job + * @param {string} jobName Name of the job to enable + * @returns {Promise} A promise that resolves once the job is enabled + */ + async enableJob(/*jobName*/) { + throw new Error('Method not implemented'); + } + + /** + * Disable a job + * @param {string} jobName Name of the job to disable + * @returns {Promise} A promise that resolves once the job is disabled + */ + async disableJob(/*jobName*/) { + throw new Error('Method not implemented'); + } } module.exports = IJobRunner; \ No newline at end of file diff --git a/jobServer/jobRunner/JobRunnerPulseImpl.js b/jobServer/jobRunner/JobRunnerPulseImpl.js index 5b11542941d..73d25c14610 100644 --- a/jobServer/jobRunner/JobRunnerPulseImpl.js +++ b/jobServer/jobRunner/JobRunnerPulseImpl.js @@ -12,6 +12,9 @@ class JobRunnerPulseImpl extends IJobRunner { */ #pulseRunner; + /** @type {Map} Store job schedules until Pulse is started */ + #pendingSchedules = new Map(); + /** * Creates a new Pulse job runner * @param {Object} db Database connection @@ -28,57 +31,111 @@ class JobRunnerPulseImpl extends IJobRunner { } /** - * Starts the Pulse runner + * Starts the Pulse runner and schedules any pending jobs */ async start() { if (!this.#pulseRunner) { throw new Error('Pulse runner not initialized'); } + await this.#pulseRunner.start(); + this.log.i('Pulse runner started'); + + // Schedule all pending jobs + for (const [jobName, scheduleConfig] of this.#pendingSchedules) { + try { + await this.#scheduleJob(jobName, scheduleConfig); + } + catch (error) { + this.log.e(`Failed to schedule job ${jobName}:`, error); + } + } + + this.#pendingSchedules.clear(); } /** - * Creates a new job with the given class + * Creates and defines a new job * @param {string} jobName The name of the job * @param {Function} JobClass The job class to create * @returns {Promise} A promise that resolves once the job is created */ async createJob(jobName, JobClass) { - const instance = new JobClass(jobName); - // instance.setLogger(this.log); - instance.setJobName(jobName); - // const schedule = instance.schedule(); - - this.#pulseRunner.define( - jobName, - instance._run.bind(instance, this.db), - { - priority: JobPriority.normal, - concurrency: 1, - lockLifetime: 10000, - shouldSaveResult: true, - attempts: 3, - backoff: { - type: 'exponential', - delay: 2000 + try { + const instance = new JobClass(jobName); + // instance.setLogger(this.log); + instance.setJobName(jobName); + + this.#pulseRunner.define( + jobName, + async(job, done) => { + instance._setTouchMethod(job.touch.bind(job)); + + return instance._run( + this.db, + job, + done + ); + }, + { + priority: JobPriority.normal, + concurrency: 1, + lockLifetime: 10000, + shouldSaveResult: true, + attempts: 3, + backoff: { + type: 'exponential', + delay: 2000 + } } - } - ); - } + ); + // Store schedule configuration for later + const scheduleConfig = instance.getSchedule(); + this.#pendingSchedules.set(jobName, scheduleConfig); + this.log.d(`Job ${jobName} defined successfully`); + } + catch (error) { + this.log.e(`Failed to create job ${jobName}:`, error); + // Don't throw - allow other jobs to continue + } + } /** - * Schedules jobs - * @param {String} name The name of the job - * @param {String} schedule Cron string for the job schedule - * @param {Object} data Data to pass to the job - * @returns {Promise} A promise that resolves once the job is scheduled + * Internal method to schedule a job + * @param {string} name The name of the job to schedule + * @param {Object} scheduleConfig Schedule configuration object + * @param {('once'|'schedule'|'now')} scheduleConfig.type Type of schedule + * @param {string|Date} [scheduleConfig.value] Cron string or Date object + * @param {Object} [data] Data to pass to the job + * @private */ - async schedule(name, schedule, data) { - if (!isValidCron(schedule)) { - throw new Error('Invalid cron schedule'); + async #scheduleJob(name, scheduleConfig, data) { + switch (scheduleConfig.type) { + case 'schedule': + if (!isValidCron(scheduleConfig.value)) { + throw new Error('Invalid cron schedule'); + } + await this.#pulseRunner.every(scheduleConfig.value, name, data); + this.log.d(`Job ${name} scheduled with cron: ${scheduleConfig.value}`); + break; + + case 'once': + if (!(scheduleConfig.value instanceof Date)) { + throw new Error('Invalid date for one-time schedule'); + } + await this.#pulseRunner.schedule(scheduleConfig.value, name, data); + this.log.d(`Job ${name} scheduled for: ${scheduleConfig.value}`); + break; + + case 'now': + await this.#pulseRunner.now(name, data); + this.log.d(`Job ${name} scheduled to run immediately`); + break; + + default: + throw new Error(`Invalid schedule type: ${scheduleConfig.type}`); } - await this.#pulseRunner.every(schedule, name, data); } /** @@ -91,6 +148,38 @@ class JobRunnerPulseImpl extends IJobRunner { this.#pulseRunner = null; } } + + /** + * Enable a job + * @param {string} jobName Name of the job to enable + * @returns {Promise} A promise that resolves once the job is enabled + */ + async enableJob(jobName) { + try { + await this.#pulseRunner.enable({ name: jobName }); + this.log.i(`Job ${jobName} enabled`); + } + catch (error) { + this.log.e(`Failed to enable job ${jobName}:`, error); + throw error; + } + } + + /** + * Disable a job + * @param {string} jobName Name of the job to disable + * @returns {Promise} A promise that resolves once the job is disabled + */ + async disableJob(jobName) { + try { + await this.#pulseRunner.disable({ name: jobName }); + this.log.i(`Job ${jobName} disabled`); + } + catch (error) { + this.log.e(`Failed to disable job ${jobName}:`, error); + throw error; + } + } } module.exports = JobRunnerPulseImpl; \ No newline at end of file From 967fa249669f2ca1741c585e378993cc725f727b Mon Sep 17 00:00:00 2001 From: Kanwar Ujjaval Singh <4216199+kanwarujjaval@users.noreply.github.com> Date: Tue, 14 Jan 2025 22:24:09 +0530 Subject: [PATCH 14/90] Expanded job baseclass with documented example --- jobServer/Job.js | 224 +++++++++++++++++++++++++++----- jobServer/example/ExampleJob.js | 110 ++++++++++++++++ 2 files changed, 302 insertions(+), 32 deletions(-) create mode 100644 jobServer/example/ExampleJob.js diff --git a/jobServer/Job.js b/jobServer/Job.js index e9b7c63ddda..49f5a57fcb8 100644 --- a/jobServer/Job.js +++ b/jobServer/Job.js @@ -1,28 +1,84 @@ +const defaultLogger = { + d: console.debug, + w: console.warn, + e: console.error, + i: console.info +}; /** - * Represents a job. + * Base class for creating jobs. + * + * @example + * // Example of a simple job that runs every minute + * class SimpleJob extends Job { + * getSchedule() { + * return { + * type: 'schedule', + * value: '* * * * *' // Runs every minute + * }; + * } + * + * async run(db, done) { + * try { + * await db.collection('mycollection').updateMany({}, { $set: { updated: new Date() } }); + * done(null, 'Successfully updated records'); + * } catch (error) { + * done(error); + * } + * } + * } + * + * @example + * // Example of a one-time job scheduled for a specific date + * class OneTimeJob extends Job { + * getSchedule() { + * return { + * type: 'once', + * value: new Date('2024-12-31T23:59:59Z') + * }; + * } + * + * async run(db, done) { + * // Your job logic here + * done(); + * } + * } + * + * @example + * // Example of a job that runs immediately + * class ImmediateJob extends Job { + * getSchedule() { + * return { + * type: 'now' + * }; + * } + * + * async run(db, done) { + * // Your job logic here + * done(); + * } + * } */ class Job { - + /** @type {string} Name of the job */ jobName = Job.name; - logger = { - d: console.debug, - w: console.warn, - e: console.error, - i: console.info - }; + /** @type {Object} Logger instance */ + logger; + + /** @type {Function|null} Touch method from Pulse */ + _touchMethod = null; /** * Creates an instance of Job. */ constructor() { - this.logger.d(`Job instance"${this.jobName}" created`); + this.logger = defaultLogger; + this.logger.d(`Job instance "${this.jobName}" created`); } /** * Sets the name of the job. * @param {String} name The name of the job - * @returns {void} The name of the job */ setJobName(name) { this.jobName = name; @@ -30,49 +86,108 @@ class Job { /** * Sets the logger - * @param {Logger} logger The logger + * @param {Object} [logger=defaultLogger] The logger instance */ - setLogger(logger) { + setLogger(logger = defaultLogger) { this.logger = logger; } /** - * Runs the job. - * @param {Object} db The database connection - * @param {Function} done The callback function to be called when the job is done - * @throws {Error} If the method is not overridden + * Runs the job. This method must be implemented by the child class. + * + * @param {Db} db The database connection + * @param {Function} done Callback function to be called when the job is complete + * Call with error as first parameter if job fails + * Call with null and optional result as second parameter if job succeeds + * @param {Function} progress Progress reporting function for long-running jobs + * Call with (total, current, bookmark) to report progress + * + * @example + * // Example implementation with progress reporting + * async run(db, done, progress) { + * try { + * const total = await db.collection('users').countDocuments({ active: false }); + * let processed = 0; + * + * const cursor = db.collection('users').find({ active: false }); + * while (await cursor.hasNext()) { + * await cursor.next(); + * processed++; + * if (processed % 100 === 0) { + * await progress(total, processed, `Processing inactive users`); + * } + * } + * done(null, { processedCount: processed }); + * } catch (error) { + * done(error); + * } + * } + * * @abstract + * @throws {Error} If the method is not overridden */ - async run(/*db, done*/) { + async run(/*db, done, progress*/) { throw new Error('Job must be overridden'); } /** - * Get the schedule for the job in cron format. - * @returns {string} The cron schedule - * @throws {Error} If the method is not overridden + * Get the schedule type and timing for the job. + * This method must be implemented by the child class. + * + * @returns {Object} Schedule configuration object + * @property {('once'|'schedule'|'now')} type - Type of schedule + * @property {string|Date} [value] - Schedule value: + * - For type='schedule': Cron expression (e.g., '0 * * * *' for hourly) + * - For type='once': Date object for when to run + * - For type='now': Not needed + * + * @example + * // Run every day at midnight + * getSchedule() { + * return { + * type: 'schedule', + * value: '0 0 * * *' + * }; + * } + * + * @example + * // Run once at a specific time + * getSchedule() { + * return { + * type: 'once', + * value: new Date('2024-01-01T00:00:00Z') + * }; + * } + * + * @example + * // Run immediately + * getSchedule() { + * return { + * type: 'now' + * }; + * } + * * @abstract + * @throws {Error} If the method is not overridden */ - schedule() { - throw new Error('schedule must be overridden'); + getSchedule() { + throw new Error('getSchedule must be overridden'); } /** - * Runs the job and handles both Promise and callback patterns. - * @param {Object} db The database + * Internal method to run the job and handle both Promise and callback patterns. + * @param {Db} db The database connection * @param {Object} job The job instance * @param {Function} done Callback to be called when job completes + * @returns {Promise} A promise that resolves once the job is completed * @private */ async _run(db, job, done) { this.logger.d(`Job "${this.jobName}" is starting with database:`, db?._cly_debug?.db); try { - // Call run() and handle both Promise and callback patterns const result = await new Promise((resolve, reject) => { try { - // Call the run method and capture its return value - // If run() uses callback pattern, resolve/reject the promise when the callback is called const runResult = this.run(db, (error, callbackResult) => { if (error) { reject(error); @@ -80,13 +195,11 @@ class Job { else { resolve(callbackResult); } - }); + }, this.reportProgress.bind(this)); - // If run() returns a Promise, handle it if (runResult instanceof Promise) { runResult.then(resolve).catch(reject); } - // If run() returns a value directly else if (runResult !== undefined) { resolve(runResult); } @@ -96,16 +209,63 @@ class Job { } }); - // Log success and call the job runner's callback this.logger.i(`Job "${this.jobName}" completed successfully:`, result || ''); done(null, result); } catch (error) { - // Log error and call the job runner's callback with the error this.logger.e(`Job "${this.jobName}" encountered an error during execution:`, error); done(error); } } + + /** + * Sets the touch method (called internally by job runner) + * @param {Function} touchMethod The touch method from Pulse + * @private + */ + _setTouchMethod(touchMethod) { + this._touchMethod = touchMethod; + } + + /** + * Reports progress for long-running jobs to prevent lock expiration + * @param {number} [total] Total number of stages + * @param {number} [current] Current stage number + * @param {string} [bookmark] Bookmark string for current stage + */ + async reportProgress(total, current, bookmark) { + if (!this._touchMethod) { + throw new Error('Touch method not available'); + } + + // Calculate progress percentage + const progress = total && current ? Math.min(100, Math.floor((current / total) * 100)) : undefined; + + // Store progress data + const progressData = { + total, + current, + bookmark, + progress, + timestamp: new Date() + }; + + // Store in job data + if (!this.data) { + this.data = {}; + } + this.data.progress = progressData; + + // Call Pulse's touch method with progress + if (progress !== undefined) { + await this._touchMethod(progress); + } + else { + await this._touchMethod(); + } + + this.logger?.d(`Progress reported for job "${this.jobName}":`, progressData); + } } module.exports = Job; \ No newline at end of file diff --git a/jobServer/example/ExampleJob.js b/jobServer/example/ExampleJob.js new file mode 100644 index 00000000000..a05b0da6f7c --- /dev/null +++ b/jobServer/example/ExampleJob.js @@ -0,0 +1,110 @@ +const job = require("../../jobServer"); + +/** + * Example job implementation demonstrating all features of the job system. + * This job processes user records in batches, demonstrating: + * - Progress reporting + * - Error handling + * - Different schedule types + * - Database operations + * - Proper logging + * + * @extends {job.Job} + */ +class ExampleJob extends job.Job { + /** + * Get the schedule configuration for the job. + * Demonstrates all possible schedule types. + * + * @returns {Object} Schedule configuration object + */ + getSchedule() { + // Example 1: Run every day at midnight + return { + type: 'schedule', + value: '0 0 * * *' + }; + + // Example 2: Run once at a specific future date + // return { + // type: 'once', + // value: new Date('2024-12-31T23:59:59Z') + // }; + + // Example 3: Run immediately + // return { + // type: 'now' + // }; + } + + /** + * Simulates some processing work + * @private + * @param {number} ms Time to wait in milliseconds + */ + async #simulateWork(ms) { + await new Promise(resolve => setTimeout(resolve, ms)); + } + + /** + * Main job execution method. + * Demonstrates: + * - Progress reporting + * - Synthetic delays to simulate work + * - Error handling + * - Logging + * + * @param {Object} db Database connection + * @param {Function} done Callback to signal job completion + * @param {Function} progress Progress reporting function + */ + async run(db, done, progress) { + try { + this.logger.d("Starting example job execution"); + + // Simulate total items to process + const total = 100; + let processed = 0; + + // Process in batches of 10 + for (let i = 0; i < total; i += 10) { + // Simulate batch processing (2 second per batch) + await this.#simulateWork(2000); + processed += 10; + + // Report progress + await progress( + total, + processed, + `Processing batch ${(i / 10) + 1}/10` + ); + + this.logger.d(`Completed batch ${(i / 10) + 1}/10`); + + // Simulate random error (10% chance) + if (Math.random() < 0.1) { + throw new Error('Random batch processing error'); + } + } + + // Simulate final processing + await this.#simulateWork(1000); + + // Job completion + const result = { + processedCount: processed, + totalItems: total, + completedAt: new Date() + }; + + this.logger.i("Job completed successfully", result); + done(null, result); + } + catch (error) { + this.logger.e("Job failed:", error); + done(error); + } + } +} + +module.exports = ExampleJob; \ No newline at end of file From 7b4dc94d1f5467263c260700dde41ce0ac6d06b3 Mon Sep 17 00:00:00 2001 From: Kanwar Ujjaval Singh <4216199+kanwarujjaval@users.noreply.github.com> Date: Tue, 14 Jan 2025 22:24:56 +0530 Subject: [PATCH 15/90] JobServer with oplog watcher to communicate with main countly process --- api/config.sample.js | 1 + jobServer/JobServer.js | 64 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/api/config.sample.js b/api/config.sample.js index d3532c70d9f..44c548ad4d4 100644 --- a/api/config.sample.js +++ b/api/config.sample.js @@ -24,6 +24,7 @@ var countlyConfig = { db: "countly", port: 27017, max_pool_size: 500, + replicaName: "rs0", //username: test, //password: test, //mongos: false, diff --git a/jobServer/JobServer.js b/jobServer/JobServer.js index a2441896944..37a691cbc31 100644 --- a/jobServer/JobServer.js +++ b/jobServer/JobServer.js @@ -1,6 +1,7 @@ const JobManager = require('./JobManager'); const JobScanner = require('./JobScanner'); +const JOBS_CONFIG_COLLECTION = 'jobConfigs'; /** * Class representing a job process. */ @@ -53,6 +54,20 @@ class JobServer { */ #db = null; + /** + * Collection for job configurations + * @private + * @type {import('mongodb').Collection} + */ + #jobConfigsCollection; + + /** + * Flag indicating whether the job process is shutting down + * avoids multiple shutdown race conditions + * @private + */ + #isShuttingDown = false; + /** * Creates a new JobProcess instance. * @param {Object} common Countly common @@ -90,7 +105,13 @@ class JobServer { this.#jobManager = new JobManager(this.#db, this.Logger); this.#jobScanner = new JobScanner(this.#db, this.Logger, this.#pluginManager); + this.#jobConfigsCollection = this.#db.collection(JOBS_CONFIG_COLLECTION); + // await this.#jobConfigsCollection.createIndex({ jobName: 1 }, /*{ unique: true }*/); + this.#setupSignalHandlers(); + // Watch for changes in job configurations + this.#watchJobConfigs(); + this.#log.i('Job process init successfully'); } catch (error) { @@ -155,12 +176,55 @@ class JobServer { }); } + /** + * Watch for changes in job configurations + * @private + */ + async #watchJobConfigs() { + const changeStream = this.#jobConfigsCollection.watch(); + + changeStream.on('change', async(change) => { + try { + if (change.operationType === 'update' || change.operationType === 'insert') { + const jobName = change.fullDocument.jobName; + const enabled = change.fullDocument.enabled; + + if (enabled) { + await this.#jobManager.enableJob(jobName); + } + else { + await this.#jobManager.disableJob(jobName); + } + + this.#log.i(`Job ${jobName} ${enabled ? 'enabled' : 'disabled'}`); + } + } + catch (error) { + this.#log.e('Error processing job config change:', error); + } + }); + + changeStream.on('error', (error) => { + this.#log.e('Error in job configs change stream:', error); + // Implement reconnection logic here + }); + } + /** * Shuts down the job process. * @param {number} [exitCode=0] - The exit code to use when shutting down the process. * @returns {Promise} A promise that resolves once the job process is shut down. */ async #shutdown(exitCode = 0) { + if (this.#isShuttingDown) { + return; + } + this.#isShuttingDown = true; + + if (this.#db && typeof this.#db.close === 'function') { + await this.#db.close(); + } + if (!this.#isRunning) { this.#log.w('Shutdown called but process is not running'); process.exit(exitCode); From 372ee079f7259b33fa921c6a6ef6b5777558a857 Mon Sep 17 00:00:00 2001 From: Kanwar Ujjaval Singh <4216199+kanwarujjaval@users.noreply.github.com> Date: Tue, 14 Jan 2025 23:42:23 +0530 Subject: [PATCH 16/90] progress job --- jobServer/Job.js | 58 +++++++++++++---------- jobServer/jobRunner/JobRunnerPulseImpl.js | 14 ++++++ 2 files changed, 47 insertions(+), 25 deletions(-) diff --git a/jobServer/Job.js b/jobServer/Job.js index 49f5a57fcb8..d429a5a366f 100644 --- a/jobServer/Job.js +++ b/jobServer/Job.js @@ -65,9 +65,12 @@ class Job { /** @type {Object} Logger instance */ logger; - /** @type {Function|null} Touch method from Pulse */ + /** @type {Function|null} Touch method from job runner */ _touchMethod = null; + /** @type {Function|null} Progress method from job runner */ + _progressMethod = null; + /** * Creates an instance of Job. */ @@ -188,14 +191,18 @@ class Job { try { const result = await new Promise((resolve, reject) => { try { - const runResult = this.run(db, (error, callbackResult) => { - if (error) { - reject(error); - } - else { - resolve(callbackResult); - } - }, this.reportProgress.bind(this)); + const runResult = this.run( + db, + (error, callbackResult) => { + if (error) { + reject(error); + } + else { + resolve(callbackResult); + } + }, + this.reportProgress.bind(this) + ); if (runResult instanceof Promise) { runResult.then(resolve).catch(reject); @@ -228,39 +235,40 @@ class Job { } /** - * Reports progress for long-running jobs to prevent lock expiration + * Sets the progress method from the runner + * @param {Function} progressMethod Method to update progress + * @protected + */ + _setProgressMethod(progressMethod) { + this._progressMethod = progressMethod; + } + + /** + * Reports progress for long-running jobs * @param {number} [total] Total number of stages * @param {number} [current] Current stage number * @param {string} [bookmark] Bookmark string for current stage */ async reportProgress(total, current, bookmark) { - if (!this._touchMethod) { - throw new Error('Touch method not available'); - } - // Calculate progress percentage const progress = total && current ? Math.min(100, Math.floor((current / total) * 100)) : undefined; - // Store progress data + // Build progress data const progressData = { total, current, bookmark, - progress, + percent: progress, timestamp: new Date() }; - // Store in job data - if (!this.data) { - this.data = {}; + // Update progress using runner's method if available + if (this._progressMethod) { + await this._progressMethod(progressData); } - this.data.progress = progressData; - // Call Pulse's touch method with progress - if (progress !== undefined) { - await this._touchMethod(progress); - } - else { + // Touch to prevent lock expiration + if (this._touchMethod) { await this._touchMethod(); } diff --git a/jobServer/jobRunner/JobRunnerPulseImpl.js b/jobServer/jobRunner/JobRunnerPulseImpl.js index 73d25c14610..6b77e8362f2 100644 --- a/jobServer/jobRunner/JobRunnerPulseImpl.js +++ b/jobServer/jobRunner/JobRunnerPulseImpl.js @@ -54,6 +54,17 @@ class JobRunnerPulseImpl extends IJobRunner { this.#pendingSchedules.clear(); } + /** + * Updates job progress in Pulse + * @param {Object} job Pulse job instance + * @param {Object} progressData Progress data to store + * @private + */ + async #updateJobProgress(job, progressData) { + job.data = progressData; + await job.save(); + } + /** * Creates and defines a new job * @param {string} jobName The name of the job @@ -70,6 +81,9 @@ class JobRunnerPulseImpl extends IJobRunner { jobName, async(job, done) => { instance._setTouchMethod(job.touch.bind(job)); + instance._setProgressMethod( + async(progressData) => this.#updateJobProgress(job, progressData) + ); return instance._run( this.db, From 480cbc4e11fd6a898e07931b87cca84d4b668a6b Mon Sep 17 00:00:00 2001 From: Kanwar Ujjaval Singh <4216199+kanwarujjaval@users.noreply.github.com> Date: Wed, 15 Jan 2025 11:02:28 +0530 Subject: [PATCH 17/90] codacy fix --- jobServer/JobUtils.js | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/jobServer/JobUtils.js b/jobServer/JobUtils.js index 2b69927ae7b..1a4df6a65cf 100644 --- a/jobServer/JobUtils.js +++ b/jobServer/JobUtils.js @@ -30,6 +30,8 @@ class JobUtils { } /** + * @note + * We shouldn't need this and use cron string directly in job schedule to avoid conversion * Converts a later.js schedule to a cron string. * @param {String} laterString - The later.js schedule string. * @constructor @@ -132,12 +134,12 @@ class JobUtils { } if (isEveryMinute) { - const interval = parseInt(isEveryMinute[1]); + const interval = parseInt(isEveryMinute[1], 10); return `*/${interval} * * * *`; } if (isEveryHour) { - const interval = parseInt(isEveryHour[1]); + const interval = parseInt(isEveryHour[1], 10); return laterString.includes('on the 1st min') ? `1 */${interval} * * *` : `0 */${interval} * * *`; @@ -145,8 +147,8 @@ class JobUtils { if (isSpecificTime && isEveryDay) { let [, hour, minute, meridiem] = isSpecificTime; - hour = parseInt(hour); - minute = parseInt(minute); + hour = parseInt(hour, 10); + minute = parseInt(minute, 10); if (meridiem) { hour = meridiem.toLowerCase() === 'pm' && hour < 12 ? hour + 12 : hour; hour = meridiem.toLowerCase() === 'am' && hour === 12 ? 0 : hour; @@ -157,8 +159,8 @@ class JobUtils { if (isWeekday) { if (isSpecificTime) { let [, hour, minute, meridiem] = isSpecificTime; - hour = parseInt(hour); - minute = parseInt(minute); + hour = parseInt(hour, 10); + minute = parseInt(minute, 10); if (meridiem) { hour = meridiem.toLowerCase() === 'pm' && hour < 12 ? hour + 12 : hour; hour = meridiem.toLowerCase() === 'am' && hour === 12 ? 0 : hour; @@ -169,11 +171,11 @@ class JobUtils { } if (isEveryNDays) { - const interval = parseInt(isEveryNDays[1]); + const interval = parseInt(isEveryNDays[1], 10); if (isSpecificTime) { let [, hour, minute, meridiem] = isSpecificTime; - hour = parseInt(hour); - minute = parseInt(minute); + hour = parseInt(hour, 10); + minute = parseInt(minute, 10); if (meridiem) { hour = meridiem.toLowerCase() === 'pm' && hour < 12 ? hour + 12 : hour; hour = meridiem.toLowerCase() === 'am' && hour === 12 ? 0 : hour; @@ -186,8 +188,8 @@ class JobUtils { if (isLastDayOfMonth) { if (isSpecificTime) { let [, hour, minute, meridiem] = isSpecificTime; - hour = parseInt(hour); - minute = parseInt(minute); + hour = parseInt(hour, 10); + minute = parseInt(minute, 10); if (meridiem) { hour = meridiem.toLowerCase() === 'pm' && hour < 12 ? hour + 12 : hour; hour = meridiem.toLowerCase() === 'am' && hour === 12 ? 0 : hour; @@ -198,7 +200,7 @@ class JobUtils { } if (isOnceInHours) { - const interval = parseInt(isOnceInHours[1]); + const interval = parseInt(isOnceInHours[1], 10); return `0 */${interval} * * *`; } From ef82822ed97905feb4a34e777326a32d76e648e4 Mon Sep 17 00:00:00 2001 From: Kanwar Ujjaval Singh <4216199+kanwarujjaval@users.noreply.github.com> Date: Wed, 15 Jan 2025 13:44:17 +0530 Subject: [PATCH 18/90] complex job operations from oplog --- jobServer/Job.js | 41 +++++ jobServer/JobManager.js | 213 ++++++++++++++++++++-- jobServer/JobServer.js | 36 ---- jobServer/JobUtils.js | 14 ++ jobServer/jobRunner/IJobRunner.js | 41 +++++ jobServer/jobRunner/JobRunnerPulseImpl.js | 109 +++++++++-- 6 files changed, 389 insertions(+), 65 deletions(-) diff --git a/jobServer/Job.js b/jobServer/Job.js index d429a5a366f..c1c47212aa5 100644 --- a/jobServer/Job.js +++ b/jobServer/Job.js @@ -4,6 +4,7 @@ const defaultLogger = { e: console.error, i: console.info }; +const { JOB_PRIORITIES } = require('./constants/JobPriorities'); /** * Base class for creating jobs. * @@ -274,6 +275,46 @@ class Job { this.logger?.d(`Progress reported for job "${this.jobName}":`, progressData); } + + /** + * Get job retry configuration + * @typedef {Object} RetryConfig + * @property {boolean} enabled - Whether retries are enabled + * @property {number} attempts - Number of retry attempts + * @property {number} delay - Delay between retry attempts in milliseconds, delay is by default exponentially increasing after each attempt + * @returns {RetryConfig|null} Retry configuration or null for default + */ + getRetryConfig() { + return { + enabled: true, + attempts: 3, + delay: 2000 // 2 seconds + }; + } + + /** + * Get job priority + * @returns {string} Priority level from JOB_PRIORITIES + */ + getPriority() { + return JOB_PRIORITIES.NORMAL; + } + + /** + * Get job concurrency + * @returns {number|null} Maximum concurrent instances or null for default + */ + getConcurrency() { + return 1; + } + + /** + * Get job lock lifetime in milliseconds + * @returns {number|null} Lock lifetime or null for default + */ + getLockLifetime() { + return 55 * 60 * 1000; // 55 minutes + } } module.exports = Job; \ No newline at end of file diff --git a/jobServer/JobManager.js b/jobServer/JobManager.js index f26ddacb233..8e2104b9229 100644 --- a/jobServer/JobManager.js +++ b/jobServer/JobManager.js @@ -1,5 +1,6 @@ -const {RUNNER_TYPES, createJobRunner} = require('./JobRunner'); +const {RUNNER_TYPES, createJobRunner} = require('./JobRunner/index'); const config = require("./config"); +const JobUtils = require('./JobUtils'); /** * Manages job configurations and initialization. @@ -25,6 +26,9 @@ class JobManager { */ #jobRunner = null; + + #jobConfigsCollection; + /** * Creates a new JobManager instance * @param {Object} db Database connection @@ -40,6 +44,199 @@ class JobManager { const pulseConfig = config.PULSE; this.#jobRunner = createJobRunner(this.#db, runnerType, pulseConfig, Logger); + this.#jobConfigsCollection = db.collection('jobConfigs'); + this.#watchConfigs(); + } + + /** + * Watches for changes in job configurations + * @private + * @returns {Promise} A promise that resolves once the watcher is started + */ + async #watchConfigs() { + const changeStream = this.#jobConfigsCollection.watch(); + + changeStream.on('change', async(change) => { + if (change.operationType === 'update' || change.operationType === 'insert') { + const jobConfig = change.fullDocument; + await this.#applyConfig(jobConfig); + } + }); + } + + /** + * Applies job configuration changes + * @private + * @param {Object} jobConfig The job configuration to apply + * @param {string} jobConfig.jobName The name of the job + * @param {boolean} [jobConfig.runNow] Whether to run the job immediately + * @param {Object} [jobConfig.schedule] Schedule configuration + * @param {Object} [jobConfig.retry] Retry configuration + * @param {boolean} [jobConfig.enabled] Whether the job is enabled + * @returns {Promise} A promise that resolves once the configuration is applied + */ + async #applyConfig(jobConfig) { + try { + if (!this.#jobRunner) { + throw new Error('Job runner not initialized'); + } + + const { jobName } = jobConfig; + + if (jobConfig.runNow === true) { + await this.#jobRunner.runJobNow(jobName); + await this.#jobConfigsCollection.updateOne( + { jobName }, + { $unset: { runNow: "" } } + ); + } + + if (jobConfig.schedule) { + await this.#jobRunner.updateSchedule(jobName, jobConfig.schedule); + } + + if (jobConfig.retry) { + await this.#jobRunner.configureRetry(jobName, jobConfig.retry); + } + + if (typeof jobConfig.enabled === 'boolean') { + if (jobConfig.enabled) { + await this.#jobRunner.enableJob(jobName); + this.#log.i(`Job ${jobName} enabled via config`); + } + else { + await this.#jobRunner.disableJob(jobName); + this.#log.i(`Job ${jobName} disabled via config`); + } + } + } + catch (error) { + this.#log.e('Error applying job configuration:', error); + } + } + + /** + * Loads job classes and manages their configurations + * @private + * @param {Object.} jobClasses - Object containing job class implementations keyed by job name + * @returns {Promise} A promise that resolves once all jobs are loaded and configured + * @throws {Error} If job loading or configuration fails + */ + async #loadJobs(jobClasses) { + // Calculate checksums for all job definitions + const jobDefinitionChecksums = Object.entries(jobClasses).reduce((acc, [name, JobClass]) => { + acc[name] = JobUtils.calculateJobChecksum(JobClass); + return acc; + }, {}); + + // Initialize or update job configurations + await this.#initializeJobConfigs(jobClasses, jobDefinitionChecksums); + + // Load and apply configurations + await this.#applyJobConfigurations(jobClasses, jobDefinitionChecksums); + } + + /** + * Initializes or updates job configurations in the database + * @private + * @param {Object.} jobClasses - Job class implementations + * @param {Object.} jobDefinitionChecksums - Checksums of job definitions + */ + async #initializeJobConfigs(jobClasses, jobDefinitionChecksums) { + await Promise.all( + Object.entries(jobClasses).map(async([jobName, JobClass ]) => { + this.#log.d(`Initializing job config for ${jobName}`); + const currentChecksum = jobDefinitionChecksums[jobName]; + const existingConfigOverride = await this.#jobConfigsCollection.findOne({ jobName }); + + if (!existingConfigOverride) { + // Create new configuration for new job + await this.#createDefaultJobConfig(jobName, currentChecksum, JobClass); + } + else if (existingConfigOverride.checksum !== currentChecksum) { + // Reset configuration if job implementation has changed + await this.#resetJobConfig(jobName, currentChecksum); + } + }) + ); + } + + /** + * Creates a default configuration for a new job + * @private + * @param {string} jobName - Name of the job + * @param {string} checksum - Checksum of the job definition + * @param {Function} JobClass - Job class implementation + */ + async #createDefaultJobConfig(jobName, checksum, JobClass) { + const instance = new JobClass(jobName); + + await this.#jobConfigsCollection.insertOne({ + jobName, + enabled: true, + checksum, + createdAt: new Date(), + defaultConfig: { + schedule: instance.getSchedule(), + retry: instance.getRetryConfig(), + priority: instance.getPriority(), + concurrency: instance.getConcurrency(), + lockLifetime: instance.getLockLifetime() + } + }); + this.#log.d(`Created default configuration for new job: ${jobName}`); + } + + /** + * Resets job configuration when implementation changes + * @private + * @param {string} jobName - Name of the job + * @param {string} newChecksum - New checksum of the job definition + */ + async #resetJobConfig(jobName, newChecksum) { + this.#log.w(`Job ${jobName} implementation changed, resetting configuration`); + await this.#jobConfigsCollection.updateOne( + { jobName }, + { + $set: { + enabled: true, + checksum: newChecksum, + updatedAt: new Date() + }, + $unset: { + schedule: "", + retry: "", + runNow: "" + } + } + ); + } + + /** + * Applies configurations to jobs + * @private + * @param {Object.} jobClasses - Job class implementations + * @param {Object.} jobDefinitionChecksums - Checksums of job definitions + */ + async #applyJobConfigurations(jobClasses, jobDefinitionChecksums) { + // Load all existing configuration overrides + const configOverrides = await this.#jobConfigsCollection.find({}).toArray(); + const configOverridesMap = new Map(configOverrides.map(conf => [conf.jobName, conf])); + + // Create and configure jobs + await Promise.all( + Object.entries(jobClasses).map(async([jobName, JobClass]) => { + // Create the job with default settings + await this.#jobRunner.createJob(jobName, JobClass); + + // Apply configuration override if valid + const configOverride = configOverridesMap.get(jobName); + if (configOverride && configOverride.checksum === jobDefinitionChecksums[jobName]) { + await this.#applyConfig(configOverride); + this.#log.d(`Applied configuration override for job: ${jobName}`); + } + }) + ); } /** @@ -82,20 +279,6 @@ class JobManager { await this.#jobRunner.disableJob(jobName); } - /** - * Loads the job classes into the job runner - * @param {Object.} jobClasses Object containing job classes keyed by job name - * @returns {Promise} A promise that resolves once the jobs are loaded - */ - #loadJobs(jobClasses) { - return Promise.all( - Object.entries(jobClasses) - .map(([name, JobClass]) => { - return this.#jobRunner.createJob(name, JobClass); - }) - ); - } - /** * Closes the JobManager and cleans up resources * @returns {Promise} A promise that resolves once cleanup is complete diff --git a/jobServer/JobServer.js b/jobServer/JobServer.js index 37a691cbc31..5153fc1e2e6 100644 --- a/jobServer/JobServer.js +++ b/jobServer/JobServer.js @@ -109,8 +109,6 @@ class JobServer { // await this.#jobConfigsCollection.createIndex({ jobName: 1 }, /*{ unique: true }*/); this.#setupSignalHandlers(); - // Watch for changes in job configurations - this.#watchJobConfigs(); this.#log.i('Job process init successfully'); } @@ -176,40 +174,6 @@ class JobServer { }); } - /** - * Watch for changes in job configurations - * @private - */ - async #watchJobConfigs() { - const changeStream = this.#jobConfigsCollection.watch(); - - changeStream.on('change', async(change) => { - try { - if (change.operationType === 'update' || change.operationType === 'insert') { - const jobName = change.fullDocument.jobName; - const enabled = change.fullDocument.enabled; - - if (enabled) { - await this.#jobManager.enableJob(jobName); - } - else { - await this.#jobManager.disableJob(jobName); - } - - this.#log.i(`Job ${jobName} ${enabled ? 'enabled' : 'disabled'}`); - } - } - catch (error) { - this.#log.e('Error processing job config change:', error); - } - }); - - changeStream.on('error', (error) => { - this.#log.e('Error in job configs change stream:', error); - // Implement reconnection logic here - }); - } - /** * Shuts down the job process. * @param {number} [exitCode=0] - The exit code to use when shutting down the process. diff --git a/jobServer/JobUtils.js b/jobServer/JobUtils.js index 1a4df6a65cf..c412d337c7b 100644 --- a/jobServer/JobUtils.js +++ b/jobServer/JobUtils.js @@ -1,6 +1,7 @@ const Job = require('./Job'); const {isValidCron} = require('cron-validator'); const later = require('@breejs/later'); +const crypto = require('crypto'); /** * Class responsible for validating job classes. @@ -235,6 +236,19 @@ class JobUtils { return `${minutePart} ${hourPart} ${dayPart} ${monthPart} ${dayOfWeekPart}`; } + /** + * Calculates checksum for a job class + * @param {Function} JobClass The job class to calculate checksum for + * @returns {string} The calculated checksum + */ + static calculateJobChecksum(JobClass) { + const jobString = JobClass.toString(); + return crypto + .createHash('sha256') + .update(jobString) + .digest('hex'); + } + } module.exports = JobUtils; \ No newline at end of file diff --git a/jobServer/jobRunner/IJobRunner.js b/jobServer/jobRunner/IJobRunner.js index 258298000ae..6bf89da7863 100644 --- a/jobServer/jobRunner/IJobRunner.js +++ b/jobServer/jobRunner/IJobRunner.js @@ -99,6 +99,47 @@ class IJobRunner { async disableJob(/*jobName*/) { throw new Error('Method not implemented'); } + + /** + * Run a job immediately + * @param {string} jobName Name of the job to run + * @returns {Promise} A promise that resolves once the job is triggered + */ + async runJobNow(/* jobName */) { + throw new Error('runJobNow must be implemented'); + } + + /** + * Update job schedule + * @param {string} jobName Name of the job + * @param {string|Date} schedule New schedule (cron string or date) + * @returns {Promise} A promise that resolves once schedule is updated + */ + async updateSchedule(/* jobName, schedule */) { + throw new Error('updateSchedule must be implemented'); + } + + /** + * Configure job retry settings + * @param {string} jobName Name of the job + * @param {Object} retryConfig Retry configuration + * @param {number} retryConfig.attempts Number of retry attempts + * @param {number} retryConfig.delay Delay between retries in ms + * @returns {Promise} A promise that resolves once retry is configured + */ + async configureRetry(/* jobName, retryConfig */) { + throw new Error('configureRetry must be implemented'); + } + + /** + * Maps generic priority to runner-specific priority + * @protected + * @param {string} priority Generic priority from JOB_PRIORITIES + * returns {any} Runner-specific priority value + */ + _mapPriority(/* priority */) { + throw new Error('_mapPriority must be implemented'); + } } module.exports = IJobRunner; \ No newline at end of file diff --git a/jobServer/jobRunner/JobRunnerPulseImpl.js b/jobServer/jobRunner/JobRunnerPulseImpl.js index 6b77e8362f2..e25bb74afdc 100644 --- a/jobServer/jobRunner/JobRunnerPulseImpl.js +++ b/jobServer/jobRunner/JobRunnerPulseImpl.js @@ -1,6 +1,7 @@ const IJobRunner = require('./IJobRunner'); const { Pulse, JobPriority } = require('@pulsecron/pulse'); const {isValidCron} = require('cron-validator'); +const { JOB_PRIORITIES } = require('../constants/JobPriorities'); /** * Pulse implementation of the job runner @@ -77,6 +78,12 @@ class JobRunnerPulseImpl extends IJobRunner { // instance.setLogger(this.log); instance.setJobName(jobName); + // Get job configurations + const retryConfig = instance.getRetryConfig(); + const priority = this._mapPriority(instance.getPriority()); + const concurrency = instance.getConcurrency(); + const lockLifetime = instance.getLockLifetime(); + this.#pulseRunner.define( jobName, async(job, done) => { @@ -84,27 +91,21 @@ class JobRunnerPulseImpl extends IJobRunner { instance._setProgressMethod( async(progressData) => this.#updateJobProgress(job, progressData) ); - - return instance._run( - this.db, - job, - done - ); + return instance._run(this.db, job, done); }, { - priority: JobPriority.normal, - concurrency: 1, - lockLifetime: 10000, + priority, + concurrency, + lockLifetime, shouldSaveResult: true, - attempts: 3, - backoff: { + attempts: retryConfig?.enabled ? retryConfig.attempts : 1, + backoff: retryConfig?.enabled ? { type: 'exponential', - delay: 2000 - } + delay: retryConfig.delay + } : undefined } ); - // Store schedule configuration for later const scheduleConfig = instance.getSchedule(); this.#pendingSchedules.set(jobName, scheduleConfig); this.log.d(`Job ${jobName} defined successfully`); @@ -194,6 +195,86 @@ class JobRunnerPulseImpl extends IJobRunner { throw error; } } + + /** + * Triggers immediate execution of a job + * @param {string} jobName Name of the job to run + * @returns {Promise} A promise that resolves when the job is triggered + */ + async runJobNow(jobName) { + try { + await this.#pulseRunner.now({ name: jobName }); + this.log.i(`Job ${jobName} triggered for immediate execution`); + } + catch (error) { + this.log.e(`Failed to run job ${jobName} immediately:`, error); + throw error; + } + } + + /** + * Updates the schedule of an existing job + * @param {string} jobName Name of the job to update + * @param {Object} schedule New schedule configuration + * @returns {Promise} A promise that resolves when the schedule is updated + */ + async updateSchedule(jobName, schedule) { + try { + await this.#pulseRunner.reschedule({ name: jobName }, schedule); + this.log.i(`Schedule updated for job ${jobName}`); + } + catch (error) { + this.log.e(`Failed to update schedule for job ${jobName}:`, error); + throw error; + } + } + + /** + * Configures retry settings for a job + * @param {string} jobName Name of the job + * @param {Object} retryConfig Retry configuration + * @param {number} retryConfig.attempts Number of retry attempts + * @param {number} retryConfig.delay Delay between retries in milliseconds + * @returns {Promise} A promise that resolves when retry config is updated + */ + async configureRetry(jobName, retryConfig) { + try { + await this.#pulseRunner.updateOne( + { name: jobName }, + { + $set: { + attempts: retryConfig.attempts, + backoff: { + delay: retryConfig.delay, + type: 'fixed' + } + } + } + ); + this.log.i(`Retry configuration updated for job ${jobName}`); + } + catch (error) { + this.log.e(`Failed to configure retry for job ${jobName}:`, error); + throw error; + } + } + + /** + * Maps generic priority to runner-specific priority + * @protected + * @param {string} priority Generic priority from JOB_PRIORITIES + * @returns {any} Runner-specific priority value + */ + _mapPriority(priority) { + const priorityMap = { + [JOB_PRIORITIES.LOWEST]: JobPriority.lowest, + [JOB_PRIORITIES.LOW]: JobPriority.low, + [JOB_PRIORITIES.NORMAL]: JobPriority.normal, + [JOB_PRIORITIES.HIGH]: JobPriority.high, + [JOB_PRIORITIES.HIGHEST]: JobPriority.highest + }; + return priorityMap[priority] || JobPriority.normal; + } } module.exports = JobRunnerPulseImpl; \ No newline at end of file From cc126901ae55300dfebd6cc59d333be278b771a4 Mon Sep 17 00:00:00 2001 From: Kanwar Ujjaval Singh <4216199+kanwarujjaval@users.noreply.github.com> Date: Wed, 15 Jan 2025 17:39:51 +0530 Subject: [PATCH 19/90] refactor for code cleanup because why not --- jobServer/Job.js | 36 ++-- jobServer/JobUtils.js | 49 +++-- jobServer/constants/JobPriorities.js | 24 +++ jobServer/example/ExampleJob.js | 156 ++++++++++++--- jobServer/jobRunner/BaseJobRunner.js | 116 +++++++++++ jobServer/jobRunner/PulseJobRunner.js | 39 ++++ .../jobRunner/impl/pulse/PulseJobExecutor.js | 187 ++++++++++++++++++ .../jobRunner/impl/pulse/PulseJobLifecycle.js | 64 ++++++ .../jobRunner/impl/pulse/PulseJobScheduler.js | 142 +++++++++++++ jobServer/jobRunner/index.js | 16 +- .../jobRunner/interfaces/IJobExecutor.js | 42 ++++ .../jobRunner/interfaces/IJobLifecycle.js | 21 ++ .../jobRunner/interfaces/IJobScheduler.js | 35 ++++ 13 files changed, 856 insertions(+), 71 deletions(-) create mode 100644 jobServer/constants/JobPriorities.js create mode 100644 jobServer/jobRunner/BaseJobRunner.js create mode 100644 jobServer/jobRunner/PulseJobRunner.js create mode 100644 jobServer/jobRunner/impl/pulse/PulseJobExecutor.js create mode 100644 jobServer/jobRunner/impl/pulse/PulseJobLifecycle.js create mode 100644 jobServer/jobRunner/impl/pulse/PulseJobScheduler.js create mode 100644 jobServer/jobRunner/interfaces/IJobExecutor.js create mode 100644 jobServer/jobRunner/interfaces/IJobLifecycle.js create mode 100644 jobServer/jobRunner/interfaces/IJobScheduler.js diff --git a/jobServer/Job.js b/jobServer/Job.js index c1c47212aa5..6652d954c38 100644 --- a/jobServer/Job.js +++ b/jobServer/Job.js @@ -1,10 +1,6 @@ -const defaultLogger = { - d: console.debug, - w: console.warn, - e: console.error, - i: console.info -}; +const defaultLogger = { d: console.debug, w: console.warn, e: console.error, i: console.info }; const { JOB_PRIORITIES } = require('./constants/JobPriorities'); + /** * Base class for creating jobs. * @@ -67,10 +63,12 @@ class Job { logger; /** @type {Function|null} Touch method from job runner */ - _touchMethod = null; + #touchMethod = null; /** @type {Function|null} Progress method from job runner */ - _progressMethod = null; + #progressMethod; + + priorities = JOB_PRIORITIES; /** * Creates an instance of Job. @@ -229,19 +227,19 @@ class Job { /** * Sets the touch method (called internally by job runner) * @param {Function} touchMethod The touch method from Pulse - * @private + * @public */ - _setTouchMethod(touchMethod) { - this._touchMethod = touchMethod; + setTouchMethod(touchMethod) { + this.#touchMethod = touchMethod; } /** * Sets the progress method from the runner * @param {Function} progressMethod Method to update progress - * @protected + * @public */ - _setProgressMethod(progressMethod) { - this._progressMethod = progressMethod; + setProgressMethod(progressMethod) { + this.#progressMethod = progressMethod; } /** @@ -264,13 +262,13 @@ class Job { }; // Update progress using runner's method if available - if (this._progressMethod) { - await this._progressMethod(progressData); + if (this.#progressMethod) { + await this.#progressMethod(progressData); } // Touch to prevent lock expiration - if (this._touchMethod) { - await this._touchMethod(); + if (this.#touchMethod) { + await this.#touchMethod(); } this.logger?.d(`Progress reported for job "${this.jobName}":`, progressData); @@ -297,7 +295,7 @@ class Job { * @returns {string} Priority level from JOB_PRIORITIES */ getPriority() { - return JOB_PRIORITIES.NORMAL; + return this.priorities.NORMAL; } /** diff --git a/jobServer/JobUtils.js b/jobServer/JobUtils.js index c412d337c7b..ccc0b05d502 100644 --- a/jobServer/JobUtils.js +++ b/jobServer/JobUtils.js @@ -26,19 +26,47 @@ class JobUtils { throw new Error(`Job class must extend ${BaseClass.name}`); } - // Additional checks can be added here + // Check if required methods are overridden + const requiredMethods = ['run', 'getSchedule']; + for (const method of requiredMethods) { + // Get the method from the job class prototype + const jobMethod = JobClass.prototype[method]; + // Get the method from the base class prototype + const baseMethod = BaseClass.prototype[method]; + + // Check if method exists and is different from base class implementation + if (!jobMethod || jobMethod === baseMethod) { + throw new Error(`Job class must override the '${method}' method`); + } + } + return true; } /** - * @note - * We shouldn't need this and use cron string directly in job schedule to avoid conversion - * Converts a later.js schedule to a cron string. + * Calculates checksum for a job class + * @param {Function} JobClass The job class to calculate checksum for + * @returns {string} The calculated checksum + */ + static calculateJobChecksum(JobClass) { + const jobString = JobClass.toString(); + return crypto + .createHash('sha256') + .update(jobString) + .digest('hex'); + } + + /** * @param {String} laterString - The later.js schedule string. * @constructor * @retuns {String} The cron string. * * @note + * + * We shouldn't need this and use cron string directly in job schedule to avoid conversion + * Converts a later.js schedule to a cron string. + * + * if we do decide to use this, we need to add tests for all the possible cron strings currently used in countly * TESTS NEEDED * * "every 5 minutes" @@ -236,19 +264,6 @@ class JobUtils { return `${minutePart} ${hourPart} ${dayPart} ${monthPart} ${dayOfWeekPart}`; } - /** - * Calculates checksum for a job class - * @param {Function} JobClass The job class to calculate checksum for - * @returns {string} The calculated checksum - */ - static calculateJobChecksum(JobClass) { - const jobString = JobClass.toString(); - return crypto - .createHash('sha256') - .update(jobString) - .digest('hex'); - } - } module.exports = JobUtils; \ No newline at end of file diff --git a/jobServer/constants/JobPriorities.js b/jobServer/constants/JobPriorities.js new file mode 100644 index 00000000000..dad4b34cd8b --- /dev/null +++ b/jobServer/constants/JobPriorities.js @@ -0,0 +1,24 @@ +/** + * Job priority levels + * @enum {string} + */ +const JOB_PRIORITIES = { + /** Lowest priority level */ + LOWEST: 'lowest', + + /** Low priority level */ + LOW: 'low', + + /** Normal/default priority level */ + NORMAL: 'normal', + + /** High priority level */ + HIGH: 'high', + + /** Highest priority level */ + HIGHEST: 'highest' +}; + +module.exports = { + JOB_PRIORITIES +}; \ No newline at end of file diff --git a/jobServer/example/ExampleJob.js b/jobServer/example/ExampleJob.js index a05b0da6f7c..11fb89a2114 100644 --- a/jobServer/example/ExampleJob.js +++ b/jobServer/example/ExampleJob.js @@ -2,45 +2,131 @@ const job = require("../../jobServer"); /** * Example job implementation demonstrating all features of the job system. - * This job processes user records in batches, demonstrating: - * - Progress reporting - * - Error handling - * - Different schedule types - * - Database operations - * - Proper logging + * + * Required methods: + * - getSchedule() + * - run() + * + * Optional methods with defaults: + * - getRetryConfig() - Default: { enabled: true, attempts: 3, delay: 2000 } + * - getPriority() - Default: NORMAL + * - getConcurrency() - Default: 1 + * - getLockLifetime() - Default: 55 minutes * * @extends {job.Job} */ class ExampleJob extends job.Job { + /** * Get the schedule configuration for the job. - * Demonstrates all possible schedule types. + * @required * * @returns {Object} Schedule configuration object + * @property {('once'|'schedule'|'now')} type - Type of schedule + * @property {string|Date} [value] - Schedule value (cron expression or Date) + * + * @example + * // Run every day at midnight + * return { + * type: 'schedule', + * value: '0 0 * * *' + * } + * + * @example + * // Run once at a specific future date + * return { + * type: 'once', + * value: new Date('2024-12-31T23:59:59Z') + * } + * + * @example + * // Run immediately + * return { + * type: 'now' + * } + * + * @example + * // Run every 5 minutes + * return { + * type: 'schedule', + * value: '* /5 * * * *' + * } + * + * @example + * // Run at specific times using cron + * return { + * type: 'schedule', + * value: '0 9,15,21 * * *' // Runs at 9am, 3pm, and 9pm + * } */ getSchedule() { - // Example 1: Run every day at midnight + // Example: Run every day at midnight return { type: 'schedule', value: '0 0 * * *' }; + } - // Example 2: Run once at a specific future date - // return { - // type: 'once', - // value: new Date('2024-12-31T23:59:59Z') - // }; + /** + * Configure retry behavior for failed jobs. + * @optional + * @default { enabled: false, attempts: 3, delay: 2000 } + * + * @returns {Object} Retry configuration + * @property {boolean} enabled - Whether retries are enabled + * @property {number} attempts - Number of retry attempts + * @property {number} delay - Initial delay between retries in milliseconds + * (increases exponentially with each retry) + */ + getRetryConfig() { + return { + enabled: true, + attempts: 3, // Will try up to 5 times + delay: 1000 * 60 // Start with 1-minute delay, then exponential backoff + }; + } - // Example 3: Run immediately - // return { - // type: 'now' - // }; + /** + * Set job priority level. + * Higher priority jobs are processed first. + * @optional + * @default "NORMAL" + * + * @returns {string} Priority level (HIGH, NORMAL, LOW) + */ + getPriority() { + return this.priorities.HIGH; + } + + /** + * Set maximum concurrent instances of this job. + * Limits how many copies of this job can run simultaneously. + * @optional + * @default 1 + * + * @returns {number} Maximum number of concurrent instances + */ + getConcurrency() { + return 2; // Allow 2 instances to run simultaneously } /** - * Simulates some processing work + * Set job lock lifetime. + * Determines how long a job can run before its lock expires. + * @optional + * @default 55 * 60 * 1000 (55 minutes) + * + * @returns {number} Lock lifetime in milliseconds + */ + getLockLifetime() { + return 30 * 60 * 1000; // 30 minutes + } + + /** + * Simulates processing work with a delay * @private * @param {number} ms Time to wait in milliseconds + * @returns {Promise} Returns a promise that resolves when the delay is complete */ async #simulateWork(ms) { await new Promise(resolve => setTimeout(resolve, ms)); @@ -48,13 +134,16 @@ class ExampleJob extends job.Job { /** * Main job execution method. + * @required + * * Demonstrates: * - Progress reporting * - Synthetic delays to simulate work * - Error handling - * - Logging + * - Logging at different levels + * - Database operations (commented examples) * - * @param {Object} db Database connection + * @param {Db} db Database connection * @param {Function} done Callback to signal job completion * @param {Function} progress Progress reporting function */ @@ -62,17 +151,25 @@ class ExampleJob extends job.Job { try { this.logger.d("Starting example job execution"); - // Simulate total items to process - const total = 100; + // Example: Query total items to process + // const total = await db.collection('users').countDocuments({ active: false }); + const total = 100; // Simulated total let processed = 0; // Process in batches of 10 for (let i = 0; i < total; i += 10) { + // Example: Process a batch of records + // const batch = await db.collection('users') + // .find({ active: false }) + // .skip(i) + // .limit(10) + // .toArray(); + // Simulate batch processing (2 second per batch) await this.#simulateWork(2000); processed += 10; - // Report progress + // Report progress with detailed bookmark await progress( total, processed, @@ -81,6 +178,17 @@ class ExampleJob extends job.Job { this.logger.d(`Completed batch ${(i / 10) + 1}/10`); + // Example: Update processed records + // await db.collection('users').updateMany( + // { _id: { $in: batch.map(doc => doc._id) } }, + // { + // $set: { + // processed: true, + // updatedAt: new Date() + // } + // } + // ); + // Simulate random error (10% chance) if (Math.random() < 0.1) { throw new Error('Random batch processing error'); @@ -90,7 +198,7 @@ class ExampleJob extends job.Job { // Simulate final processing await this.#simulateWork(1000); - // Job completion + // Job completion with result data const result = { processedCount: processed, totalItems: total, diff --git a/jobServer/jobRunner/BaseJobRunner.js b/jobServer/jobRunner/BaseJobRunner.js new file mode 100644 index 00000000000..84af66dcdb2 --- /dev/null +++ b/jobServer/jobRunner/BaseJobRunner.js @@ -0,0 +1,116 @@ +const IJobScheduler = require('./interfaces/IJobScheduler'); +const IJobExecutor = require('./interfaces/IJobExecutor'); +const IJobLifecycle = require('./interfaces/IJobLifecycle'); + +/** + * Base class for job runners that implements all interfaces through composition + */ +class BaseJobRunner { + /** + * @param {IJobScheduler} scheduler - Scheduler implementation + * @param {IJobExecutor} executor - Executor implementation + * @param {IJobLifecycle} lifecycle - Lifecycle implementation + */ + constructor(scheduler, executor, lifecycle) { + if (!(scheduler instanceof IJobScheduler)) { + throw new Error('Invalid scheduler implementation'); + } + if (!(executor instanceof IJobExecutor)) { + throw new Error('Invalid executor implementation'); + } + if (!(lifecycle instanceof IJobLifecycle)) { + throw new Error('Invalid lifecycle implementation'); + } + + this.scheduler = scheduler; + this.executor = executor; + this.lifecycle = lifecycle; + } + + /** + * Schedules a job to run + * @param {string} name Job name + * @param {Object} scheduleConfig Schedule configuration + * @param {Object} [data] Optional data to pass to the job + * @returns {Promise} A promise that resolves once the job is scheduled + */ + async schedule(name, scheduleConfig, data) { + return this.scheduler.schedule(name, scheduleConfig, data); + } + + /** + * Updates a job's schedule + * @param {string} jobName Name of the job + * @param {Object} schedule New schedule configuration + * @returns {Promise} A promise that resolves once the schedule is updated + */ + async updateSchedule(jobName, schedule) { + return this.scheduler.updateSchedule(jobName, schedule); + } + + /** + * Runs a job immediately + * @param {string} jobName Name of the job + * @returns {Promise} A promise that resolves when the job is triggered + */ + async runJobNow(jobName) { + return this.scheduler.runJobNow(jobName); + } + + /** + * Creates and registers a new job + * @param {string} jobName Name of the job + * @param {Function} JobClass Job class implementation + * @returns {Promise} A promise that resolves once the job is created and registered + */ + async createJob(jobName, JobClass) { + return this.executor.createJob(jobName, JobClass); + } + + /** + * Enables a job + * @param {string} jobName Name of the job + * @returns {Promise} A promise that resolves once the job is enabled + */ + async enableJob(jobName) { + return this.executor.enableJob(jobName); + } + + /** + * Disables a job + * @param {string} jobName Name of the job + * @returns {Promise} A promise that resolves once the job is disabled + */ + async disableJob(jobName) { + return this.executor.disableJob(jobName); + } + + /** + * Configures retry settings for a job + * @param {string} jobName Name of the job + * @param {Object} retryConfig Retry configuration + * @returns {Promise} A promise that resolves once the retry settings are configured + */ + async configureRetry(jobName, retryConfig) { + return this.executor.configureRetry(jobName, retryConfig); + } + + /** + * Starts the job runner + * @param {Object.} jobClasses Job classes to register + * @returns {Promise} A promise that resolves once the job runner is started + */ + async start(jobClasses) { + return this.lifecycle.start(jobClasses); + } + + /** + * Closes the job runner and cleans up resources + * @returns {Promise} A promise that resolves once the job runner is closed + */ + async close() { + return this.lifecycle.close(); + } +} + +module.exports = BaseJobRunner; \ No newline at end of file diff --git a/jobServer/jobRunner/PulseJobRunner.js b/jobServer/jobRunner/PulseJobRunner.js new file mode 100644 index 00000000000..03875504e38 --- /dev/null +++ b/jobServer/jobRunner/PulseJobRunner.js @@ -0,0 +1,39 @@ +const { Pulse } = require('@pulsecron/pulse'); +const BaseJobRunner = require('./BaseJobRunner'); +const PulseJobScheduler = require('./impl/pulse/PulseJobScheduler'); +const PulseJobExecutor = require('./impl/pulse/PulseJobExecutor'); +const PulseJobLifecycle = require('./impl/pulse/PulseJobLifecycle'); + +/** + * Pulse-specific implementation of the job runner using BaseJobRunner composition + */ +class PulseJobRunner extends BaseJobRunner { + /** + * Creates a new Pulse job runner with all required implementations + * @param {Object} db Database connection + * @param {Object} config Configuration object + * @param {function} Logger - Logger constructor + */ + constructor(db, config, Logger) { + const log = Logger('jobs:runner:pulse'); + + // Create the Pulse instance that will be shared across implementations + const pulseRunner = new Pulse({ + ...config, + mongo: db, + }); + + // Create implementations with shared pulseRunner instance + const scheduler = new PulseJobScheduler(pulseRunner, log); + const executor = new PulseJobExecutor(pulseRunner, db, log); + const lifecycle = new PulseJobLifecycle(pulseRunner, executor, scheduler, log); + + // Initialize base class with implementations + super(scheduler, executor, lifecycle); + + this.log = log; + this.pulseRunner = pulseRunner; + } +} + +module.exports = PulseJobRunner; \ No newline at end of file diff --git a/jobServer/jobRunner/impl/pulse/PulseJobExecutor.js b/jobServer/jobRunner/impl/pulse/PulseJobExecutor.js new file mode 100644 index 00000000000..1016800817e --- /dev/null +++ b/jobServer/jobRunner/impl/pulse/PulseJobExecutor.js @@ -0,0 +1,187 @@ +const IJobExecutor = require('../../interfaces/IJobExecutor'); +const { JobPriority } = require('@pulsecron/pulse'); +const { JOB_PRIORITIES } = require('../../../constants/JobPriorities'); + +/** + * Pulse implementation of job executor + */ +class PulseJobExecutor extends IJobExecutor { + /** + * Creates a new PulseJobExecutor instance + * @param {Object} pulseRunner The Pulse runner instance + * @param {Object} db Database connection + * @param {Object} logger Logger instance + */ + constructor(pulseRunner, db, logger) { + super(); + this.pulseRunner = pulseRunner; + this.db = db; + this.log = logger; + this.pendingSchedules = new Map(); + } + + /** + * Creates and registers a new job + * @param {string} jobName Name of the job + * @param {Function} JobClass Job class implementation + * @returns {Promise} A promise that resolves once the job is created + */ + async createJob(jobName, JobClass) { + try { + const instance = new JobClass(jobName); + instance.setJobName(jobName); + + const retryConfig = instance.getRetryConfig(); + const priority = this.#mapPriority(instance.getPriority()); + const concurrency = instance.getConcurrency(); + const lockLifetime = instance.getLockLifetime(); + + this.pulseRunner.define( + jobName, + async(job, done) => { + instance.setTouchMethod(job.touch.bind(job)); + instance.setProgressMethod( + async(progressData) => this.#updateJobProgress(job, progressData) + ); + return instance._run(this.db, job, done); + }, + { + priority, + concurrency, + lockLifetime, + shouldSaveResult: true, + attempts: retryConfig?.enabled ? retryConfig.attempts : 1, + backoff: retryConfig?.enabled ? { + type: 'exponential', + delay: retryConfig.delay + } : undefined + } + ); + + const scheduleConfig = instance.getSchedule(); + this.pendingSchedules.set(jobName, scheduleConfig); + this.log.d(`Job ${jobName} defined successfully`); + } + catch (error) { + this.log.e(`Failed to create job ${jobName}:`, error); + } + } + + /** + * Enables a job + * @param {string} jobName Name of the job + * @returns {Promise} A promise that resolves once the job is enabled + */ + async enableJob(jobName) { + try { + await this.pulseRunner.enable({ name: jobName }); + this.log.i(`Job ${jobName} enabled`); + } + catch (error) { + this.log.e(`Failed to enable job ${jobName}:`, error); + throw error; + } + } + + /** + * Disables a job + * @param {string} jobName Name of the job + * @returns {Promise} A promise that resolves once the job is disabled + */ + async disableJob(jobName) { + try { + await this.pulseRunner.disable({ name: jobName }); + this.log.i(`Job ${jobName} disabled`); + } + catch (error) { + this.log.e(`Failed to disable job ${jobName}:`, error); + throw error; + } + } + + /** + * Configures retry settings for a job + * @param {string} jobName Name of the job + * @param {Object} retryConfig Retry configuration + * @returns {Promise} A promise that resolves once retry settings are updated + */ + async configureRetry(jobName, retryConfig) { + try { + // First get the job definition + const definition = this.pulseRunner._definitions[jobName]; + if (!definition) { + throw new Error(`Job ${jobName} not found`); + } + + // Update the definition for future job instances + definition.attempts = retryConfig.attempts; + definition.backoff = { + delay: retryConfig.delay, + type: 'exponential' + }; + + // Update existing jobs in the queue using the MongoDB collection directly + await this.pulseRunner._collection.updateMany( + { name: jobName }, + { + $set: { + attempts: retryConfig.attempts, + backoff: { + delay: retryConfig.delay, + type: 'exponential' + } + } + } + ); + + this.log.i(`Retry configuration updated for job ${jobName}`); + } + catch (error) { + this.log.e(`Failed to configure retry for job ${jobName}:`, error); + throw error; + } + } + + /** + * Updates job progress data + * @private + * @param {Object} job Job instance + * @param {Object} progressData Progress data to store + * @returns {Promise} A promise that resolves once progress is updated + */ + async #updateJobProgress(job, progressData) { + try { + job.data = progressData; + await job.save(); + } + catch (error) { + this.log.e(`Failed to update job progress: ${error.message}`); + // Consider whether to throw + } + } + + /** + * Maps generic priority to Pulse-specific priority with validation + * @private + * @param {string} priority Generic priority from JOB_PRIORITIES + * @returns {JobPriority} Pulse-specific priority value + */ + #mapPriority(priority) { + const priorityMap = { + [JOB_PRIORITIES.LOWEST]: JobPriority.lowest, + [JOB_PRIORITIES.LOW]: JobPriority.low, + [JOB_PRIORITIES.NORMAL]: JobPriority.normal, + [JOB_PRIORITIES.HIGH]: JobPriority.high, + [JOB_PRIORITIES.HIGHEST]: JobPriority.highest + }; + + if (!priority || !priorityMap[priority]) { + this.log.w(`Invalid priority "${priority}", defaulting to normal`); + return JobPriority.normal; + } + + return priorityMap[priority]; + } +} + +module.exports = PulseJobExecutor; \ No newline at end of file diff --git a/jobServer/jobRunner/impl/pulse/PulseJobLifecycle.js b/jobServer/jobRunner/impl/pulse/PulseJobLifecycle.js new file mode 100644 index 00000000000..e9955f4334c --- /dev/null +++ b/jobServer/jobRunner/impl/pulse/PulseJobLifecycle.js @@ -0,0 +1,64 @@ +const IJobLifecycle = require('../../interfaces/IJobLifecycle'); + +/** + * Pulse implementation of job lifecycle management + */ +class PulseJobLifecycle extends IJobLifecycle { + /** + * Creates a new PulseJobLifecycle instance + * @param {Object} pulseRunner The Pulse runner instance + * @param {Object} executor Job executor instance + * @param {Object} scheduler Job scheduler instance + * @param {Object} logger Logger instance + */ + constructor(pulseRunner, executor, scheduler, logger) { + super(); + this.pulseRunner = pulseRunner; + this.executor = executor; + this.scheduler = scheduler; + this.log = logger; + } + + /** + * Starts the job runner and schedules pending jobs + * @param {Object.} jobClasses Job classes to register + * @returns {Promise} A promise that resolves once the runner is started + */ + async start(/* jobClasses */) { + if (!this.pulseRunner) { + throw new Error('Pulse runner not initialized'); + } + + await this.pulseRunner.start(); + this.log.i('Pulse runner started'); + + // Schedule all pending jobs from the executor + for (const [jobName, scheduleConfig] of this.executor.pendingSchedules) { + try { + await this.scheduler.schedule(jobName, scheduleConfig); + } + catch (error) { + this.log.e(`Failed to schedule job ${jobName}:`, error); + } + } + + this.executor.pendingSchedules.clear(); + } + + /** + * Closes the job runner and cleans up resources + * @returns {Promise} A promise that resolves once the runner is closed + */ + async close() { + try { + await this.pulseRunner.close(); + this.log.i('Pulse runner closed'); + } + catch (error) { + this.log.e('Error closing Pulse runner:', error); + throw error; + } + } +} + +module.exports = PulseJobLifecycle; \ No newline at end of file diff --git a/jobServer/jobRunner/impl/pulse/PulseJobScheduler.js b/jobServer/jobRunner/impl/pulse/PulseJobScheduler.js new file mode 100644 index 00000000000..86a09274b7f --- /dev/null +++ b/jobServer/jobRunner/impl/pulse/PulseJobScheduler.js @@ -0,0 +1,142 @@ +const IJobScheduler = require('../../interfaces/IJobScheduler'); +const {isValidCron} = require('cron-validator'); + +/** + * Pulse implementation of job scheduler + */ +class PulseJobScheduler extends IJobScheduler { + /** + * Creates a new PulseJobScheduler instance + * @param {Object} pulseRunner The Pulse runner instance + * @param {Object} logger Logger instance + */ + constructor(pulseRunner, logger) { + super(); + this.pulseRunner = pulseRunner; + this.log = logger; + } + + /** + * Validates schedule configuration + * @private + * @param {Object} config Schedule configuration + * @throws {Error} If configuration is invalid + */ + #validateScheduleConfig(config) { + if (!config || typeof config !== 'object') { + throw new Error('Schedule configuration must be an object'); + } + + if (!config.type) { + throw new Error('Schedule type is required'); + } + + const validTypes = ['schedule', 'once', 'now']; + if (!validTypes.includes(config.type)) { + throw new Error(`Invalid schedule type: ${config.type}`); + } + + if (config.type !== 'now' && !config.value) { + throw new Error('Schedule value is required'); + } + } + + /** + * Schedules a job to run + * @param {string} name Job name + * @param {Object} scheduleConfig Schedule configuration + * @param {Object} [data] Optional data to pass to the job + * @returns {Promise} A promise that resolves once the job is scheduled + */ + async schedule(name, scheduleConfig, data = {}) { + try { + this.#validateScheduleConfig(scheduleConfig); + + switch (scheduleConfig.type) { + case 'schedule': + if (!isValidCron(scheduleConfig.value)) { + throw new Error('Invalid cron schedule'); + } + await this.pulseRunner.every(scheduleConfig.value, name, data); + break; + + case 'once': + if (!(scheduleConfig.value instanceof Date)) { + throw new Error('Invalid date for one-time schedule'); + } + await this.pulseRunner.schedule(scheduleConfig.value, name, data); + break; + + case 'now': + await this.pulseRunner.now(name, data); + break; + } + + this.log.d(`Job ${name} scheduled successfully with type: ${scheduleConfig.type}`); + } + catch (error) { + this.log.e(`Failed to schedule job ${name}:`, error); + throw error; + } + } + + /** + * Updates a job's schedule + * @param {string} jobName Name of the job + * @param {Object} schedule New schedule configuration + * @returns {Promise} A promise that resolves once the schedule is updated + */ + async updateSchedule(jobName, schedule) { + try { + this.#validateScheduleConfig(schedule); + + // First remove the existing job + await this.pulseRunner.remove({ name: jobName }); + + // Then create a new schedule based on the type + switch (schedule.type) { + case 'schedule': + if (!isValidCron(schedule.value)) { + throw new Error('Invalid cron schedule'); + } + await this.pulseRunner.every(schedule.value, jobName); + break; + + case 'once': + if (!(schedule.value instanceof Date)) { + throw new Error('Invalid date for one-time schedule'); + } + await this.pulseRunner.schedule(schedule.value, jobName); + break; + + case 'now': + await this.pulseRunner.now(jobName); + break; + } + + this.log.i(`Schedule updated for job ${jobName}`); + } + catch (error) { + this.log.e(`Failed to update schedule for job ${jobName}:`, error); + throw error; + } + } + + /** + * Runs a job immediately + * @param {string} jobName Name of the job + * @returns {Promise} A promise that resolves when the job is triggered + */ + async runJobNow(jobName) { + try { + await this.pulseRunner.now({ name: jobName }); + this.log.i(`Job ${jobName} triggered for immediate execution`); + } + catch (error) { + this.log.e(`Failed to run job ${jobName} immediately:`, error); + throw error; + } + } +} + +module.exports = PulseJobScheduler; \ No newline at end of file diff --git a/jobServer/jobRunner/index.js b/jobServer/jobRunner/index.js index 24e62197db3..7e6c624646c 100644 --- a/jobServer/jobRunner/index.js +++ b/jobServer/jobRunner/index.js @@ -1,13 +1,10 @@ -const IJobRunner = require('./IJobRunner'); -// const JobRunnerBullImpl = require('./JobRunnerBullImpl'); -const JobRunnerPulseImpl = require('./JobRunnerPulseImpl'); +const PulseJobRunner = require('./PulseJobRunner.js'); /** * JobRunner implementation types * @enum {string} */ const RUNNER_TYPES = { - // BULL: 'bull', PULSE: 'pulse' }; @@ -15,21 +12,18 @@ const RUNNER_TYPES = { * Job Runner factory * * @param {Object} db The database connection - * @param {string} [type='pulse'] The type of runner to create ('bull' or 'pulse') + * @param {string} [type='pulse'] The type of runner to create * @param {Object} [config={}] Configuration specific to the runner implementation * @param {function} Logger - Logger constructor - * @returns {IJobRunner} An instance of the specified JobRunner implementation + * @returns {BaseJobRunner} An instance of BaseJobRunner with specific implementation * @throws {Error} If an invalid runner type is specified */ function createJobRunner(db, type = RUNNER_TYPES.PULSE, config = {}, Logger) { - switch (type.toLowerCase()) { - // case RUNNER_TYPES.BULL: - // return new JobRunnerBullImpl(db, config, Logger); case RUNNER_TYPES.PULSE: - return new JobRunnerPulseImpl(db, config, Logger); + return new PulseJobRunner(db, config, Logger); default: - throw new Error(`Invalid runner type: ${type}. Must be one of: ${Object.values(RUNNER_TYPES).join(', ')} and implementation of ` + IJobRunner.name); + throw new Error(`Invalid runner type: ${type}. Must be one of: ${Object.values(RUNNER_TYPES).join(', ')}`); } } diff --git a/jobServer/jobRunner/interfaces/IJobExecutor.js b/jobServer/jobRunner/interfaces/IJobExecutor.js new file mode 100644 index 00000000000..5757cc756c8 --- /dev/null +++ b/jobServer/jobRunner/interfaces/IJobExecutor.js @@ -0,0 +1,42 @@ +/** + * Interface for job execution operations + */ +class IJobExecutor { + /** + * Creates and defines a new job + * @param {string} jobName The name of the job + * @param {Function} JobClass The job class to create + */ + async createJob(/* jobName, JobClass */) { + throw new Error('Method not implemented'); + } + + /** + * Enable a job + * @param {string} jobName Name of the job to enable + */ + async enableJob(/* jobName */) { + throw new Error('Method not implemented'); + } + + /** + * Disable a job + * @param {string} jobName Name of the job to disable + */ + async disableJob(/* jobName */) { + throw new Error('Method not implemented'); + } + + /** + * Configure job retry settings + * @param {string} jobName Name of the job + * @param {Object} retryConfig Retry configuration + * @param {number} retryConfig.attempts Number of retry attempts + * @param {number} retryConfig.delay Delay between retries in ms + */ + async configureRetry(/* jobName, retryConfig */) { + throw new Error('Method not implemented'); + } +} + +module.exports = IJobExecutor; \ No newline at end of file diff --git a/jobServer/jobRunner/interfaces/IJobLifecycle.js b/jobServer/jobRunner/interfaces/IJobLifecycle.js new file mode 100644 index 00000000000..92b1bd83222 --- /dev/null +++ b/jobServer/jobRunner/interfaces/IJobLifecycle.js @@ -0,0 +1,21 @@ +/** + * Interface for job lifecycle operations + */ +class IJobLifecycle { + /** + * Starts the job runner + * @param {Object.} jobClasses Object containing job classes keyed by job name + */ + async start(/* jobClasses */) { + throw new Error('Method not implemented'); + } + + /** + * Closes the job runner and cleans up resources + */ + async close() { + throw new Error('Method not implemented'); + } +} + +module.exports = IJobLifecycle; \ No newline at end of file diff --git a/jobServer/jobRunner/interfaces/IJobScheduler.js b/jobServer/jobRunner/interfaces/IJobScheduler.js new file mode 100644 index 00000000000..9046ec7ee30 --- /dev/null +++ b/jobServer/jobRunner/interfaces/IJobScheduler.js @@ -0,0 +1,35 @@ +/** + * Interface for job scheduling operations + */ +class IJobScheduler { + /** + * Schedules a job based on its configuration + * @param {String} name The name of the job + * @param {Object} scheduleConfig Schedule configuration object + * @param {('once'|'schedule'|'now')} scheduleConfig.type Type of schedule + * @param {string|Date} [scheduleConfig.value] Cron string or Date object + * @param {Object} [data] Data to pass to the job + */ + async schedule(/* name, scheduleConfig, data */) { + throw new Error('Method not implemented'); + } + + /** + * Update job schedule + * @param {string} jobName Name of the job + * @param {string|Date} schedule New schedule (cron string or date) + */ + async updateSchedule(/* jobName, schedule */) { + throw new Error('Method not implemented'); + } + + /** + * Runs a job now + * @param {string} jobName Name of the job to run + */ + async runJobNow(/* jobName */) { + throw new Error('Method not implemented'); + } +} + +module.exports = IJobScheduler; \ No newline at end of file From 7b2162617d37561ac59bb24a256ecb7b1efe8d99 Mon Sep 17 00:00:00 2001 From: Kanwar Ujjaval Singh <4216199+kanwarujjaval@users.noreply.github.com> Date: Wed, 15 Jan 2025 18:15:08 +0530 Subject: [PATCH 20/90] README update --- jobServer/README.md | 964 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 963 insertions(+), 1 deletion(-) diff --git a/jobServer/README.md b/jobServer/README.md index c858d63ada6..8118adc6d0f 100644 --- a/jobServer/README.md +++ b/jobServer/README.md @@ -1 +1,963 @@ -# Job Server \ No newline at end of file +# Job Server Module + +A flexible, extensible job scheduling and execution system built on MongoDB with support for multiple runner implementations. + +## Table of Contents + +1. [Overview](#overview) +2. [Architecture](#architecture) +3. [Dependencies](#dependencies) +4. [Installation](#installation) +5. [Basic Usage](#basic-usage) +6. [Collections](#collections) +7. [Server Configuration](#server-configuration) +8. [Job Implementation Guide](#job-implementation-guide) +9. [Job Configuration Management](#job-configuration-management) +10. [Parallel Processing](#parallel-processing) +11. [Class Reference](#class-reference) +12. [File Structure](#file-structure) +13. [Interface Contracts](#interface-contracts) +14. [Implementing New Runners](#implementing-new-runners) +15. [BullMQ Implementation Guide](#bullmq-implementation-guide) + +## Overview + +The Job Server Module provides a robust framework for scheduling and executing background jobs in a distributed environment. Built with extensibility in mind, it currently supports Pulse (MongoDB-based) as the default runner, with capability to add other runners like BullMQ. + +### Key Features: + +- Flexible job scheduling (cron, one-time, immediate) +- Job progress tracking +- Automatic retries with exponential backoff +- Priority-based execution +- Concurrent job execution control +- Dynamic configuration updates +- Distributed execution across multiple processes +- Extensible runner architecture + +## Dependencies + +Core dependencies: +```json +{ + "@pulsecron/pulse": "^1.0.0", + "cron-validator": "^1.3.1", + "@breejs/later": "^4.1.0", // should be removed soon + "mongodb": "^4.0.0" +} +``` + +## Installation + +1. Install dependencies: +```bash +npm install @pulsecron/pulse cron-validator @breejs/later mongodb +``` + +2. Set up MongoDB connection: +```javascript +const { MongoClient } = require('mongodb'); +const client = await MongoClient.connect('mongodb://localhost:27017'); +const db = client.db('your_database'); +``` + +## Collections + +The module uses two MongoDB collections: + +1. `pulseJobs` (configurable name) + - Stores job definitions and execution state + - Managed by Pulse runner + - Schema includes job name, schedule, status, locks, etc. + +2. `jobConfigs` + - Stores job configuration overrides + - Used for dynamic configuration management + - Schema: + ```javascript + { + jobName: String, // Unique job identifier + enabled: Boolean, // Job enabled state + checksum: String, // Job implementation checksum + schedule: Object, // Schedule override + retry: Object, // Retry configuration + runNow: Boolean, // Trigger immediate execution + createdAt: Date, + updatedAt: Date, + defaultConfig: Object // Original job configuration + } + ``` + +## Basic Usage + +1. Create a job server instance: +```javascript +const JobServer = require('./JobServer'); +const server = await JobServer.create(common, Logger, pluginManager); +``` + +2. Start the server: +```javascript +await server.start(); +``` + +3. Handle graceful shutdown: +```javascript +process.on('SIGTERM', () => server.shutdown()); +process.on('SIGINT', () => server.shutdown()); +``` + +## Architecture + +### Dependency Flow + +``` +JobServer + | + +------------------+------------------+ + | | | +JobScanner JobManager Signal Handlers + | | + | +------+------+ + | | | | +Plugin System Jobs JobRunner Config + | | + | +------+------+ + | | | | +JobExecutor JobScheduler JobLifecycle + | | | + +----------+------+ + | + Runner Impl + (Pulse/BullMQ/etc) +``` + +### Core Components Flow + +``` +[Job Files] -> [JobScanner] -> [JobManager] -> [JobRunner] -> [Database] + ^ | | | + | v v v +[Plugin Jobs] [Configuration] [Execution] [Storage] +``` + +### Component Roles + +1. **JobServer** + - Entry point and orchestrator + - Manages lifecycle of all components + - Handles process signals and shutdown + +2. **JobScanner** + - Discovers job files in project and plugins + - Validates job implementations + - Tracks job file changes + +3. **JobManager** + - Manages job configurations + - Handles dynamic updates + - Coordinates with runner implementation + +4. **JobRunner** + - Abstract interface for runner implementations + - Provides common job operations + - Delegates to specific implementations + +5. **Runner Implementation** + - Concrete implementation (e.g., Pulse) + - Handles actual job execution + - Manages scheduling and state + +## Server Configuration + +### Pulse Runner Configuration + +```javascript +const DEFAULT_PULSE_CONFIG = { + processEvery: '3 seconds', // Job check frequency + maxConcurrency: 1, // Max concurrent jobs + defaultConcurrency: 1, // Default concurrent jobs per type + lockLimit: 1, // Max locked jobs + defaultLockLimit: 1, // Default locked jobs per type + defaultLockLifetime: 55 * 60 * 1000, // Lock timeout (55 mins) + sort: { nextRunAt: 1, priority: -1 }, + db: { + collection: 'pulseJobs', // Collection name + } +}; +``` + +### Process-Level Configuration + +```javascript +const server = await JobServer.create(common, Logger, pluginManager, { + runner: { + type: 'pulse', + config: { + maxConcurrency: 5, + processEvery: '5 seconds', + defaultLockLifetime: 30 * 60 * 1000 + } + }, + scanner: { + watchEnabled: true, + scanInterval: 60000 + } +}); +``` + +## Job Implementation Guide + +### Basic Job Structure + +```javascript +const { Job } = require('./jobServer'); + +class MyJob extends Job { + // Required: Define schedule + getSchedule() { + return { + type: 'schedule', + value: '0 * * * *' // Run hourly + }; + } + + // Required: Implement job logic + async run(db, done, progress) { + try { + // Your job logic here + await progress(100, 50, 'Processing...'); + done(null, { success: true }); + } + catch (error) { + done(error); + } + } + + // Optional: Configure retries + getRetryConfig() { + return { + enabled: true, + attempts: 3, + delay: 60000 // 1 minute + }; + } + + // Optional: Set priority + getPriority() { + return this.priorities.HIGH; + } + + // Optional: Set concurrency + getConcurrency() { + return 2; + } +} +``` + +### Long-Running Job Example + +```javascript +class DataProcessingJob extends Job { + getSchedule() { + return { + type: 'schedule', + value: '0 0 * * *' // Daily at midnight + }; + } + + async run(db, done, progress) { + try { + const total = await db.collection('data').countDocuments(); + let processed = 0; + + // Process in batches + for (let i = 0; i < total; i += 100) { + const batch = await db.collection('data') + .find() + .skip(i) + .limit(100) + .toArray(); + + await this.processBatch(batch); + processed += batch.length; + + // Report progress + await progress( + total, + processed, + `Processed ${processed} of ${total} records` + ); + + // Extend lock if needed + await this.touch(); + } + + done(null, { processed }); + } + catch (error) { + done(error); + } + } + + getLockLifetime() { + return 2 * 60 * 60 * 1000; // 2 hours + } +} +``` + +## Job Configuration Management + +### Dynamic Configuration via MongoDB + +Jobs can be configured dynamically by modifying the `jobConfigs` collection. The server watches for changes and applies them in real-time. + +### Configuration Operations + +1. **Enable/Disable Job** +```javascript +await db.collection('jobConfigs').updateOne( + { jobName: 'api:myJob' }, + { $set: { enabled: false } } +); +``` + +2. **Update Schedule** +```javascript +await db.collection('jobConfigs').updateOne( + { jobName: 'api:myJob' }, + { + $set: { + schedule: { + type: 'schedule', + value: '*/30 * * * *' // Every 30 minutes + } + } + } +); +``` + +3. **Modify Retry Settings** +```javascript +await db.collection('jobConfigs').updateOne( + { jobName: 'api:myJob' }, + { + $set: { + retry: { + enabled: true, + attempts: 5, + delay: 120000 // 2 minutes + } + } + } +); +``` + +4. **Trigger Immediate Execution** +```javascript +await db.collection('jobConfigs').updateOne( + { jobName: 'api:myJob' }, + { $set: { runNow: true } } +); +``` + +### Configuration Lifecycle + +1. **Initial Configuration** + - Created when job is first discovered + - Contains default values from job implementation + - Stores implementation checksum + +2. **Configuration Updates** + - Applied immediately to running jobs + - Persisted across server restarts + - Validated against job interface + +3. **Implementation Changes** + - Detected via checksum comparison + - Triggers configuration reset + - Maintains enabled/disabled state + +## Parallel Processing + +### Process-Level Parallelism + +Multiple job server processes can run simultaneously, coordinating through MongoDB: + +1. **Process Configuration** +```javascript +node index.js +``` + +All the running processes will look at the jobs collection and start processing the next available job based on +* runtime +* lock status +* priority + +2. **Lock-Based Coordination** + - Jobs are locked when claimed by a process + - Locks expire after `lockLifetime` + - Failed processes release locks automatically + +### Job-Level Concurrency + +Control parallel execution at the job level: + +1. **Global Settings** +```javascript +const config = { + maxConcurrency: 5, // Process-wide limit + defaultConcurrency: 1, // Default per job + lockLimit: 3, // Max locks per process +}; +``` + +2. **Per-Job Settings** +```javascript +class MyJob extends Job { + getConcurrency() { + return 2; // Allow 2 concurrent instances + } + + getLockLifetime() { + return 30 * 60 * 1000; // 30 minute lock + } +} +``` + +3. **Dynamic Adjustment** +```javascript +await db.collection('jobConfigs').updateOne( + { jobName: 'api:myJob' }, + { + $set: { + concurrency: 3, + lockLifetime: 45 * 60 * 1000 + } + } +); +``` + +### Load Distribution + +Jobs are distributed across processes based on: + +1. **Priority** + - Higher priority jobs run first + - Configurable via `getPriority()` + +2. **Process Capacity** + - Respects `maxConcurrency` limits + - Considers current load + +3. **Lock Management** + - Prevents duplicate execution + - Handles failed processes + - Supports job recovery + +## File Structure + +``` +jobServer/ +├── constants/ +│ └── JobPriorities.js # Priority level definitions +├── jobRunner/ +│ ├── interfaces/ # Core interfaces +│ │ ├── IJobExecutor.js +│ │ ├── IJobLifecycle.js +│ │ └── IJobScheduler.js +│ ├── impl/ # Runner implementations +│ │ ├── pulse/ # Pulse runner +│ │ │ ├── PulseJobExecutor.js +│ │ │ ├── PulseJobLifecycle.js +│ │ │ └── PulseJobScheduler.js +│ │ └── bullmq/ # Future BullMQ implementation +│ ├── BaseJobRunner.js # Abstract runner base +│ ├── PulseJobRunner.js # Pulse runner composition +│ └── index.js # Runner factory +├── Job.js # Base job class +├── JobManager.js # Job management +├── JobScanner.js # Job discovery +├── JobServer.js # Main entry point +├── JobUtils.js # Utility functions +└── config.js # Default configurations +``` + +## Interface Contracts + +### IJobExecutor + +Handles job creation and execution control: + +```javascript +class IJobExecutor { + async createJob(jobName, JobClass) {} + async enableJob(jobName) {} + async disableJob(jobName) {} + async configureRetry(jobName, retryConfig) {} +} +``` + +### IJobScheduler + +Manages job scheduling and timing: + +```javascript +class IJobScheduler { + async schedule(name, scheduleConfig, data) {} + async updateSchedule(jobName, schedule) {} + async runJobNow(jobName) {} +} +``` + +### IJobLifecycle + +Controls runner lifecycle: + +```javascript +class IJobLifecycle { + async start(jobClasses) {} + async close() {} +} +``` + +## Implementing New Runners + +### Runner Implementation Steps + +1. **Create Implementation Directory** +```bash +mkdir -p jobRunner/impl/myRunner +``` + +2. **Implement Required Classes** + - MyJobExecutor extends IJobExecutor + - MyJobScheduler extends IJobScheduler + - MyJobLifecycle extends IJobLifecycle + +3. **Create Runner Class** +```javascript +class MyJobRunner extends BaseJobRunner { + constructor(db, config, Logger) { + const executor = new MyJobExecutor(/* ... */); + const scheduler = new MyJobScheduler(/* ... */); + const lifecycle = new MyJobLifecycle(/* ... */); + super(scheduler, executor, lifecycle); + } +} +``` + +4. **Register Runner Type** +```javascript +// jobRunner/index.js +const RUNNER_TYPES = { + PULSE: 'pulse', + MY_RUNNER: 'myRunner' +}; +``` + +### Example: Custom Runner Implementation + +```javascript +// jobRunner/impl/myRunner/MyJobExecutor.js +class MyJobExecutor extends IJobExecutor { + async createJob(jobName, JobClass) { + const instance = new JobClass(); + // Initialize job in your system + await this.runner.define(jobName, { + concurrency: instance.getConcurrency(), + priority: instance.getPriority(), + // ... other settings + }); + } + + async enableJob(jobName) { + await this.runner.enable(jobName); + } + + async disableJob(jobName) { + await this.runner.disable(jobName); + } + + async configureRetry(jobName, retryConfig) { + await this.runner.updateJobSettings(jobName, { + retries: retryConfig.attempts, + backoff: { + type: 'exponential', + delay: retryConfig.delay + } + }); + } +} +``` + +## Error Handling & Monitoring + +### Job Error Handling + +1. **Error Types** +```javascript +class MyJob extends Job { + async run(db, done, progress) { + try { + // Transient errors (will retry) + throw new RetryableError('Database timeout'); + + // Fatal errors (won't retry) + throw new FatalError('Invalid configuration'); + + // Unknown errors (will retry based on config) + throw new Error('Unexpected error'); + } + catch (error) { + done(error); + } + } +} +``` + +2. **Retry Configuration** +```javascript +{ + retry: { + enabled: true, + attempts: 3, + delay: 60000, + backoff: { + type: 'exponential', + factor: 2 + }, + } +} +``` + +### Job Progress Monitoring + +1. **Progress Updates** +```javascript +await progress( + totalItems, // Total items to process + processedItems, // Items processed so far + 'Processing batch 3/10', // Status message +); +``` + +2. **Job Status Queries** +```javascript +const jobStatus = await db.collection('pulseJobs').findOne( + { name: 'api:myJob' }, + { + projection: { + status: 1, + progress: 1, + lastRunAt: 1, + nextRunAt: 1, + failCount: 1, + lastError: 1 + } + } +); +``` + +## Best Practices + +### Job Implementation + +1. **Idempotency** +```javascript +class IdempotentJob extends Job { + async run(db, done) { + const operationId = 'batch_20240101'; + + // Check if already processed + const existing = await db.collection('processed') + .findOne({ operationId }); + + if (existing) { + return done(null, { skipped: true }); + } + + // Process and mark as done + await db.collection('processed') + .insertOne({ + operationId, + processedAt: new Date() + }); + } +} +``` + +2. **Resource Management** +```javascript +class ResourceEfficientJob extends Job { + async run(db, done) { + const cursor = db.collection('large_data') + .find() + .batchSize(1000); + + try { + while (await cursor.hasNext()) { + const doc = await cursor.next(); + await this.processDocument(doc); + } + } + finally { + await cursor.close(); + } + } +} +``` + +## Troubleshooting Guide + +### Common Issues + +1. **Job Not Running** + - Check job configuration in `jobConfigs` collection + - Verify schedule configuration + - Check if job is enabled + - Look for lock conflicts + +2. **Job Failing** + - Check error messages in job document + - Verify retry configuration + - Check resource availability + - Monitor lock timeouts + +3. **Performance Issues** + - Review concurrency settings + - Check database indexes + - Monitor memory usage + - Optimize batch sizes + +### Debugging Tools + +1. **Job Status Check** +```javascript +const status = await db.collection('pulseJobs') + .find( + { name: 'api:myJob' }, + { + sort: { lastRunAt: -1 }, + limit: 1 + } + ).toArray(); +``` + +2. **Lock Investigation** +```javascript +const locks = await db.collection('pulseJobs') + .find({ + lockedAt: { $exists: true }, + lastRunAt: { + $lt: new Date(Date.now() - 30 * 60 * 1000) + } + }).toArray(); +``` + +3. **Configuration Validation** +```javascript +const config = await db.collection('jobConfigs') + .findOne({ jobName: 'api:myJob' }); + +const isValid = JobUtils.validateConfig(config); +``` + +## Monitoring & Metrics + +### System Health Metrics + +1. **Job Statistics** +```javascript +const stats = await db.collection('pulseJobs').aggregate([ + { + $group: { + _id: '$name', + totalRuns: { $sum: 1 }, + failures: { $sum: { $cond: ['$failedAt', 1, 0] } }, + avgDuration: { $avg: '$duration' }, + lastRun: { $max: '$lastRunAt' } + } + } +]).toArray(); +``` + +2. **Process Metrics** +```javascript +const metrics = { + activeJobs: await db.collection('pulseJobs').countDocuments({ + lockedAt: { $exists: true } + }), + pendingJobs: await db.collection('pulseJobs').countDocuments({ + nextRunAt: { $lte: new Date() }, + lockedAt: { $exists: false } + }), + failedJobs: await db.collection('pulseJobs').countDocuments({ + failedAt: { $exists: true } + }) +}; +``` + +### Health Checks + +1. **Lock Health** +```javascript +const staleLocks = await db.collection('pulseJobs').find({ + lockedAt: { + $lt: new Date(Date.now() - 60 * 60 * 1000) // 1 hour + } +}).toArray(); + +if (staleLocks.length > 0) { + // Alert: Stale locks detected +} +``` + +### Alerting Integration + +### Logging Best Practices + +1. **Structured Logging** +```javascript +class LoggingJob extends Job { + async run(db, done) { + this.logger.i('Starting job', { + job: this.jobName, + timestamp: new Date(), + params: this.params + }); + + // Job logic + + this.logger.i('Job completed', { + job: this.jobName, + duration: Date.now() - startTime, + result: result + }); + } +} +``` + +2. **Log Levels** +```javascript +{ + d: 'Detailed debugging information', + i: 'General operational information', + w: 'Warning messages for potentially harmful situations', + e: 'Error events that might still allow the application to continue running' +} +``` + +## Lock Extension & Progress Reporting + +### Lock Extension +Long-running jobs need to periodically extend their locks to prevent expiration and avoid duplicate execution. The job system provides two ways to extend locks: + +1. **Automatic Extension with Progress Updates** +When using the progress reporting function, locks are automatically extended: + +```javascript +class MyLongJob extends Job { + async run(db, done, progress) { + const total = 1000; + for (let i = 0; i < total; i++) { + // Process item... + + // Automatically extends lock when reporting progress + await progress( + total, + i + 1, + `Processing item ${i + 1}/${total}` + ); + } + done(); + } +} +``` + +## BullMQ Implementation Guide + +### Setup Requirements + +1. **Redis Connection** +```javascript +const { Queue, Worker } = require('bullmq'); +const Redis = require('ioredis'); + +const connection = new Redis({ + host: 'localhost', + port: 6379 +}); +``` + +2. **Basic Structure** +``` +jobRunner/impl/bullmq/ +├── BullMQJobExecutor.js +├── BullMQJobLifecycle.js +├── BullMQJobScheduler.js +└── BullMQJobRunner.js +``` + +### Implementation Strategy + +1. **Queue Management** +```javascript +class BullMQJobExecutor extends IJobExecutor { + constructor() { + this.queues = new Map(); + this.workers = new Map(); + } + + async createJob(jobName, JobClass) { + const queue = new Queue(jobName, { connection }); + const worker = new Worker(jobName, async job => { + const instance = new JobClass(); + return instance.run(this.db, job.done, job.progress); + }, { connection }); + + this.queues.set(jobName, queue); + this.workers.set(jobName, worker); + } +} +``` + +2. **Scheduling** +```javascript +class BullMQJobScheduler extends IJobScheduler { + async schedule(name, config, data) { + const queue = this.queues.get(name); + + switch (config.type) { + case 'schedule': + await queue.add(name, data, { + repeat: { cron: config.value } + }); + break; + case 'once': + await queue.add(name, data, { + delay: config.value - Date.now() + }); + break; + case 'now': + await queue.add(name, data); + break; + } + } +} +``` + +3. **Lifecycle Management** +```javascript +class BullMQJobLifecycle extends IJobLifecycle { + async close() { + await Promise.all([ + ...this.queues.values() + ].map(queue => queue.close())); + + await Promise.all([ + ...this.workers.values() + ].map(worker => worker.close())); + } +} +``` From 984016c8a56ac3d0d03e622cc35ea918c54ce18e Mon Sep 17 00:00:00 2001 From: Kanwar Ujjaval Singh <4216199+kanwarujjaval@users.noreply.github.com> Date: Wed, 15 Jan 2025 18:19:09 +0530 Subject: [PATCH 21/90] fix docs --- jobServer/README.md | 115 ++++++++++++++++---------------------------- 1 file changed, 42 insertions(+), 73 deletions(-) diff --git a/jobServer/README.md b/jobServer/README.md index 8118adc6d0f..122615509cb 100644 --- a/jobServer/README.md +++ b/jobServer/README.md @@ -5,20 +5,24 @@ A flexible, extensible job scheduling and execution system built on MongoDB with ## Table of Contents 1. [Overview](#overview) -2. [Architecture](#architecture) -3. [Dependencies](#dependencies) -4. [Installation](#installation) +2. [Dependencies](#dependencies) +3. [Installation](#installation) +4. [Collections](#collections) 5. [Basic Usage](#basic-usage) -6. [Collections](#collections) +6. [Architecture](#architecture) 7. [Server Configuration](#server-configuration) 8. [Job Implementation Guide](#job-implementation-guide) -9. [Job Configuration Management](#job-configuration-management) -10. [Parallel Processing](#parallel-processing) -11. [Class Reference](#class-reference) +9. [Lock Extension & Progress Reporting](#lock-extension--progress-reporting) +10. [Job Configuration Management](#job-configuration-management) +11. [Parallel Processing](#parallel-processing) 12. [File Structure](#file-structure) 13. [Interface Contracts](#interface-contracts) 14. [Implementing New Runners](#implementing-new-runners) -15. [BullMQ Implementation Guide](#bullmq-implementation-guide) +15. [Error Handling & Monitoring](#error-handling--monitoring) +16. [Best Practices](#best-practices) +17. [Troubleshooting Guide](#troubleshooting-guide) +18. [Monitoring & Metrics](#monitoring--metrics) +19. [BullMQ Implementation Guide](#bullmq-implementation-guide) ## Overview @@ -40,10 +44,10 @@ The Job Server Module provides a robust framework for scheduling and executing b Core dependencies: ```json { - "@pulsecron/pulse": "^1.0.0", + "@pulsecron/pulse": "1.6.7", "cron-validator": "^1.3.1", - "@breejs/later": "^4.1.0", // should be removed soon - "mongodb": "^4.0.0" + "@breejs/later": "^4.2.0", // should be removed soon + "mongodb": "6.11.0" } ``` @@ -306,6 +310,33 @@ class DataProcessingJob extends Job { } ``` +## Lock Extension & Progress Reporting + +### Lock Extension +Long-running jobs need to periodically extend their locks to prevent expiration and avoid duplicate execution. The job system provides two ways to extend locks: + +1. **Automatic Extension with Progress Updates** + When using the progress reporting function, locks are automatically extended: + +```javascript +class MyLongJob extends Job { + async run(db, done, progress) { + const total = 1000; + for (let i = 0; i < total; i++) { + // Process item... + + // Automatically extends lock when reporting progress + await progress( + total, + i + 1, + `Processing item ${i + 1}/${total}` + ); + } + done(); + } +} +``` + ## Job Configuration Management ### Dynamic Configuration via MongoDB @@ -556,41 +587,6 @@ const RUNNER_TYPES = { }; ``` -### Example: Custom Runner Implementation - -```javascript -// jobRunner/impl/myRunner/MyJobExecutor.js -class MyJobExecutor extends IJobExecutor { - async createJob(jobName, JobClass) { - const instance = new JobClass(); - // Initialize job in your system - await this.runner.define(jobName, { - concurrency: instance.getConcurrency(), - priority: instance.getPriority(), - // ... other settings - }); - } - - async enableJob(jobName) { - await this.runner.enable(jobName); - } - - async disableJob(jobName) { - await this.runner.disable(jobName); - } - - async configureRetry(jobName, retryConfig) { - await this.runner.updateJobSettings(jobName, { - retries: retryConfig.attempts, - backoff: { - type: 'exponential', - delay: retryConfig.delay - } - }); - } -} -``` - ## Error Handling & Monitoring ### Job Error Handling @@ -848,33 +844,6 @@ class LoggingJob extends Job { } ``` -## Lock Extension & Progress Reporting - -### Lock Extension -Long-running jobs need to periodically extend their locks to prevent expiration and avoid duplicate execution. The job system provides two ways to extend locks: - -1. **Automatic Extension with Progress Updates** -When using the progress reporting function, locks are automatically extended: - -```javascript -class MyLongJob extends Job { - async run(db, done, progress) { - const total = 1000; - for (let i = 0; i < total; i++) { - // Process item... - - // Automatically extends lock when reporting progress - await progress( - total, - i + 1, - `Processing item ${i + 1}/${total}` - ); - } - done(); - } -} -``` - ## BullMQ Implementation Guide ### Setup Requirements From 5acfa2f6c22b57dc813bfd9a31d65fc384dde93c Mon Sep 17 00:00:00 2001 From: Kanwar Ujjaval Singh <4216199+kanwarujjaval@users.noreply.github.com> Date: Wed, 15 Jan 2025 18:55:47 +0530 Subject: [PATCH 22/90] remove new logger file :D --- api/utils/log.v2.js | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 api/utils/log.v2.js diff --git a/api/utils/log.v2.js b/api/utils/log.v2.js deleted file mode 100644 index e69de29bb2d..00000000000 From baf399fc150bc70f7128457edfab141fc3fd361f Mon Sep 17 00:00:00 2001 From: Kanwar Ujjaval Singh <4216199+kanwarujjaval@users.noreply.github.com> Date: Wed, 15 Jan 2025 19:27:32 +0530 Subject: [PATCH 23/90] progress with bookmark data --- jobServer/Job.js | 2 +- jobServer/jobRunner/impl/pulse/PulseJobExecutor.js | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/jobServer/Job.js b/jobServer/Job.js index 6652d954c38..3119717731b 100644 --- a/jobServer/Job.js +++ b/jobServer/Job.js @@ -268,7 +268,7 @@ class Job { // Touch to prevent lock expiration if (this.#touchMethod) { - await this.#touchMethod(); + await this.#touchMethod(progress); } this.logger?.d(`Progress reported for job "${this.jobName}":`, progressData); diff --git a/jobServer/jobRunner/impl/pulse/PulseJobExecutor.js b/jobServer/jobRunner/impl/pulse/PulseJobExecutor.js index 1016800817e..f0d5940e6bf 100644 --- a/jobServer/jobRunner/impl/pulse/PulseJobExecutor.js +++ b/jobServer/jobRunner/impl/pulse/PulseJobExecutor.js @@ -151,7 +151,10 @@ class PulseJobExecutor extends IJobExecutor { */ async #updateJobProgress(job, progressData) { try { - job.data = progressData; + job.attrs.data = { + ...job.attrs.data, + progressData + }; await job.save(); } catch (error) { From 3c38282d979e02f3b7a512476ba93157db754a74 Mon Sep 17 00:00:00 2001 From: Kanwar Ujjaval Singh <4216199+kanwarujjaval@users.noreply.github.com> Date: Wed, 15 Jan 2025 23:33:18 +0530 Subject: [PATCH 24/90] so many tiny docs and logs --- jobServer/Job.js | 55 +++- jobServer/JobManager.js | 81 +++-- jobServer/JobScanner.js | 50 +++- jobServer/JobServer.js | 95 +++--- jobServer/config.js | 35 ++- jobServer/constants/JobPriorities.js | 39 ++- jobServer/index.js | 92 +++--- jobServer/jobRunner/BaseJobRunner.js | 46 ++- jobServer/jobRunner/IJobRunner.js | 145 --------- jobServer/jobRunner/JobRunnerBullImpl.js | 95 ------ jobServer/jobRunner/JobRunnerPulseImpl.js | 280 ------------------ jobServer/jobRunner/PulseJobRunner.js | 48 ++- .../jobRunner/impl/pulse/PulseJobExecutor.js | 54 +++- .../jobRunner/impl/pulse/PulseJobLifecycle.js | 117 ++++++-- .../jobRunner/impl/pulse/PulseJobScheduler.js | 86 ++++-- jobServer/jobRunner/index.js | 61 +++- .../jobRunner/interfaces/IJobExecutor.js | 40 ++- .../jobRunner/interfaces/IJobLifecycle.js | 21 +- .../jobRunner/interfaces/IJobScheduler.js | 38 ++- 19 files changed, 708 insertions(+), 770 deletions(-) delete mode 100644 jobServer/jobRunner/IJobRunner.js delete mode 100644 jobServer/jobRunner/JobRunnerBullImpl.js delete mode 100644 jobServer/jobRunner/JobRunnerPulseImpl.js diff --git a/jobServer/Job.js b/jobServer/Job.js index 3119717731b..1410965b1ae 100644 --- a/jobServer/Job.js +++ b/jobServer/Job.js @@ -1,6 +1,29 @@ const defaultLogger = { d: console.debug, w: console.warn, e: console.error, i: console.info }; const { JOB_PRIORITIES } = require('./constants/JobPriorities'); +/** + * @typedef {Object} Logger + * @property {Function} d - Debug logging function + * @property {Function} w - Warning logging function + * @property {Function} e - Error logging function + * @property {Function} i - Info logging function + */ + +/** + * @typedef {Object} ProgressData + * @property {number} [total] - Total number of items to process + * @property {number} [current] - Current number of items processed + * @property {string} [bookmark] - Current processing stage description + * @property {number} [percent] - Progress percentage (0-100) + * @property {Date} timestamp - When the progress was reported + */ + +/** + * @typedef {Object} ScheduleConfig + * @property {('once'|'schedule'|'now')} type - Type of schedule + * @property {(string|Date)} [value] - Schedule value (cron expression or Date) + */ + /** * Base class for creating jobs. * @@ -59,15 +82,16 @@ class Job { /** @type {string} Name of the job */ jobName = Job.name; - /** @type {Object} Logger instance */ + /** @type {Logger} Logger instance */ logger; /** @type {Function|null} Touch method from job runner */ #touchMethod = null; /** @type {Function|null} Progress method from job runner */ - #progressMethod; + #progressMethod = null; + /** @type {Object.} Available job priorities */ priorities = JOB_PRIORITIES; /** @@ -178,14 +202,18 @@ class Job { /** * Internal method to run the job and handle both Promise and callback patterns. - * @param {Db} db The database connection + * @param {MongoDb} db The MongoDB database connection * @param {Object} job The job instance * @param {Function} done Callback to be called when job completes * @returns {Promise} A promise that resolves once the job is completed * @private */ async _run(db, job, done) { - this.logger.d(`Job "${this.jobName}" is starting with database:`, db?._cly_debug?.db); + this.logger.d(`[Job:${this.jobName}] Starting execution`, { + database: db?._cly_debug?.db, + jobId: job?.attrs._id, + jobName: this.jobName + }); try { const result = await new Promise((resolve, reject) => { @@ -215,11 +243,18 @@ class Job { } }); - this.logger.i(`Job "${this.jobName}" completed successfully:`, result || ''); + this.logger.i(`[Job:${this.jobName}] Completed successfully`, { + result: result || null, + duration: `${Date.now() - job?.attrs?.lastRunAt?.getTime()}ms` + }); done(null, result); } catch (error) { - this.logger.e(`Job "${this.jobName}" encountered an error during execution:`, error); + this.logger.e(`[Job:${this.jobName}] Execution failed`, { + error: error.message, + stack: error.stack, + duration: `${Date.now() - job?.attrs?.lastRunAt?.getTime()}ms` + }); done(error); } } @@ -247,12 +282,12 @@ class Job { * @param {number} [total] Total number of stages * @param {number} [current] Current stage number * @param {string} [bookmark] Bookmark string for current stage + * @returns {Promise} A promise that resolves once the progress is reported */ async reportProgress(total, current, bookmark) { - // Calculate progress percentage const progress = total && current ? Math.min(100, Math.floor((current / total) * 100)) : undefined; - // Build progress data + /** @type {ProgressData} */ const progressData = { total, current, @@ -261,17 +296,15 @@ class Job { timestamp: new Date() }; - // Update progress using runner's method if available if (this.#progressMethod) { await this.#progressMethod(progressData); } - // Touch to prevent lock expiration if (this.#touchMethod) { await this.#touchMethod(progress); } - this.logger?.d(`Progress reported for job "${this.jobName}":`, progressData); + this.logger?.d(`[Job:${this.jobName}] Progress update`, progressData); } /** diff --git a/jobServer/JobManager.js b/jobServer/JobManager.js index 8e2104b9229..1f3c675975e 100644 --- a/jobServer/JobManager.js +++ b/jobServer/JobManager.js @@ -3,36 +3,50 @@ const config = require("./config"); const JobUtils = require('./JobUtils'); /** - * Manages job configurations and initialization. + * @typedef {import('../api/utils/log.js').Logger} Logger + * @typedef {import('mongodb').Db} MongoDb + * @typedef {import('./JobRunner/types').IJobRunner} IJobRunner + * @typedef {import('./JobRunner/PulseImpl').JobRunnerPulseImpl} JobRunnerPulseImpl + * + * @typedef {Object} JobConfig + * @property {string} jobName - The unique identifier for the job + * @property {boolean} [enabled] - Whether the job is enabled + * @property {boolean} [runNow] - Whether to run the job immediately + * @property {Object} [schedule] - Cron or interval-based schedule configuration + * @property {Object} [retry] - Retry strategy configuration + * @property {Date} createdAt - When the config was first created + * @property {Date} [updatedAt] - When the config was last updated + * @property {string} checksum - Hash of the job implementation + * @property {Object} defaultConfig - Original job configuration + */ + +/** + * Manages job configurations, scheduling, and lifecycle. + * Provides functionality to: + * - Load and initialize jobs from class definitions + * - Track and apply configuration changes + * - Enable/disable jobs + * - Handle job implementation updates */ class JobManager { - /** - * The logger instance - * @private - * @type {import('../api/utils/log.js').Logger} - * */ + /** @type {Logger} */ #log; - /** - * The database connection - * @type {import('mongodb').Db | null} - */ + /** @type {MongoDb} */ #db = null; - /** - * The job runner instance - * @type {IJobRunner | JobRunnerPulseImpl |null} - */ + /** @type {IJobRunner | JobRunnerPulseImpl | null} */ #jobRunner = null; - + /** @type {import('mongodb').Collection} */ #jobConfigsCollection; /** * Creates a new JobManager instance - * @param {Object} db Database connection - * @param {function} Logger - Logger constructor + * @param {MongoDb} db - MongoDB database connection + * @param {function(string): Logger} Logger - Logger factory function + * @throws {Error} If database connection is invalid */ constructor(db, Logger) { this.Logger = Logger; @@ -49,14 +63,20 @@ class JobManager { } /** - * Watches for changes in job configurations + * Watches for changes in job configurations and applies them in real-time * @private * @returns {Promise} A promise that resolves once the watcher is started + * @throws {Error} If watch stream cannot be established */ async #watchConfigs() { + this.#log.d('Initializing config change stream watcher'); const changeStream = this.#jobConfigsCollection.watch(); changeStream.on('change', async(change) => { + this.#log.d('Detected config change:', { + operationType: change.operationType, + jobName: change.fullDocument?.jobName + }); if (change.operationType === 'update' || change.operationType === 'insert') { const jobConfig = change.fullDocument; await this.#applyConfig(jobConfig); @@ -67,13 +87,8 @@ class JobManager { /** * Applies job configuration changes * @private - * @param {Object} jobConfig The job configuration to apply - * @param {string} jobConfig.jobName The name of the job - * @param {boolean} [jobConfig.runNow] Whether to run the job immediately - * @param {Object} [jobConfig.schedule] Schedule configuration - * @param {Object} [jobConfig.retry] Retry configuration - * @param {boolean} [jobConfig.enabled] Whether the job is enabled - * @returns {Promise} A promise that resolves once the configuration is applied + * @param {JobConfig} jobConfig The job configuration to apply + * @throws {Error} If job runner is not initialized or configuration is invalid */ async #applyConfig(jobConfig) { try { @@ -82,6 +97,15 @@ class JobManager { } const { jobName } = jobConfig; + this.#log.d('Applying config changes for job:', { + jobName, + changes: { + runNow: jobConfig.runNow, + scheduleUpdated: !!jobConfig.schedule, + retryUpdated: !!jobConfig.retry, + enabledStateChanged: typeof jobConfig.enabled === 'boolean' + } + }); if (jobConfig.runNow === true) { await this.#jobRunner.runJobNow(jobName); @@ -111,7 +135,12 @@ class JobManager { } } catch (error) { - this.#log.e('Error applying job configuration:', error); + this.#log.e('Failed to apply job configuration:', { + jobName: jobConfig.jobName, + error: error.message, + stack: error.stack + }); + throw error; // Re-throw to allow caller to handle } } diff --git a/jobServer/JobScanner.js b/jobServer/JobScanner.js index 374172438fa..616dc988375 100644 --- a/jobServer/JobScanner.js +++ b/jobServer/JobScanner.js @@ -11,6 +11,7 @@ const JobUtils = require('./JobUtils'); * @typedef {Object} JobConfig * @property {string} category - Category of the job (e.g., 'api' or plugin name) * @property {string} dir - Directory path containing job files + * @property {boolean} [required=false] - Whether this job directory must exist */ /** @@ -18,12 +19,16 @@ const JobUtils = require('./JobUtils'); * @property {string} category - Category of the job * @property {string} name - Name of the job file (without extension) * @property {string} file - Full path to the job file + * @property {Date} lastModified - Last modification timestamp of the job file */ /** * @typedef {Object} ScanResult * @property {Object.} files - Object storing job file paths, keyed by job name * @property {Object.} classes - Object storing job class implementations, keyed by job name + * @property {number} totalScanned - Total number of files scanned + * @property {number} successfullyLoaded - Number of successfully loaded jobs + * @property {string[]} errors - Array of error messages from failed loads */ /** @@ -97,27 +102,37 @@ class JobScanner { const files = await fs.readdir(jobConfig.dir); const jobFiles = []; + this.#log.d(`Scanning directory ${jobConfig.dir} for jobs`); + for (const file of files) { const fullPath = path.join(jobConfig.dir, file); try { const stats = await fs.stat(fullPath); - if (stats.isFile()) { + if (stats.isFile() && file.endsWith('.js')) { jobFiles.push({ category: jobConfig.category, name: path.basename(file, '.js'), - file: fullPath + file: fullPath, + lastModified: stats.mtime }); + this.#log.d(`Found job file: ${file}`); } } catch (err) { - this.#log.w(`Failed to stat file ${fullPath}: ${err.message}`); + this.#log.w(`Failed to stat file ${fullPath}:`, err); } } + this.#log.i(`Found ${jobFiles.length} job files in ${jobConfig.dir}`); return jobFiles; } catch (err) { - this.#log.w(`Failed to read directory ${jobConfig.dir}: ${err.message}`); + const message = `Failed to read directory ${jobConfig.dir}: ${err.message}`; + if (jobConfig.required) { + this.#log.e(message, err); + throw new Error(message); + } + this.#log.w(message); return []; } } @@ -197,7 +212,8 @@ class JobScanner { * @throws {Error} If plugin configuration is invalid or missing */ async scan() { - // Initialize plugin manager + this.#log.i('Starting job scan...'); + await this.#initializeConfig(); const plugins = this.#pluginManager.getPlugins(true); @@ -205,11 +221,16 @@ class JobScanner { throw new Error('No valid plugins.json configuration found'); } - this.#log.i('Scanning plugins:', plugins); + this.#log.i(`Found ${plugins.length} plugins to scan:`, plugins); // Reset current collections this.#currentFiles = {}; this.#currentClasses = {}; + const scanStats = { + totalScanned: 0, + successfullyLoaded: 0, + errors: [] + }; // Build list of directories to scan const jobDirs = this.#getJobDirectories(plugins); @@ -223,13 +244,26 @@ class JobScanner { jobFiles.flat() .filter(Boolean) .forEach(job => { + scanStats.totalScanned++; const loaded = this.#loadJobFile(job); - this.#storeLoadedJob(loaded); + if (loaded) { + scanStats.successfullyLoaded++; + this.#storeLoadedJob(loaded); + } + else { + scanStats.errors.push(`Failed to load job: ${job.file}`); + } }); + this.#log.i(`Job scan complete. Loaded ${scanStats.successfullyLoaded}/${scanStats.totalScanned} jobs`); + if (scanStats.errors.length) { + this.#log.w(`Failed to load ${scanStats.errors.length} jobs`); + } + return { files: this.#currentFiles, - classes: this.#currentClasses + classes: this.#currentClasses, + ...scanStats }; } diff --git a/jobServer/JobServer.js b/jobServer/JobServer.js index 5153fc1e2e6..e73415c2838 100644 --- a/jobServer/JobServer.js +++ b/jobServer/JobServer.js @@ -1,23 +1,33 @@ +/** + * @typedef {import('../api/utils/log.js').Logger} Logger + * @typedef {import('../plugins/pluginManager.js')} PluginManager + * @typedef {import('mongodb').Db} MongoDb + * @typedef {import('mongodb').Collection} MongoCollection + * @typedef {import('./JobManager')} JobManager + * @typedef {import('./JobScanner')} JobScanner + */ + const JobManager = require('./JobManager'); const JobScanner = require('./JobScanner'); const JOBS_CONFIG_COLLECTION = 'jobConfigs'; /** - * Class representing a job process. + * Class representing a job server that manages background job processing. + * Handles job scheduling, execution, and lifecycle management. */ class JobServer { /** * The logger instance * @private - * @type {import('../api/utils/log.js').Logger} + * @type {Logger} * */ #log; /** * The plugin manager instance * @private - * @type {import('../plugins/pluginManager.js')} + * @type {PluginManager} */ #pluginManager; @@ -50,14 +60,14 @@ class JobServer { /** * The database connection - * @type {import('mongodb').Db | null} + * @type {MongoDb | null} */ #db = null; /** * Collection for job configurations * @private - * @type {import('mongodb').Collection} + * @type {MongoCollection} */ #jobConfigsCollection; @@ -69,11 +79,12 @@ class JobServer { #isShuttingDown = false; /** - * Creates a new JobProcess instance. - * @param {Object} common Countly common - * @param {function} Logger - Logger constructor - * @param {pluginManager} pluginManager - Plugin manager instance - * @returns {Promise} A promise that resolves to a new JobProcess instance. + * Factory method to create and initialize a new JobServer instance. + * @param {Object} common - Countly common utilities + * @param {Logger} Logger - Logger constructor + * @param {PluginManager} pluginManager - Plugin manager instance + * @returns {Promise} A fully initialized JobServer instance + * @throws {Error} If initialization fails */ static async create(common, Logger, pluginManager) { const process = new JobServer(common, Logger, pluginManager); @@ -95,11 +106,14 @@ class JobServer { } /** - * init the job process. - * @returns {Promise} A promise that resolves once the job process is initialized. + * Initializes the job server by establishing database connections, + * setting up components, and configuring signal handlers. + * @returns {Promise} A promise that resolves once the job server is initialized + * @throws {Error} If initialization fails */ async init() { try { + this.#log.d('Initializing job server...'); await this.#connectToDb(); this.#jobManager = new JobManager(this.#db, this.Logger); @@ -110,37 +124,37 @@ class JobServer { this.#setupSignalHandlers(); - this.#log.i('Job process init successfully'); + this.#log.i('Job server initialized successfully'); } catch (error) { - this.#log.e('Failed to initialize job process:', error); + this.#log.e('Failed to initialize job server: %j', error); throw error; } } /** - * Starts the job process. - * @returns {Promise} A promise that resolves once the job process is started. + * Starts the job processing server. + * @returns {Promise} A promise that resolves once the job server is started + * @throws {Error} If startup fails */ async start() { if (this.#isRunning) { - this.#log.w('Process is already running'); + this.#log.w('Start requested but server is already running'); return; } try { this.#isRunning = true; - this.#log.i('Starting job process'); + this.#log.i('Starting job server...'); - // Load job classes const { classes } = await this.#jobScanner.scan(); - // Start job manager + this.#log.d('Discovered %d job classes', Object.keys(classes).length); await this.#jobManager.start(classes); - this.#log.i('Job process is running'); + this.#log.i('Job server is now running and processing jobs'); } catch (error) { - this.#log.e('Error starting job process:', error); + this.#log.e('Critical error during server startup: %j', error); await this.#shutdown(1); } } @@ -160,52 +174,63 @@ class JobServer { } /** - * Sets up signal handlers for graceful shutdown and uncaught exceptions. + * Sets up process signal handlers for graceful shutdown and error handling. + * @private */ #setupSignalHandlers() { - // Handle graceful shutdown - process.on('SIGTERM', () => this.#shutdown()); - process.on('SIGINT', () => this.#shutdown()); + process.on('SIGTERM', () => { + this.#log.i('Received SIGTERM signal'); + this.#shutdown(); + }); + + process.on('SIGINT', () => { + this.#log.i('Received SIGINT signal'); + this.#shutdown(); + }); - // Handle uncaught errors process.on('uncaughtException', (error) => { - this.#log.e('Uncaught exception:', error); + this.#log.e('Uncaught exception in job server: %j', error); this.#shutdown(1); }); } /** - * Shuts down the job process. - * @param {number} [exitCode=0] - The exit code to use when shutting down the process. - * @returns {Promise} A promise that resolves once the job process is shut down. + * Gracefully shuts down the job server, closing connections and stopping jobs. + * @private + * @param {number} [exitCode=0] - Process exit code + * @returns {Promise} A promise that resolves once the job server is shut down */ async #shutdown(exitCode = 0) { if (this.#isShuttingDown) { + this.#log.d('Shutdown already in progress, skipping duplicate request'); return; } this.#isShuttingDown = true; + this.#log.i('Initiating job server shutdown...'); + if (this.#db && typeof this.#db.close === 'function') { + this.#log.d('Closing database connection'); await this.#db.close(); } if (!this.#isRunning) { - this.#log.w('Shutdown called but process is not running'); + this.#log.w('Shutdown called but server was not running'); process.exit(exitCode); return; } - this.#log.i('Shutting down job process...'); this.#isRunning = false; try { if (this.#jobManager) { + this.#log.d('Stopping job manager'); await this.#jobManager.close(); } - this.#log.i('Job process shutdown complete'); + this.#log.i('Job server shutdown completed successfully'); } catch (error) { - this.#log.e('Error during shutdown:', error); + this.#log.e('Error during shutdown: %j', error); exitCode = 1; } finally { diff --git a/jobServer/config.js b/jobServer/config.js index fea20aa24c8..9941659c934 100644 --- a/jobServer/config.js +++ b/jobServer/config.js @@ -1,20 +1,31 @@ /** * Default configuration for Pulse jobs - * @type {import('@pulsecron/pulse').PulseConfig} + * @typedef {Object} PulseConfig + * @property {string} [name] - Name of the Pulse instance for identification + * @property {string} processEvery - Frequency to check for new jobs (e.g., '3 seconds', '1 minute') + * @property {number} maxConcurrency - Maximum number of jobs that can run concurrently across all job types + * @property {number} defaultConcurrency - Default concurrent jobs limit for each job type + * @property {number} lockLimit - Maximum number of jobs that can be locked globally + * @property {number} defaultLockLimit - Default lock limit for each job type + * @property {number} defaultLockLifetime - Time in milliseconds before a job's lock expires + * @property {{nextRunAt: 1|-1, priority: 1|-1}} sort - Job execution sorting criteria + * @property {boolean} disableAutoIndex - Whether to disable automatic MongoDB index creation + * @property {boolean} resumeOnRestart - Whether to resume pending jobs on service restart + * @property {Object} db - Database configuration options + * @property {string} db.collection - MongoDB collection name for storing jobs */ const DEFAULT_PULSE_CONFIG = { - // name: 'core', // Name of the Pulse instance - processEvery: '3 seconds', // Frequency to check for new jobs - maxConcurrency: 1, // Maximum number of jobs that can run concurrently - defaultConcurrency: 1, // Default number of jobs that can run concurrently - lockLimit: 1, // Maximum number of jobs that can be locked at the same time - defaultLockLimit: 1, // Default number of jobs that can be locked at the same time - defaultLockLifetime: 55 * 60 * 1000, // 55 minutes, time in milliseconds for how long a job should be locked - sort: { nextRunAt: 1, priority: -1 }, // Sorting order for job execution - disableAutoIndex: false, // Whether to disable automatic index creation - resumeOnRestart: true, // Whether to resume jobs on restart + processEvery: '3 seconds', + maxConcurrency: 1, + defaultConcurrency: 1, + lockLimit: 1, + defaultLockLimit: 1, + defaultLockLifetime: 55 * 60 * 1000, // 55 minutes + sort: { nextRunAt: 1, priority: -1 }, + disableAutoIndex: false, + resumeOnRestart: true, db: { - collection: 'pulseJobs', // MongoDB collection to store jobs + collection: 'pulseJobs', } }; diff --git a/jobServer/constants/JobPriorities.js b/jobServer/constants/JobPriorities.js index dad4b34cd8b..f910b844db9 100644 --- a/jobServer/constants/JobPriorities.js +++ b/jobServer/constants/JobPriorities.js @@ -1,24 +1,49 @@ /** - * Job priority levels - * @enum {string} + * Defines the priority levels for job processing + * @readonly + * @enum {PriorityLevel} */ + +/** + * @typedef {'lowest' | 'low' | 'normal' | 'high' | 'highest'} PriorityLevel + * @description Represents the available priority levels for jobs + */ + const JOB_PRIORITIES = { - /** Lowest priority level */ + /** + * Lowest priority - Use for background tasks that can be delayed + * @type {PriorityLevel} + */ LOWEST: 'lowest', - /** Low priority level */ + /** + * Low priority - Use for non-time-critical operations + * @type {PriorityLevel} + */ LOW: 'low', - /** Normal/default priority level */ + /** + * Normal priority - Default priority level for most jobs + * @type {PriorityLevel} + */ NORMAL: 'normal', - /** High priority level */ + /** + * High priority - Use for time-sensitive operations + * @type {PriorityLevel} + */ HIGH: 'high', - /** Highest priority level */ + /** + * Highest priority - Use for critical operations that need immediate processing + * @type {PriorityLevel} + */ HIGHEST: 'highest' }; +// Freeze the object to prevent modifications +Object.freeze(JOB_PRIORITIES); + module.exports = { JOB_PRIORITIES }; \ No newline at end of file diff --git a/jobServer/index.js b/jobServer/index.js index c551753bed4..c942a38f769 100644 --- a/jobServer/index.js +++ b/jobServer/index.js @@ -2,43 +2,39 @@ * @module jobServer * @version 2.0 * @author Countly - * + * + * @typedef {import('../api/utils/common.js')} Common + * @typedef {import('../plugins/pluginManager.js')} PluginManager + * @typedef {import('./Job')} JobType + * @typedef {import('./JobServer')} JobServerType + * * @note - * Dependencies like common utilities and plugin manager should only be imported in this entry file - * and injected into the respective modules via their constructors or create methods. - * + * Dependencies like common utilities and plugin manager should only be imported in this entry file + * and injected into the respective modules via their constructors or create methods. + * * @description - * This module provides the job management system for countly. - * It handles job scheduling, execution, and management through a flexible API. - * - * - * @property {typeof import('./Job')} Job - Class for creating and managing individual jobs - * @property {typeof import('./JobServer')} JobServer - Class for running jobs in a separate process - * - * @throws {Error} When database connection fails during initialization - * @throws {Error} When job definition is invalid - * - * @requires './Job' - * @requires './JobServer' - * - * @external Common - * @see {@import ../api/utils/common.js|common} - * - * @external PluginManager - * @see {@import ../plugins/pluginManager.js|PluginManager} - * - * @execution - * This module can be run directly as a standalone process: - * - * ```bash - * node index.js - * ``` - * When run directly, it will: - * 1. Create a new JobServer instance - * 2. Initialize it with the common utilities and plugin manager - * 3. Start the job processing - * 4. Handle process signals (SIGTERM, SIGINT) for graceful shutdown - * + * This module serves as the entry point for Countly's job management system. + * It provides a robust infrastructure for: + * - Scheduling and executing background tasks + * - Managing job lifecycles and states + * - Handling job dependencies and priorities + * - Providing process isolation for job execution + * + * @exports {Object} module.exports + * @property {JobType} Job - Class for creating and managing individual jobs + * @property {JobServerType} JobServer - Class for running jobs in a separate process + * + * @throws {Error} DatabaseConnectionError When database connection fails during initialization + * @throws {Error} InvalidJobError When job definition is invalid + * + * @example + * // Import and create a new job + * const { Job } = require('./jobs'); + * const job = new Job({ + * name: 'example', + * type: 'report', + * schedule: '0 0 * * *' + * }); */ const JobServer = require('./JobServer'); @@ -46,17 +42,37 @@ const Job = require('./Job'); // Start the process if this file is run directly if (require.main === module) { - const common = require('../api/utils/common.js'); const pluginManager = require('../plugins/pluginManager.js'); const Logger = common.log; + const log = Logger('jobs:server'); + + log.i('Initializing job server process...'); JobServer.create(common, Logger, pluginManager) - .then(process => process.start()) + .then(process => { + log.i('Job server successfully created, starting process...'); + return process.start(); + }) .catch(error => { - console.error('Failed to start job process:', error); + log.e('Critical error during job server initialization:', { + error: error.message, + stack: error.stack + }); process.exit(1); }); + + // Handle process signals for graceful shutdown + ['SIGTERM', 'SIGINT'].forEach(signal => { + process.on(signal, () => { + log.i(`Received ${signal}, initiating graceful shutdown...`); + // Allow time for cleanup before force exit + setTimeout(() => { + log.e('Forced shutdown after timeout'); + process.exit(1); + }, 10000); + }); + }); } module.exports = { diff --git a/jobServer/jobRunner/BaseJobRunner.js b/jobServer/jobRunner/BaseJobRunner.js index 84af66dcdb2..083f9beeac4 100644 --- a/jobServer/jobRunner/BaseJobRunner.js +++ b/jobServer/jobRunner/BaseJobRunner.js @@ -1,15 +1,35 @@ +/** + * @typedef {import('./interfaces/IJobScheduler')} IJobScheduler + * @typedef {import('./interfaces/IJobExecutor')} IJobExecutor + * @typedef {import('./interfaces/IJobLifecycle')} IJobLifecycle + * + * @typedef {Object} ScheduleConfig + * @property {string} [cron] - Cron expression for scheduled execution + * @property {number} [interval] - Interval in milliseconds between executions + * @property {Date} [startDate] - Date when the job should start + * @property {Date} [endDate] - Date when the job should end + * + * @typedef {Object} RetryConfig + * @property {number} attempts - Maximum number of retry attempts + * @property {number} backoff - Delay between retries in milliseconds + * @property {('exponential'|'linear'|'fixed')} strategy - Retry backoff strategy + */ + const IJobScheduler = require('./interfaces/IJobScheduler'); const IJobExecutor = require('./interfaces/IJobExecutor'); const IJobLifecycle = require('./interfaces/IJobLifecycle'); /** - * Base class for job runners that implements all interfaces through composition + * Base class for job runners that implements scheduling, execution, and lifecycle management + * through composition. Provides a unified interface for job management operations. */ class BaseJobRunner { /** - * @param {IJobScheduler} scheduler - Scheduler implementation - * @param {IJobExecutor} executor - Executor implementation - * @param {IJobLifecycle} lifecycle - Lifecycle implementation + * Creates a new BaseJobRunner instance + * @param {IJobScheduler} scheduler - Handles job scheduling and timing + * @param {IJobExecutor} executor - Manages job execution and retry logic + * @param {IJobLifecycle} lifecycle - Controls runner lifecycle and resource management + * @throws {Error} If any of the implementations are invalid */ constructor(scheduler, executor, lifecycle) { if (!(scheduler instanceof IJobScheduler)) { @@ -28,11 +48,12 @@ class BaseJobRunner { } /** - * Schedules a job to run - * @param {string} name Job name - * @param {Object} scheduleConfig Schedule configuration - * @param {Object} [data] Optional data to pass to the job - * @returns {Promise} A promise that resolves once the job is scheduled + * Schedules a job for execution based on the provided configuration + * @param {string} name - Unique identifier for the job + * @param {ScheduleConfig} scheduleConfig - Configuration for when the job should run + * @param {Object} [data] - Optional data passed to the job during execution + * @returns {Promise} Resolves when scheduling is complete + * @throws {Error} If scheduling fails or job doesn't exist */ async schedule(name, scheduleConfig, data) { return this.scheduler.schedule(name, scheduleConfig, data); @@ -96,9 +117,10 @@ class BaseJobRunner { } /** - * Starts the job runner - * @param {Object.} jobClasses Job classes to register - * @returns {Promise} A promise that resolves once the job runner is started + * Initializes and starts the job runner with the provided job implementations + * @param {Object.} jobClasses - Map of job names to their implementing classes + * @returns {Promise} Resolves when all jobs are registered and the runner is ready + * @throws {Error} If initialization fails or invalid job classes are provided */ async start(jobClasses) { return this.lifecycle.start(jobClasses); diff --git a/jobServer/jobRunner/IJobRunner.js b/jobServer/jobRunner/IJobRunner.js deleted file mode 100644 index 6bf89da7863..00000000000 --- a/jobServer/jobRunner/IJobRunner.js +++ /dev/null @@ -1,145 +0,0 @@ -/** - * Interface for job runner implementations - */ -class IJobRunner { - - db; - - config; - - log; - - /** - *@param {Object} db Database connection - * @param {Object} config Configuration - * @param {function} Logger - Logger constructor - */ - constructor(db, config, Logger) { - this.log = Logger('jobs:runner'); - this.db = db; - this.config = config; - } - - /** - * Defines a new job - * @param {String} jobName The name of the job - * @param {function} jobRunner The job runner function - * @param {Object} [jobOptions=null] The job options - * @returns {Promise} A promise that resolves once the job is defined - */ - async createJob(/*jobName, jobRunner, jobOptions=null*/) { - throw new Error('Method not implemented'); - } - - /** - * Schedules a job based on its configuration - * @param {String} name The name of the job - * @param {Object} scheduleConfig Schedule configuration object - * @param {('once'|'schedule'|'now')} scheduleConfig.type Type of schedule - * @param {string|Date} [scheduleConfig.value] Cron string or Date object - * @param {Object} [data] Data to pass to the job - * @returns {Promise} A promise that resolves once the job is scheduled - */ - async schedule(/*name, scheduleConfig, data*/) { - throw new Error('Method not implemented'); - } - - /** - * Runs a job once - * @param {String} name The name of the job - * @param {Date} date The schedule - * @param {Object} data Data to pass to the job - * @returns {Promise} A promise that resolves once the job is scheduled - */ - async once(/*name, date, data*/) { - throw new Error('Method not implemented'); - } - - /** - * Runs a job now - * @param {String} name The name of the job - * @param {Object} data Data to pass to the job - * @returns {Promise} A promise that resolves once the job is run - */ - async now(/*name, data*/) { - throw new Error('Method not implemented'); - } - - /** - * Starts the job runner - * @param {Object.} jobClasses Object containing job classes keyed by job name - * @returns {Promise} A promise that resolves once the runner is started - */ - async start(/*jobClasses*/) { - throw new Error('Method not implemented'); - } - - /** - * Closes the job runner and cleans up resources - * @returns {Promise} A promise that resolves once the runner is closed - */ - async close() { - throw new Error('Method not implemented'); - } - - /** - * Enable a job - * @param {string} jobName Name of the job to enable - * @returns {Promise} A promise that resolves once the job is enabled - */ - async enableJob(/*jobName*/) { - throw new Error('Method not implemented'); - } - - /** - * Disable a job - * @param {string} jobName Name of the job to disable - * @returns {Promise} A promise that resolves once the job is disabled - */ - async disableJob(/*jobName*/) { - throw new Error('Method not implemented'); - } - - /** - * Run a job immediately - * @param {string} jobName Name of the job to run - * @returns {Promise} A promise that resolves once the job is triggered - */ - async runJobNow(/* jobName */) { - throw new Error('runJobNow must be implemented'); - } - - /** - * Update job schedule - * @param {string} jobName Name of the job - * @param {string|Date} schedule New schedule (cron string or date) - * @returns {Promise} A promise that resolves once schedule is updated - */ - async updateSchedule(/* jobName, schedule */) { - throw new Error('updateSchedule must be implemented'); - } - - /** - * Configure job retry settings - * @param {string} jobName Name of the job - * @param {Object} retryConfig Retry configuration - * @param {number} retryConfig.attempts Number of retry attempts - * @param {number} retryConfig.delay Delay between retries in ms - * @returns {Promise} A promise that resolves once retry is configured - */ - async configureRetry(/* jobName, retryConfig */) { - throw new Error('configureRetry must be implemented'); - } - - /** - * Maps generic priority to runner-specific priority - * @protected - * @param {string} priority Generic priority from JOB_PRIORITIES - * returns {any} Runner-specific priority value - */ - _mapPriority(/* priority */) { - throw new Error('_mapPriority must be implemented'); - } -} - -module.exports = IJobRunner; \ No newline at end of file diff --git a/jobServer/jobRunner/JobRunnerBullImpl.js b/jobServer/jobRunner/JobRunnerBullImpl.js deleted file mode 100644 index 826e0ae7497..00000000000 --- a/jobServer/jobRunner/JobRunnerBullImpl.js +++ /dev/null @@ -1,95 +0,0 @@ -const IJobRunner = require('./IJobRunner'); -const { Queue, Worker } = require('bullmq'); - -/** - * BullMQ implementation of the job runner - */ -class JobRunnerBullImpl extends IJobRunner { - /** - * Map of queue names to BullMQ Queue instances - * @type {Map} - */ - #queues = new Map(); - - /** - * Map of queue names to BullMQ Worker instances - * @type {Map} - */ - #workers = new Map(); - - #bullConfig; - - #redisConnection; - - #Queue; - - #Worker; - - /** - * Creates a new BullMQ job runner - * @param {Object} db Database connection - * @param {Object} config Configuration object - */ - constructor(db, config) { - super(db, config); - this.#Queue = Queue; - this.#Worker = Worker; - this.#redisConnection = config.redis; - this.#bullConfig = config.bullConfig; - } - - /** - * @param {Object.} jobClasses Object containing job classes keyed by job name - */ - async start(jobClasses) { - - // Create queues and workers for each job - for (const [name, JobClass] of Object.entries(jobClasses)) { - // Create queue - const queue = new this.#Queue(name, { - connection: this.#redisConnection, - ...this.#bullConfig - }); - this.#queues.set(name, queue); - - // Create worker - const worker = new this.#Worker(name, async(job) => { - const instance = new JobClass(name); - await instance.run(job); - }, { - connection: this.#redisConnection, - ...this.#bullConfig - }); - this.#workers.set(name, worker); - - // Handle worker events - worker.on('completed', (job) => { - console.log(`Job ${job.id} completed`); - }); - - worker.on('failed', (job, err) => { - console.error(`Job ${job.id} failed:`, err); - }); - } - } - - /** - * Closes all queues and workers - * @returns {Promise} A promise that resolves once all queues and workers are closed - */ - async close() { - // Close all workers - for (const worker of this.#workers.values()) { - await worker.close(); - } - this.#workers.clear(); - - // Close all queues - for (const queue of this.#queues.values()) { - await queue.close(); - } - this.#queues.clear(); - } -} - -module.exports = JobRunnerBullImpl; \ No newline at end of file diff --git a/jobServer/jobRunner/JobRunnerPulseImpl.js b/jobServer/jobRunner/JobRunnerPulseImpl.js deleted file mode 100644 index e25bb74afdc..00000000000 --- a/jobServer/jobRunner/JobRunnerPulseImpl.js +++ /dev/null @@ -1,280 +0,0 @@ -const IJobRunner = require('./IJobRunner'); -const { Pulse, JobPriority } = require('@pulsecron/pulse'); -const {isValidCron} = require('cron-validator'); -const { JOB_PRIORITIES } = require('../constants/JobPriorities'); - -/** - * Pulse implementation of the job runner - */ -class JobRunnerPulseImpl extends IJobRunner { - /** - * The Pulse runner instance - * @type {import('@pulsecron/pulse').Pulse} - */ - #pulseRunner; - - /** @type {Map} Store job schedules until Pulse is started */ - #pendingSchedules = new Map(); - - /** - * Creates a new Pulse job runner - * @param {Object} db Database connection - * @param {Object} config Configuration object - * @param {function} Logger - Logger constructor - */ - constructor(db, config, Logger) { - super(db, config, Logger); - this.log = Logger('jobs:runner:pulse'); - this.#pulseRunner = new Pulse({ - ...config, - mongo: this.db, - }); - } - - /** - * Starts the Pulse runner and schedules any pending jobs - */ - async start() { - if (!this.#pulseRunner) { - throw new Error('Pulse runner not initialized'); - } - - await this.#pulseRunner.start(); - this.log.i('Pulse runner started'); - - // Schedule all pending jobs - for (const [jobName, scheduleConfig] of this.#pendingSchedules) { - try { - await this.#scheduleJob(jobName, scheduleConfig); - } - catch (error) { - this.log.e(`Failed to schedule job ${jobName}:`, error); - } - } - - this.#pendingSchedules.clear(); - } - - /** - * Updates job progress in Pulse - * @param {Object} job Pulse job instance - * @param {Object} progressData Progress data to store - * @private - */ - async #updateJobProgress(job, progressData) { - job.data = progressData; - await job.save(); - } - - /** - * Creates and defines a new job - * @param {string} jobName The name of the job - * @param {Function} JobClass The job class to create - * @returns {Promise} A promise that resolves once the job is created - */ - async createJob(jobName, JobClass) { - try { - const instance = new JobClass(jobName); - // instance.setLogger(this.log); - instance.setJobName(jobName); - - // Get job configurations - const retryConfig = instance.getRetryConfig(); - const priority = this._mapPriority(instance.getPriority()); - const concurrency = instance.getConcurrency(); - const lockLifetime = instance.getLockLifetime(); - - this.#pulseRunner.define( - jobName, - async(job, done) => { - instance._setTouchMethod(job.touch.bind(job)); - instance._setProgressMethod( - async(progressData) => this.#updateJobProgress(job, progressData) - ); - return instance._run(this.db, job, done); - }, - { - priority, - concurrency, - lockLifetime, - shouldSaveResult: true, - attempts: retryConfig?.enabled ? retryConfig.attempts : 1, - backoff: retryConfig?.enabled ? { - type: 'exponential', - delay: retryConfig.delay - } : undefined - } - ); - - const scheduleConfig = instance.getSchedule(); - this.#pendingSchedules.set(jobName, scheduleConfig); - this.log.d(`Job ${jobName} defined successfully`); - } - catch (error) { - this.log.e(`Failed to create job ${jobName}:`, error); - // Don't throw - allow other jobs to continue - } - } - - /** - * Internal method to schedule a job - * @param {string} name The name of the job to schedule - * @param {Object} scheduleConfig Schedule configuration object - * @param {('once'|'schedule'|'now')} scheduleConfig.type Type of schedule - * @param {string|Date} [scheduleConfig.value] Cron string or Date object - * @param {Object} [data] Data to pass to the job - * @private - */ - async #scheduleJob(name, scheduleConfig, data) { - switch (scheduleConfig.type) { - case 'schedule': - if (!isValidCron(scheduleConfig.value)) { - throw new Error('Invalid cron schedule'); - } - await this.#pulseRunner.every(scheduleConfig.value, name, data); - this.log.d(`Job ${name} scheduled with cron: ${scheduleConfig.value}`); - break; - - case 'once': - if (!(scheduleConfig.value instanceof Date)) { - throw new Error('Invalid date for one-time schedule'); - } - await this.#pulseRunner.schedule(scheduleConfig.value, name, data); - this.log.d(`Job ${name} scheduled for: ${scheduleConfig.value}`); - break; - - case 'now': - await this.#pulseRunner.now(name, data); - this.log.d(`Job ${name} scheduled to run immediately`); - break; - - default: - throw new Error(`Invalid schedule type: ${scheduleConfig.type}`); - } - } - - /** - * Closes the Pulse runner - * @returns {Promise} A promise that resolves once the runner is closed - */ - async close() { - if (this.#pulseRunner) { - await this.#pulseRunner.close(); - this.#pulseRunner = null; - } - } - - /** - * Enable a job - * @param {string} jobName Name of the job to enable - * @returns {Promise} A promise that resolves once the job is enabled - */ - async enableJob(jobName) { - try { - await this.#pulseRunner.enable({ name: jobName }); - this.log.i(`Job ${jobName} enabled`); - } - catch (error) { - this.log.e(`Failed to enable job ${jobName}:`, error); - throw error; - } - } - - /** - * Disable a job - * @param {string} jobName Name of the job to disable - * @returns {Promise} A promise that resolves once the job is disabled - */ - async disableJob(jobName) { - try { - await this.#pulseRunner.disable({ name: jobName }); - this.log.i(`Job ${jobName} disabled`); - } - catch (error) { - this.log.e(`Failed to disable job ${jobName}:`, error); - throw error; - } - } - - /** - * Triggers immediate execution of a job - * @param {string} jobName Name of the job to run - * @returns {Promise} A promise that resolves when the job is triggered - */ - async runJobNow(jobName) { - try { - await this.#pulseRunner.now({ name: jobName }); - this.log.i(`Job ${jobName} triggered for immediate execution`); - } - catch (error) { - this.log.e(`Failed to run job ${jobName} immediately:`, error); - throw error; - } - } - - /** - * Updates the schedule of an existing job - * @param {string} jobName Name of the job to update - * @param {Object} schedule New schedule configuration - * @returns {Promise} A promise that resolves when the schedule is updated - */ - async updateSchedule(jobName, schedule) { - try { - await this.#pulseRunner.reschedule({ name: jobName }, schedule); - this.log.i(`Schedule updated for job ${jobName}`); - } - catch (error) { - this.log.e(`Failed to update schedule for job ${jobName}:`, error); - throw error; - } - } - - /** - * Configures retry settings for a job - * @param {string} jobName Name of the job - * @param {Object} retryConfig Retry configuration - * @param {number} retryConfig.attempts Number of retry attempts - * @param {number} retryConfig.delay Delay between retries in milliseconds - * @returns {Promise} A promise that resolves when retry config is updated - */ - async configureRetry(jobName, retryConfig) { - try { - await this.#pulseRunner.updateOne( - { name: jobName }, - { - $set: { - attempts: retryConfig.attempts, - backoff: { - delay: retryConfig.delay, - type: 'fixed' - } - } - } - ); - this.log.i(`Retry configuration updated for job ${jobName}`); - } - catch (error) { - this.log.e(`Failed to configure retry for job ${jobName}:`, error); - throw error; - } - } - - /** - * Maps generic priority to runner-specific priority - * @protected - * @param {string} priority Generic priority from JOB_PRIORITIES - * @returns {any} Runner-specific priority value - */ - _mapPriority(priority) { - const priorityMap = { - [JOB_PRIORITIES.LOWEST]: JobPriority.lowest, - [JOB_PRIORITIES.LOW]: JobPriority.low, - [JOB_PRIORITIES.NORMAL]: JobPriority.normal, - [JOB_PRIORITIES.HIGH]: JobPriority.high, - [JOB_PRIORITIES.HIGHEST]: JobPriority.highest - }; - return priorityMap[priority] || JobPriority.normal; - } -} - -module.exports = JobRunnerPulseImpl; \ No newline at end of file diff --git a/jobServer/jobRunner/PulseJobRunner.js b/jobServer/jobRunner/PulseJobRunner.js index 03875504e38..1478cf33bca 100644 --- a/jobServer/jobRunner/PulseJobRunner.js +++ b/jobServer/jobRunner/PulseJobRunner.js @@ -5,23 +5,59 @@ const PulseJobExecutor = require('./impl/pulse/PulseJobExecutor'); const PulseJobLifecycle = require('./impl/pulse/PulseJobLifecycle'); /** - * Pulse-specific implementation of the job runner using BaseJobRunner composition + * @typedef {import('@pulsecron/pulse').Pulse} PulseInstance + * @typedef {import('./BaseJobRunner')} BaseJobRunner + * @typedef {import('./impl/pulse/PulseJobScheduler')} PulseJobScheduler + * @typedef {import('./impl/pulse/PulseJobExecutor')} PulseJobExecutor + * @typedef {import('./impl/pulse/PulseJobLifecycle')} PulseJobLifecycle + * @typedef {import('mongodb').Db} Mongodb + */ + +/** + * Pulse-specific implementation of the job runner using BaseJobRunner composition. + * This class provides a concrete implementation of job running capabilities using + * the Pulse framework for scheduling and executing jobs. + * + * @extends {BaseJobRunner} */ class PulseJobRunner extends BaseJobRunner { + /** + * @type {PulseInstance} + * @private + */ + #pulseRunner; + + /** + * @type {Logger} + * @private + */ + log; + /** * Creates a new Pulse job runner with all required implementations - * @param {Object} db Database connection - * @param {Object} config Configuration object - * @param {function} Logger - Logger constructor + * + * @param {Mongodb} db - MongoDB database connection + * @param {Object} config - Configuration object for Pulse + * @param {string} [config.name] - Name of the Pulse instance + * @param {Object} [config.options] - Additional Pulse configuration options + * @param {function} Logger - Logger constructor function + * @throws {Error} If required dependencies are not provided */ constructor(db, config, Logger) { const log = Logger('jobs:runner:pulse'); + log.d('Initializing PulseJobRunner'); + + if (!db || !config || !Logger) { + log.e('Missing required dependencies'); + throw new Error('Missing required dependencies for PulseJobRunner'); + } // Create the Pulse instance that will be shared across implementations const pulseRunner = new Pulse({ ...config, mongo: db, }); + log.i('Created Pulse instance', { config: { ...config, mongo: '[Connection]' } }); // Create implementations with shared pulseRunner instance const scheduler = new PulseJobScheduler(pulseRunner, log); @@ -32,7 +68,9 @@ class PulseJobRunner extends BaseJobRunner { super(scheduler, executor, lifecycle); this.log = log; - this.pulseRunner = pulseRunner; + this.#pulseRunner = pulseRunner; + + log.i('PulseJobRunner initialized successfully'); } } diff --git a/jobServer/jobRunner/impl/pulse/PulseJobExecutor.js b/jobServer/jobRunner/impl/pulse/PulseJobExecutor.js index f0d5940e6bf..a55c3bc1fd1 100644 --- a/jobServer/jobRunner/impl/pulse/PulseJobExecutor.js +++ b/jobServer/jobRunner/impl/pulse/PulseJobExecutor.js @@ -3,14 +3,22 @@ const { JobPriority } = require('@pulsecron/pulse'); const { JOB_PRIORITIES } = require('../../../constants/JobPriorities'); /** - * Pulse implementation of job executor + * @typedef {import('@pulsecron/pulse').JobPriority} JobPriority + * @typedef {import('@pulsecron/pulse').PulseRunner} PulseRunner + * @typedef {import('@pulsecron/pulse').Job} PulseJob + * @typedef {import('mongodb').Db} MongoDB + */ + +/** + * Pulse implementation of job executor that handles job lifecycle management + * @implements {IJobExecutor} */ class PulseJobExecutor extends IJobExecutor { /** * Creates a new PulseJobExecutor instance - * @param {Object} pulseRunner The Pulse runner instance - * @param {Object} db Database connection - * @param {Object} logger Logger instance + * @param {PulseRunner} pulseRunner - The Pulse runner instance for job management + * @param {MongoDB} db - MongoDB database connection + * @param {Logger} logger - Logger instance for operational logging */ constructor(pulseRunner, db, logger) { super(); @@ -21,12 +29,14 @@ class PulseJobExecutor extends IJobExecutor { } /** - * Creates and registers a new job - * @param {string} jobName Name of the job - * @param {Function} JobClass Job class implementation + * Creates and registers a new job with the Pulse runner + * @param {string} jobName - Unique identifier for the job + * @param {Constructor} JobClass - Job class constructor + * @throws {Error} When job creation or registration fails * @returns {Promise} A promise that resolves once the job is created */ async createJob(jobName, JobClass) { + this.log.d(`Attempting to create job: ${jobName}`); try { const instance = new JobClass(jobName); instance.setJobName(jobName); @@ -61,9 +71,14 @@ class PulseJobExecutor extends IJobExecutor { const scheduleConfig = instance.getSchedule(); this.pendingSchedules.set(jobName, scheduleConfig); this.log.d(`Job ${jobName} defined successfully`); + + this.log.d(`Configuring job ${jobName} with priority: ${priority}, concurrency: ${concurrency}, lockLifetime: ${lockLifetime}`); + + this.log.i(`Job ${jobName} created and configured successfully with retry attempts: ${retryConfig?.attempts || 1}`); } catch (error) { - this.log.e(`Failed to create job ${jobName}:`, error); + this.log.e(`Failed to create job ${jobName}`, { error, stack: error.stack }); + throw error; // Propagate error for proper handling } } @@ -143,31 +158,38 @@ class PulseJobExecutor extends IJobExecutor { } /** - * Updates job progress data + * Updates progress information for a running job * @private - * @param {Object} job Job instance - * @param {Object} progressData Progress data to store + * @param {PulseJob} job - Pulse job instance + * @param {Object} progressData - Progress information to store + * @throws {Error} When progress update fails * @returns {Promise} A promise that resolves once progress is updated */ async #updateJobProgress(job, progressData) { + this.log.d(`Updating progress for job ${job.attrs.name}`, { progressData }); try { job.attrs.data = { ...job.attrs.data, progressData }; await job.save(); + this.log.d(`Progress updated successfully for job ${job.attrs.name}`); } catch (error) { - this.log.e(`Failed to update job progress: ${error.message}`); - // Consider whether to throw + this.log.e(`Failed to update job progress for ${job.attrs.name}`, { + error, + stack: error.stack, + jobId: job.attrs._id + }); + throw error; // Propagate error for proper handling } } /** - * Maps generic priority to Pulse-specific priority with validation + * Maps generic priority levels to Pulse-specific priority values * @private - * @param {string} priority Generic priority from JOB_PRIORITIES - * @returns {JobPriority} Pulse-specific priority value + * @param {string} priority - Generic priority from JOB_PRIORITIES + * @returns {JobPriority} Mapped Pulse priority */ #mapPriority(priority) { const priorityMap = { diff --git a/jobServer/jobRunner/impl/pulse/PulseJobLifecycle.js b/jobServer/jobRunner/impl/pulse/PulseJobLifecycle.js index e9955f4334c..30d7ad9f605 100644 --- a/jobServer/jobRunner/impl/pulse/PulseJobLifecycle.js +++ b/jobServer/jobRunner/impl/pulse/PulseJobLifecycle.js @@ -1,7 +1,33 @@ const IJobLifecycle = require('../../interfaces/IJobLifecycle'); /** - * Pulse implementation of job lifecycle management + * @typedef {import('../../interfaces/IJobLifecycle')} IJobLifecycle + * @typedef {import('../PulseRunner')} PulseRunner + * @typedef {import('../JobExecutor')} JobExecutor + * @typedef {import('../JobScheduler')} JobScheduler + */ + +/** + * @typedef {Object} ScheduleConfig + * @property {string} cron - The cron expression for job scheduling + * @property {Object} [options] - Additional scheduling options + * @property {boolean} [options.immediate] - Whether to run the job immediately + * @property {string} [options.timezone] - Timezone for the cron schedule + * @property {number} [options.retryAttempts] - Number of retry attempts on failure + */ + +/** + * @typedef {Object} JobClass + * @property {string} name - The name of the job class + * @property {Function} execute - The main execution method + * @property {Object} [config] - Optional job configuration + */ + +/** + * Pulse implementation of job lifecycle management. + * Handles the initialization, scheduling, and cleanup of jobs in the Pulse system. + * + * @implements {IJobLifecycle} */ class PulseJobLifecycle extends IJobLifecycle { /** @@ -20,42 +46,95 @@ class PulseJobLifecycle extends IJobLifecycle { } /** - * Starts the job runner and schedules pending jobs - * @param {Object.} jobClasses Job classes to register - * @returns {Promise} A promise that resolves once the runner is started + * Initializes and starts the job runner, then schedules all pending jobs. + * This method performs the following steps: + * 1. Validates the pulse runner initialization + * 2. Starts the pulse runner instance + * 3. Schedules all pending jobs from the executor + * + * @param {Object.} jobClasses - Map of job names to their implementing classes + * @returns {Promise} Resolves when the runner is started and all jobs are scheduled + * @throws {Error} If pulse runner is not initialized or scheduling fails */ async start(/* jobClasses */) { if (!this.pulseRunner) { + this.log.e('Lifecycle start failed: Pulse runner not initialized', { + component: 'PulseJobLifecycle', + method: 'start' + }); throw new Error('Pulse runner not initialized'); } - await this.pulseRunner.start(); - this.log.i('Pulse runner started'); + try { + await this.pulseRunner.start(); + this.log.i('Pulse runner initialization complete', { + component: 'PulseJobLifecycle', + status: 'started' + }); - // Schedule all pending jobs from the executor - for (const [jobName, scheduleConfig] of this.executor.pendingSchedules) { - try { - await this.scheduler.schedule(jobName, scheduleConfig); - } - catch (error) { - this.log.e(`Failed to schedule job ${jobName}:`, error); + const pendingJobCount = this.executor.pendingSchedules.size; + this.log.i('Beginning job schedule processing', { + component: 'PulseJobLifecycle', + pendingJobs: pendingJobCount + }); + + // Schedule all pending jobs from the executor + for (const [jobName, scheduleConfig] of this.executor.pendingSchedules) { + try { + await this.scheduler.schedule(jobName, scheduleConfig); + this.log.d('Job scheduled successfully', { + component: 'PulseJobLifecycle', + job: jobName, + config: scheduleConfig + }); + } + catch (error) { + this.log.e('Job scheduling failed', { + component: 'PulseJobLifecycle', + job: jobName, + error: error.message, + stack: error.stack + }); + } } - } - this.executor.pendingSchedules.clear(); + this.executor.pendingSchedules.clear(); + this.log.i('Job scheduling process complete', { + component: 'PulseJobLifecycle', + totalProcessed: pendingJobCount + }); + } + catch (error) { + this.log.e('Pulse runner start failed', { + component: 'PulseJobLifecycle', + error: error.message, + stack: error.stack + }); + throw error; + } } /** - * Closes the job runner and cleans up resources - * @returns {Promise} A promise that resolves once the runner is closed + * Gracefully shuts down the job runner and cleans up resources. + * Ensures all running jobs are properly terminated and resources are released. + * + * @returns {Promise} Resolves when the runner is successfully closed + * @throws {Error} If the shutdown process fails */ async close() { try { await this.pulseRunner.close(); - this.log.i('Pulse runner closed'); + this.log.i('Pulse runner shutdown complete', { + component: 'PulseJobLifecycle', + status: 'closed' + }); } catch (error) { - this.log.e('Error closing Pulse runner:', error); + this.log.e('Pulse runner shutdown failed', { + component: 'PulseJobLifecycle', + error: error.message, + stack: error.stack + }); throw error; } } diff --git a/jobServer/jobRunner/impl/pulse/PulseJobScheduler.js b/jobServer/jobRunner/impl/pulse/PulseJobScheduler.js index 86a09274b7f..8c1d5f50b8a 100644 --- a/jobServer/jobRunner/impl/pulse/PulseJobScheduler.js +++ b/jobServer/jobRunner/impl/pulse/PulseJobScheduler.js @@ -1,14 +1,26 @@ const IJobScheduler = require('../../interfaces/IJobScheduler'); const {isValidCron} = require('cron-validator'); +/** + * @typedef {import('../../interfaces/IJobScheduler')} IJobScheduler + * @typedef {import('@pulse/runner')} PulseRunner + * @typedef {import('@logger/interface')} Logger + * + * @typedef {Object} ScheduleConfig + * @property {'schedule'|'once'|'now'} type - Type of schedule + * @property {string|Date} [value] - Cron expression for 'schedule' type or Date for 'once' type + */ + /** * Pulse implementation of job scheduler + * Handles scheduling, updating, and immediate execution of jobs using Pulse runner + * @implements {IJobScheduler} */ class PulseJobScheduler extends IJobScheduler { /** * Creates a new PulseJobScheduler instance - * @param {Object} pulseRunner The Pulse runner instance - * @param {Object} logger Logger instance + * @param {PulseRunner} pulseRunner - The Pulse runner instance for job scheduling + * @param {Logger} logger - Logger instance for operational logging */ constructor(pulseRunner, logger) { super(); @@ -17,10 +29,10 @@ class PulseJobScheduler extends IJobScheduler { } /** - * Validates schedule configuration + * Validates schedule configuration structure and values * @private - * @param {Object} config Schedule configuration - * @throws {Error} If configuration is invalid + * @param {ScheduleConfig} config - Schedule configuration to validate + * @throws {Error} If configuration is invalid or missing required fields */ #validateScheduleConfig(config) { if (!config || typeof config !== 'object') { @@ -42,15 +54,20 @@ class PulseJobScheduler extends IJobScheduler { } /** - * Schedules a job to run - * @param {string} name Job name - * @param {Object} scheduleConfig Schedule configuration - * @param {Object} [data] Optional data to pass to the job - * @returns {Promise} A promise that resolves once the job is scheduled + * Schedules a job to run based on provided configuration + * @param {string} name - Unique identifier for the job + * @param {ScheduleConfig} scheduleConfig - Configuration defining when the job should run + * @param {Object} [data={}] - Optional payload to pass to the job during execution + * @returns {Promise} Resolves when job is successfully scheduled + * @throws {Error} If scheduling fails or configuration is invalid */ async schedule(name, scheduleConfig, data = {}) { try { this.#validateScheduleConfig(scheduleConfig); + this.log.d(`Attempting to schedule job '${name}' with type: ${scheduleConfig.type}`, { + scheduleConfig, + data + }); switch (scheduleConfig.type) { case 'schedule': @@ -72,22 +89,33 @@ class PulseJobScheduler extends IJobScheduler { break; } - this.log.d(`Job ${name} scheduled successfully with type: ${scheduleConfig.type}`); + this.log.i(`Successfully scheduled job '${name}'`, { + type: scheduleConfig.type, + value: scheduleConfig.value, + hasData: Object.keys(data).length > 0 + }); } catch (error) { - this.log.e(`Failed to schedule job ${name}:`, error); + this.log.e(`Failed to schedule job '${name}'`, { + error: error.message, + scheduleConfig, + stack: error.stack + }); throw error; } } /** - * Updates a job's schedule - * @param {string} jobName Name of the job - * @param {Object} schedule New schedule configuration - * @returns {Promise} A promise that resolves once the schedule is updated + * Updates an existing job's schedule with new configuration + * @param {string} jobName - Name of the job to update + * @param {ScheduleConfig} schedule - New schedule configuration + * @returns {Promise} Resolves when schedule is successfully updated + * @throws {Error} If update fails or new configuration is invalid */ async updateSchedule(jobName, schedule) { try { + this.log.d(`Attempting to update schedule for job '${jobName}'`, { schedule }); + this.#validateScheduleConfig(schedule); // First remove the existing job @@ -114,26 +142,38 @@ class PulseJobScheduler extends IJobScheduler { break; } - this.log.i(`Schedule updated for job ${jobName}`); + this.log.i(`Successfully updated schedule for job '${jobName}'`, { + type: schedule.type, + value: schedule.value + }); } catch (error) { - this.log.e(`Failed to update schedule for job ${jobName}:`, error); + this.log.e(`Failed to update schedule for job '${jobName}'`, { + error: error.message, + schedule, + stack: error.stack + }); throw error; } } /** - * Runs a job immediately - * @param {string} jobName Name of the job - * @returns {Promise} A promise that resolves when the job is triggered + * Triggers immediate execution of a job + * @param {string} jobName - Name of the job to execute + * @returns {Promise} Resolves when job is successfully triggered + * @throws {Error} If immediate execution fails */ async runJobNow(jobName) { try { + this.log.d(`Attempting to trigger immediate execution of job '${jobName}'`); await this.pulseRunner.now({ name: jobName }); - this.log.i(`Job ${jobName} triggered for immediate execution`); + this.log.i(`Successfully triggered immediate execution of job '${jobName}'`); } catch (error) { - this.log.e(`Failed to run job ${jobName} immediately:`, error); + this.log.e(`Failed to trigger immediate execution of job '${jobName}'`, { + error: error.message, + stack: error.stack + }); throw error; } } diff --git a/jobServer/jobRunner/index.js b/jobServer/jobRunner/index.js index 7e6c624646c..ddf0bcd4f3a 100644 --- a/jobServer/jobRunner/index.js +++ b/jobServer/jobRunner/index.js @@ -1,29 +1,68 @@ +/** + * @typedef {import('./PulseJobRunner.js').PulseJobRunner} PulseJobRunner + * @typedef {import('./BaseJobRunner.js').BaseJobRunner} BaseJobRunner + * @typedef {import('mongodb').Db} Mongodb + * @typedef {import('./types/Logger').Logger} Logger + */ + +/** + * @typedef {Object} JobRunnerConfig + * @property {number} [pollInterval=1000] - Interval between job checks in milliseconds + * @property {number} [maxConcurrent=5] - Maximum number of concurrent jobs + * @property {string} [queueName='default'] - Name of the job queue to process + */ + const PulseJobRunner = require('./PulseJobRunner.js'); /** - * JobRunner implementation types + * Available JobRunner implementation types + * @readonly * @enum {string} */ const RUNNER_TYPES = { + /** Pulse-based job runner implementation */ PULSE: 'pulse' }; /** - * Job Runner factory + * Creates a new job runner instance based on the specified type * - * @param {Object} db The database connection - * @param {string} [type='pulse'] The type of runner to create - * @param {Object} [config={}] Configuration specific to the runner implementation - * @param {function} Logger - Logger constructor + * @param {Mongodb} db - Mongoose database connection + * @param {RUNNER_TYPES} [type=RUNNER_TYPES.PULSE] - The type of runner to create + * @param {JobRunnerConfig} [config={}] - Configuration specific to the runner implementation + * @param {Logger} Logger - Logger Constructor * @returns {BaseJobRunner} An instance of BaseJobRunner with specific implementation * @throws {Error} If an invalid runner type is specified + * @throws {Error} If required dependencies are missing + * + * @example + * const runner = createJobRunner(db, RUNNER_TYPES.PULSE, { + * pollInterval: 5000, + * maxConcurrent: 10, + * queueName: 'high-priority' + * }, logger); + * await runner.start(); */ function createJobRunner(db, type = RUNNER_TYPES.PULSE, config = {}, Logger) { - switch (type.toLowerCase()) { - case RUNNER_TYPES.PULSE: - return new PulseJobRunner(db, config, Logger); - default: - throw new Error(`Invalid runner type: ${type}. Must be one of: ${Object.values(RUNNER_TYPES).join(', ')}`); + if (!db) { + throw new Error('Database connection is required'); + } + + const log = Logger('jobs:runner:factory'); + + log.i('Creating job runner', { type, config }); + + try { + switch (type.toLowerCase()) { + case RUNNER_TYPES.PULSE: + return new PulseJobRunner(db, config, Logger); + default: + throw new Error(`Invalid runner type: ${type}. Must be one of: ${Object.values(RUNNER_TYPES).join(', ')}`); + } + } + catch (error) { + log.e('Failed to create job runner', { type, config, error }); + throw error; } } diff --git a/jobServer/jobRunner/interfaces/IJobExecutor.js b/jobServer/jobRunner/interfaces/IJobExecutor.js index 5757cc756c8..cc74622d98e 100644 --- a/jobServer/jobRunner/interfaces/IJobExecutor.js +++ b/jobServer/jobRunner/interfaces/IJobExecutor.js @@ -1,38 +1,52 @@ /** - * Interface for job execution operations + * @typedef {Object} RetryConfig + * @property {number} attempts - Maximum number of retry attempts + * @property {number} delay - Delay between retries in milliseconds + */ + +/** + * Interface for job execution operations. + * Provides methods to manage and configure job execution within the system. + * @interface */ class IJobExecutor { /** - * Creates and defines a new job - * @param {string} jobName The name of the job - * @param {Function} JobClass The job class to create + * Creates and defines a new job in the execution system + * @param {string} jobName - Unique identifier for the job + * @param {Constructor} JobClass - Constructor for the job implementation + * @throws {Error} When method is not implemented by concrete class + * @returns {Promise} Resolves when job is created and defined */ async createJob(/* jobName, JobClass */) { throw new Error('Method not implemented'); } /** - * Enable a job - * @param {string} jobName Name of the job to enable + * Enables a previously created job for execution + * @param {string} jobName - Name of the job to enable + * @throws {Error} When method is not implemented by concrete class + * @returns {Promise} Resolves when job is enabled */ async enableJob(/* jobName */) { throw new Error('Method not implemented'); } /** - * Disable a job - * @param {string} jobName Name of the job to disable + * Disables an active job from execution + * @param {string} jobName - Name of the job to disable + * @throws {Error} When method is not implemented by concrete class + * @returns {Promise} Resolves when job is disabled */ async disableJob(/* jobName */) { throw new Error('Method not implemented'); } /** - * Configure job retry settings - * @param {string} jobName Name of the job - * @param {Object} retryConfig Retry configuration - * @param {number} retryConfig.attempts Number of retry attempts - * @param {number} retryConfig.delay Delay between retries in ms + * Configures retry behavior for a specific job + * @param {string} jobName - Name of the job to configure + * @param {RetryConfig} retryConfig - Configuration for retry behavior + * @throws {Error} When method is not implemented by concrete class + * @returns {Promise} Resolves when retry configuration is applied */ async configureRetry(/* jobName, retryConfig */) { throw new Error('Method not implemented'); diff --git a/jobServer/jobRunner/interfaces/IJobLifecycle.js b/jobServer/jobRunner/interfaces/IJobLifecycle.js index 92b1bd83222..513e2b2211e 100644 --- a/jobServer/jobRunner/interfaces/IJobLifecycle.js +++ b/jobServer/jobRunner/interfaces/IJobLifecycle.js @@ -1,17 +1,30 @@ /** - * Interface for job lifecycle operations + * Interface for managing job lifecycle operations. + * Handles the initialization, execution, and cleanup of job runners. + * + * @interface */ class IJobLifecycle { /** - * Starts the job runner - * @param {Object.} jobClasses Object containing job classes keyed by job name + * Starts the job runner and initializes all registered jobs + * + * @typedef {Object} JobClass + * @property {Function} new Creates a new instance of the job + * @property {Function} execute Method that runs the job logic + * + * @param {Object.} jobClasses - Map of job names to their implementing classes + * @throws {Error} When initialization fails + * @returns {Promise} Resolves when all jobs are initialized and runner is ready */ async start(/* jobClasses */) { throw new Error('Method not implemented'); } /** - * Closes the job runner and cleans up resources + * Gracefully shuts down the job runner and performs cleanup + * + * @throws {Error} When cleanup fails + * @returns {Promise} Resolves when all jobs are stopped and resources are released */ async close() { throw new Error('Method not implemented'); diff --git a/jobServer/jobRunner/interfaces/IJobScheduler.js b/jobServer/jobRunner/interfaces/IJobScheduler.js index 9046ec7ee30..9d0eaf0999f 100644 --- a/jobServer/jobRunner/interfaces/IJobScheduler.js +++ b/jobServer/jobRunner/interfaces/IJobScheduler.js @@ -1,31 +1,49 @@ +/** + * @typedef {Object} ScheduleConfig + * @property {'once'|'schedule'|'now'} type - Type of schedule execution + * @property {string|Date} [value] - Cron expression (for 'schedule') or Date object (for 'once') + */ + +/** + * @typedef {Object} JobData + * @property {*} [payload] - Custom data payload for the job + * @property {Object} [metadata] - Additional metadata for job execution + */ + /** * Interface for job scheduling operations + * Handles scheduling, updating, and immediate execution of jobs + * @interface */ class IJobScheduler { /** * Schedules a job based on its configuration - * @param {String} name The name of the job - * @param {Object} scheduleConfig Schedule configuration object - * @param {('once'|'schedule'|'now')} scheduleConfig.type Type of schedule - * @param {string|Date} [scheduleConfig.value] Cron string or Date object - * @param {Object} [data] Data to pass to the job + * @param {string} name - Unique identifier for the job + * @param {ScheduleConfig} scheduleConfig - Schedule configuration + * @param {JobData} [data] - Optional data to pass to the job + * @throws {Error} If scheduling fails + * @returns {Promise} Resolves when scheduling is complete` */ async schedule(/* name, scheduleConfig, data */) { throw new Error('Method not implemented'); } /** - * Update job schedule - * @param {string} jobName Name of the job - * @param {string|Date} schedule New schedule (cron string or date) + * Update existing job schedule + * @param {string} jobName - Name of the job to update + * @param {string|Date} schedule - New schedule (cron expression or date) + * @throws {Error} If job not found or update fails + * @returns {Promise} Resolves when scheduling is complete */ async updateSchedule(/* jobName, schedule */) { throw new Error('Method not implemented'); } /** - * Runs a job now - * @param {string} jobName Name of the job to run + * Executes a job immediately, bypassing its schedule + * @param {string} jobName - Name of the job to run + * @throws {Error} If job not found or execution fails + * @returns {Promise} Resolves when job is executed */ async runJobNow(/* jobName */) { throw new Error('Method not implemented'); From 372ddd6fc0089f1d813f44ba1a0a772b7fd42b58 Mon Sep 17 00:00:00 2001 From: Kanwar Ujjaval Singh <4216199+kanwarujjaval@users.noreply.github.com> Date: Wed, 15 Jan 2025 23:49:22 +0530 Subject: [PATCH 25/90] codacy fix --- jobServer/jobRunner/PulseJobRunner.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jobServer/jobRunner/PulseJobRunner.js b/jobServer/jobRunner/PulseJobRunner.js index 1478cf33bca..0c1fb169719 100644 --- a/jobServer/jobRunner/PulseJobRunner.js +++ b/jobServer/jobRunner/PulseJobRunner.js @@ -47,7 +47,7 @@ class PulseJobRunner extends BaseJobRunner { const log = Logger('jobs:runner:pulse'); log.d('Initializing PulseJobRunner'); - if (!db || !config || !Logger) { + if (!db || !config) { log.e('Missing required dependencies'); throw new Error('Missing required dependencies for PulseJobRunner'); } From a3acc44c8527062c7d0b563f57d6fbc25755d734 Mon Sep 17 00:00:00 2001 From: Kanwar Ujjaval Singh <4216199+kanwarujjaval@users.noreply.github.com> Date: Thu, 16 Jan 2025 11:03:59 +0530 Subject: [PATCH 26/90] add design pattern conventions --- jobServer/README.md | 95 ++++++++++++++++++++++++++++++++++++++------- 1 file changed, 80 insertions(+), 15 deletions(-) diff --git a/jobServer/README.md b/jobServer/README.md index 122615509cb..e71b726c787 100644 --- a/jobServer/README.md +++ b/jobServer/README.md @@ -10,19 +10,20 @@ A flexible, extensible job scheduling and execution system built on MongoDB with 4. [Collections](#collections) 5. [Basic Usage](#basic-usage) 6. [Architecture](#architecture) -7. [Server Configuration](#server-configuration) -8. [Job Implementation Guide](#job-implementation-guide) -9. [Lock Extension & Progress Reporting](#lock-extension--progress-reporting) -10. [Job Configuration Management](#job-configuration-management) -11. [Parallel Processing](#parallel-processing) -12. [File Structure](#file-structure) -13. [Interface Contracts](#interface-contracts) -14. [Implementing New Runners](#implementing-new-runners) -15. [Error Handling & Monitoring](#error-handling--monitoring) -16. [Best Practices](#best-practices) -17. [Troubleshooting Guide](#troubleshooting-guide) -18. [Monitoring & Metrics](#monitoring--metrics) -19. [BullMQ Implementation Guide](#bullmq-implementation-guide) +7. [Design Patterns](#design-patterns) +8. [Server Configuration](#server-configuration) +9. [Job Implementation Guide](#job-implementation-guide) +10. [Lock Extension & Progress Reporting](#lock-extension--progress-reporting) +11. [Job Configuration Management](#job-configuration-management) +12. [Parallel Processing](#parallel-processing) +13. [File Structure](#file-structure) +14. [Interface Contracts](#interface-contracts) +15. [Implementing New Runners](#implementing-new-runners) +16. [Error Handling & Monitoring](#error-handling--monitoring) +17. [Best Practices](#best-practices) +18. [Troubleshooting Guide](#troubleshooting-guide) +19. [Monitoring & Metrics](#monitoring--metrics) +20. [BullMQ Implementation Guide](#bullmq-implementation-guide) ## Overview @@ -172,6 +173,70 @@ JobExecutor JobScheduler JobLifecycle - Handles actual job execution - Manages scheduling and state +## Design Patterns + +The Job Server Module employs several design patterns to maintain flexibility, testability, and extensibility: + +### Core Patterns + +1. **Interface Segregation** + - Job operations split into focused interfaces (IJobExecutor, IJobScheduler, IJobLifecycle) + - Enables targeted implementation of specific job aspects + - Reduces coupling between components + ```javascript + // Example: Separate interfaces for different concerns + interface IJobExecutor { /* job execution methods */ } + interface IJobScheduler { /* scheduling methods */ } + interface IJobLifecycle { /* lifecycle methods */ } + ``` + +2. **Composition over Inheritance** + - BaseJobRunner composes functionality from specialized interfaces + - Runner implementations combine executor, scheduler, and lifecycle components + - Allows flexible mixing of different implementations + ```javascript + class BaseJobRunner { + constructor(scheduler, executor, lifecycle) { + this.scheduler = scheduler; + this.executor = executor; + this.lifecycle = lifecycle; + } + } + ``` + +3. **Dependency Injection** + - Components receive dependencies through constructors + - Facilitates testing and configuration + - Enables runtime selection of implementations + ```javascript + const server = await JobServer.create(common, Logger, pluginManager, { + runner: { + type: 'pulse', + config: { /* ... */ } + } + }); + ``` + +4. **Factory Pattern** + - Runner implementations created through factory methods + - Centralizes runner instantiation logic + - Supports multiple runner types (Pulse, BullMQ) + ```javascript + // Example: Runner factory + const RUNNER_TYPES = { + PULSE: 'pulse', + BULL: 'bullmq' + }; + ``` + + +### Benefits + +- **Extensibility**: New runners can be added without modifying existing code +- **Testability**: Components can be tested in isolation with mock implementations +- **Flexibility**: Runtime configuration of job processing behavior +- **Maintainability**: Clear separation of concerns and modular design + ## Server Configuration ### Pulse Runner Configuration @@ -860,8 +925,7 @@ const connection = new Redis({ ``` 2. **Basic Structure** -``` -jobRunner/impl/bullmq/ +```jobRunner/impl/bullmq/ ├── BullMQJobExecutor.js ├── BullMQJobLifecycle.js ├── BullMQJobScheduler.js @@ -930,3 +994,4 @@ class BullMQJobLifecycle extends IJobLifecycle { } } ``` + From e103818b371a050951f075a6692652d60e0f9832 Mon Sep 17 00:00:00 2001 From: Kanwar Ujjaval Singh <4216199+kanwarujjaval@users.noreply.github.com> Date: Thu, 16 Jan 2025 11:28:23 +0530 Subject: [PATCH 27/90] wip mark in readme --- jobServer/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jobServer/README.md b/jobServer/README.md index e71b726c787..c83b0d57e28 100644 --- a/jobServer/README.md +++ b/jobServer/README.md @@ -909,7 +909,7 @@ class LoggingJob extends Job { } ``` -## BullMQ Implementation Guide +## BullMQ Implementation Guide [WIP] ### Setup Requirements From 4445d5a1cdfbd229d6afb4538261fdfcf9e834d0 Mon Sep 17 00:00:00 2001 From: Kanwar Ujjaval Singh <4216199+kanwarujjaval@users.noreply.github.com> Date: Thu, 16 Jan 2025 12:05:58 +0530 Subject: [PATCH 28/90] Add simple example --- jobServer/example/SimpleExample.js | 43 ++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 jobServer/example/SimpleExample.js diff --git a/jobServer/example/SimpleExample.js b/jobServer/example/SimpleExample.js new file mode 100644 index 00000000000..9dc9785f7d1 --- /dev/null +++ b/jobServer/example/SimpleExample.js @@ -0,0 +1,43 @@ +const job = require("../../jobServer"); + +/** + * Simple example job with only required methods. + * @extends {job.Job} + */ +class SimpleExample extends job.Job { + /** + * Get the schedule configuration for the job. + * @required + * @returns {Object} Schedule configuration object + */ + getSchedule() { + return { + type: 'schedule', + value: '0 0 * * *' // Runs daily at midnight + }; + } + + /** + * Main job execution method. + * @required + * @param {Db} db Database connection + * @param {Function} done Callback to signal job completion + * @param {Function} progress Progress reporting function + */ + async run(db, done, progress) { + try { + this.logger.i("Starting simple job"); + + // Your job logic here + await progress(1, 1, "Task completed"); + + done(null, { success: true }); + } + catch (error) { + this.logger.e("Job failed:", error); + done(error); + } + } +} + +module.exports = SimpleExample; \ No newline at end of file From 97ed75384c6dd6a6fce019d5a402947cf5d8d9fa Mon Sep 17 00:00:00 2001 From: Kanwar Ujjaval Singh <4216199+kanwarujjaval@users.noreply.github.com> Date: Thu, 16 Jan 2025 12:08:03 +0530 Subject: [PATCH 29/90] sane defaults for Job --- jobServer/Job.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/jobServer/Job.js b/jobServer/Job.js index 1410965b1ae..4699c862e42 100644 --- a/jobServer/Job.js +++ b/jobServer/Job.js @@ -211,7 +211,7 @@ class Job { async _run(db, job, done) { this.logger.d(`[Job:${this.jobName}] Starting execution`, { database: db?._cly_debug?.db, - jobId: job?.attrs._id, + jobId: job?.attrs?._id, jobName: this.jobName }); @@ -317,9 +317,9 @@ class Job { */ getRetryConfig() { return { - enabled: true, + enabled: false, attempts: 3, - delay: 2000 // 2 seconds + delay: 5 * 60 * 1000 // 5 minutes }; } From b6a542118bef4f7fccef5bcfc65f8a4126974ff4 Mon Sep 17 00:00:00 2001 From: Kanwar Ujjaval Singh <4216199+kanwarujjaval@users.noreply.github.com> Date: Thu, 16 Jan 2025 12:09:30 +0530 Subject: [PATCH 30/90] sane defaults for global --- jobServer/config.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/jobServer/config.js b/jobServer/config.js index 9941659c934..ceef4454397 100644 --- a/jobServer/config.js +++ b/jobServer/config.js @@ -15,11 +15,11 @@ * @property {string} db.collection - MongoDB collection name for storing jobs */ const DEFAULT_PULSE_CONFIG = { - processEvery: '3 seconds', + processEvery: '60 seconds', maxConcurrency: 1, defaultConcurrency: 1, lockLimit: 1, - defaultLockLimit: 1, + defaultLockLimit: 3, defaultLockLifetime: 55 * 60 * 1000, // 55 minutes sort: { nextRunAt: 1, priority: -1 }, disableAutoIndex: false, From 46c77aabf0f4bd5e925dffd1c5d2ffda7752ea4c Mon Sep 17 00:00:00 2001 From: Kanwar Ujjaval Singh <4216199+kanwarujjaval@users.noreply.github.com> Date: Thu, 16 Jan 2025 15:58:50 +0530 Subject: [PATCH 31/90] Prevent job scheduler collision --- api/parts/jobs/scanner.js | 34 ++++++++++++++++++++++++++++++---- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/api/parts/jobs/scanner.js b/api/parts/jobs/scanner.js index dad13d46845..771e234ed97 100644 --- a/api/parts/jobs/scanner.js +++ b/api/parts/jobs/scanner.js @@ -2,7 +2,30 @@ const log = require('../../utils/log.js')('jobs:scanner'), manager = require('../../../plugins/pluginManager.js'), - fs = require('fs'); + fs = require('fs'), + {Job, IPCJob, IPCFaçadeJob, TransientJob} = require('./job.js'); + +/** + * Validates if a job class has the required methods + * @param {Function} JobClass - The job class to validate + * @returns {boolean} - True if valid, throws error if invalid + */ +const validateJobClass = (JobClass) => { + // Check if it's a class/constructor + if (typeof JobClass !== 'function') { + throw new Error('Job must be a class constructor'); + } + + // Check if it inherits from one of the valid base classes + if (!(JobClass.prototype instanceof Job || + JobClass.prototype instanceof IPCJob || + JobClass.prototype instanceof IPCFaçadeJob || + JobClass.prototype instanceof TransientJob)) { + throw new Error('Job class must extend Job, IPCJob, IPCFaçadeJob, or TransientJob'); + } + + return true; +}; module.exports = (db, filesObj, classesObj) => { return new Promise((resolve, reject) => { @@ -52,9 +75,12 @@ module.exports = (db, filesObj, classesObj) => { (arr || []).forEach(job => { try { let name = job.category + ':' + job.name; - filesObj[name] = job.file; - classesObj[name] = require(job.file); - log.d('Found job %j at %j', name, job.file); + const JobClass = require(job.file); + if (validateJobClass(JobClass)) { + filesObj[name] = job.file; + classesObj[name] = JobClass; + log.d('Found valid job %j at %j', name, job.file); + } } catch (e) { log.e('Error when loading job %s: %j ', job.file, e, e.stack); From 67f258890bbaf760df84b22d3d933080a89a5d53 Mon Sep 17 00:00:00 2001 From: Kanwar Ujjaval Singh <4216199+kanwarujjaval@users.noreply.github.com> Date: Thu, 23 Jan 2025 23:29:10 +0530 Subject: [PATCH 32/90] migrate crashes jobs --- plugins/crashes/api/api.js | 6 +++--- .../crashes/api/jobs/cleanup_custom_field.js | 17 +++++++++++++++-- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/plugins/crashes/api/api.js b/plugins/crashes/api/api.js index 891f1d652c2..3c0fef4bd23 100644 --- a/plugins/crashes/api/api.js +++ b/plugins/crashes/api/api.js @@ -59,9 +59,9 @@ plugins.setConfigs("crashes", { } }); - setTimeout(() => { - require('../../../api/parts/jobs').job('crashes:cleanup_custom_field').replace().schedule('at 01:01 am ' + 'every 1 day'); - }, 10000); + // setTimeout(() => { + // require('../../../api/parts/jobs').job('crashes:cleanup_custom_field').replace().schedule('at 01:01 am ' + 'every 1 day'); + // }, 10000); }); var ranges = ["ram", "bat", "disk", "run", "session"]; var segments = ["os_version", "os_name", "manufacture", "device", "resolution", "app_version", "cpu", "opengl", "orientation", "view", "browser"]; diff --git a/plugins/crashes/api/jobs/cleanup_custom_field.js b/plugins/crashes/api/jobs/cleanup_custom_field.js index c6309e321a3..dddc17aebd1 100644 --- a/plugins/crashes/api/jobs/cleanup_custom_field.js +++ b/plugins/crashes/api/jobs/cleanup_custom_field.js @@ -1,11 +1,24 @@ -const job = require('../../../../api/parts/jobs/job.js'); +// const job = require('../../../../api/parts/jobs/job.js'); +const Job = require('./../../../../jobServer/Job'); const log = require('../../../../api/utils/log.js')('job:crashes:cleanup_custom_field'); const pluginManager = require('../../../pluginManager.js'); const { cleanupCustomField, DEFAULT_MAX_CUSTOM_FIELD_KEYS } = require('../parts/custom_field.js'); /** class CleanupCustomFieldJob */ -class CleanupCustomFieldJob extends job.Job { +class CleanupCustomFieldJob extends Job { + + /** + * Get the schedule configuration for this job + * @returns {GetScheduleConfig} schedule configuration + */ + getSchedule() { + return { + type: "schedule", + value: "1 1 * * *" // every day at 1:01 + }; + } + /** function run * @param {object} countlyDb - db connection object * @param {function} doneJob - function to call when finishing Job From d6eb77fb92be93f707fff22014cbc0edaf4e07c1 Mon Sep 17 00:00:00 2001 From: Kanwar Ujjaval Singh <4216199+kanwarujjaval@users.noreply.github.com> Date: Thu, 23 Jan 2025 23:29:16 +0530 Subject: [PATCH 33/90] migrate hooks jobs --- plugins/hooks/api/jobs/schedule.js | 21 +++++++++++++++---- plugins/hooks/api/parts/triggers/scheduled.js | 12 +++++------ 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/plugins/hooks/api/jobs/schedule.js b/plugins/hooks/api/jobs/schedule.js index d5bbf464afc..7a041bce3ad 100644 --- a/plugins/hooks/api/jobs/schedule.js +++ b/plugins/hooks/api/jobs/schedule.js @@ -1,14 +1,27 @@ 'use strict'; -const job = require('../../../../api/parts/jobs/job.js'), - plugins = require('../../../pluginManager.js'), - log = require('../../../../api/utils/log.js')('hooks:monitor'); +// const job = require('../../../../api/parts/jobs/job.js'), +const Job = require('../../../../jobServer/Job'); +const plugins = require('../../../pluginManager.js'); +const log = require('../../../../api/utils/log.js')('hooks:monitor'); /** * @class * @classdesc Class MonitorJob is Hooks Monitor Job extend from Countly Job * @extends Job */ -class ScheduleJob extends job.Job { +class ScheduleJob extends Job { + + /** + * Get the schedule configuration for this job + * @returns {GetScheduleConfig} schedule configuration + */ + getSchedule() { + return { + type: "schedule", + value: "1 * * * *" // every 1 hour on the 1st min + }; + } + /** * run task * @param {object} db - db object diff --git a/plugins/hooks/api/parts/triggers/scheduled.js b/plugins/hooks/api/parts/triggers/scheduled.js index 99e598fed49..b40e2f948f9 100644 --- a/plugins/hooks/api/parts/triggers/scheduled.js +++ b/plugins/hooks/api/parts/triggers/scheduled.js @@ -2,7 +2,7 @@ const plugins = require('../../../../pluginManager.js'); const common = require('../../../../../api/utils/common.js'); const utils = require('../../utils.js'); const log = common.log("hooks:api:schedule"); -const JOB = require('../../../../../api/parts/jobs'); +// const JOB = require('../../../../../api/parts/jobs'); const later = require('later'); const moment = require('moment-timezone'); @@ -98,11 +98,11 @@ class ScheduledTrigger { } -plugins.register("/master", function() { - setTimeout(() => { - JOB.job('hooks:schedule', {type: 'ScheduledTrigger'}).replace().schedule("every 1 hour on the 1st min"); - }, 10000); -}); +// plugins.register("/master", function() { +// setTimeout(() => { +// JOB.job('hooks:schedule', {type: 'ScheduledTrigger'}).replace().schedule("every 1 hour on the 1st min"); +// }, 10000); +// }); module.exports = ScheduledTrigger; From 4a45e76fef3e6a887810c86701e89e3a077a2b04 Mon Sep 17 00:00:00 2001 From: Kanwar Ujjaval Singh <4216199+kanwarujjaval@users.noreply.github.com> Date: Thu, 23 Jan 2025 23:29:22 +0530 Subject: [PATCH 34/90] migrate logger jobs --- plugins/logger/api/api.js | 16 ++++++++-------- plugins/logger/api/jobs/clear.js | 17 +++++++++++++++-- 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/plugins/logger/api/api.js b/plugins/logger/api/api.js index 4e36c16430b..d1a6e5cadf5 100644 --- a/plugins/logger/api/api.js +++ b/plugins/logger/api/api.js @@ -4,7 +4,7 @@ var exported = {}, automaticStateManager = require('./helpers/automaticStateManager'), log = require('../../../api/utils/log.js')('logger:api'), { validateRead } = require('../../../api/utils/rights.js'); -const JOB = require('../../../api/parts/jobs'); +// const JOB = require('../../../api/parts/jobs'); const MAX_NUMBER_OF_LOG_ENTRIES = 1000; const FEATURE_NAME = 'logger'; @@ -21,13 +21,13 @@ plugins.setConfigs("logger", { }); (function() { - plugins.register("/master", function() { - setTimeout(() => { - JOB.job('logger:clear', { max: MAX_NUMBER_OF_LOG_ENTRIES }) - .replace() - .schedule("every 5 minutes"); - }, 10000); - }); + // plugins.register("/master", function() { + // setTimeout(() => { + // JOB.job('logger:clear', { max: MAX_NUMBER_OF_LOG_ENTRIES }) + // .replace() + // .schedule("every 5 minutes"); + // }, 10000); + // }); plugins.register("/permissions/features", function(ob) { ob.features.push(FEATURE_NAME); diff --git a/plugins/logger/api/jobs/clear.js b/plugins/logger/api/jobs/clear.js index ddf9376c217..cb117945d58 100644 --- a/plugins/logger/api/jobs/clear.js +++ b/plugins/logger/api/jobs/clear.js @@ -2,7 +2,8 @@ * @typedef {import("mongodb").Db} Database * @typedef {import("mongodb").ObjectId} ObjectId */ -const JOB = require("../../../../api/parts/jobs/job.js"); +// const JOB = require("../../../../api/parts/jobs/job.js"); +const Job = require("../../../../jobServer/Job"); const log = require("../../../../api/utils/log.js")("job:logger:clear"); const DEFAULT_MAX_ENTRIES = 1000; @@ -10,7 +11,19 @@ const DEFAULT_MAX_ENTRIES = 1000; /** * clears logs */ -class ClearJob extends JOB.Job { +class ClearJob extends Job { + + /** + * Get the schedule configuration for this job + * @returns {GetScheduleConfig} schedule configuration + */ + getSchedule() { + return { + type: "schedule", + value: "*/5 * * * *" // every 5 minutes + }; + } + /** * Cleans up the logs{APPID} collection * @param {Database} db mongodb database instance From b91fe81e9f563b026289f98eb0b238d7a8a62726 Mon Sep 17 00:00:00 2001 From: Kanwar Ujjaval Singh <4216199+kanwarujjaval@users.noreply.github.com> Date: Thu, 23 Jan 2025 23:29:30 +0530 Subject: [PATCH 35/90] migrate reports jobs --- plugins/reports/api/api.js | 12 ++++++------ plugins/reports/api/jobs/send.js | 25 +++++++++++++++++++------ 2 files changed, 25 insertions(+), 12 deletions(-) diff --git a/plugins/reports/api/api.js b/plugins/reports/api/api.js index 38dfe3487d5..fdbbef28218 100644 --- a/plugins/reports/api/api.js +++ b/plugins/reports/api/api.js @@ -14,12 +14,12 @@ const FEATURE_NAME = 'reports'; ob.features.push(FEATURE_NAME); }); - plugins.register("/master", function() { - // Allow configs to load & scanner to find all jobs classes - setTimeout(() => { - require('../../../api/parts/jobs').job('reports:send').replace().schedule("every 1 hour starting on the 0 min"); - }, 10000); - }); + // plugins.register("/master", function() { + // // Allow configs to load & scanner to find all jobs classes + // setTimeout(() => { + // require('../../../api/parts/jobs').job('reports:send').replace().schedule("every 1 hour starting on the 0 min"); + // }, 10000); + // }); /** * @api {get} /o/reports/all Get reports data diff --git a/plugins/reports/api/jobs/send.js b/plugins/reports/api/jobs/send.js index 0b893861086..52dcbe681e1 100644 --- a/plugins/reports/api/jobs/send.js +++ b/plugins/reports/api/jobs/send.js @@ -1,16 +1,29 @@ 'use strict'; -const job = require('../../../../api/parts/jobs/job.js'), - log = require('../../../../api/utils/log.js')('job:reports'); -var plugins = require('../../../pluginManager.js'), - async = require("async"), - reports = require("../reports"); +// const job = require('../../../../api/parts/jobs/job.js'), +const Job = require('../../../../jobServer/Job'); +const log = require('../../../../api/utils/log.js')('job:reports'); +const plugins = require('../../../pluginManager.js'); +const async = require("async"); +const reports = require("../reports"); /** * @class * @classdesc Class ReportsJob is report Job extend from Countly Job * @extends Job */ -class ReportsJob extends job.Job { +class ReportsJob extends Job { + + /** + * Get the schedule configuration for this job + * @returns {GetScheduleConfig} schedule configuration + */ + getSchedule() { + return { + type: "schedule", + value: "0 * * * *" // every hour + }; + } + /** * run task * @param {object} countlyDb - db object From 84fba2806cc4e32b00e86719a6fc98a6da75d504 Mon Sep 17 00:00:00 2001 From: Kanwar Ujjaval Singh <4216199+kanwarujjaval@users.noreply.github.com> Date: Thu, 23 Jan 2025 23:29:34 +0530 Subject: [PATCH 36/90] migrate stats jobs --- plugins/server-stats/api/api.js | 12 +++++------ plugins/server-stats/api/jobs/stats.js | 30 +++++++++++++++++++------- 2 files changed, 28 insertions(+), 14 deletions(-) diff --git a/plugins/server-stats/api/api.js b/plugins/server-stats/api/api.js index abd498dd544..a096f5c903b 100644 --- a/plugins/server-stats/api/api.js +++ b/plugins/server-stats/api/api.js @@ -18,12 +18,12 @@ const internalEventsSkipped = ["[CLY]_orientation"]; ob.features.push(FEATURE_NAME); }); - plugins.register("/master", function() { - // Allow configs to load & scanner to find all jobs classes - setTimeout(() => { - require('../../../api/parts/jobs').job('server-stats:stats').replace().schedule('every 1 day'); - }, 10000); - }); + // plugins.register("/master", function() { + // // Allow configs to load & scanner to find all jobs classes + // setTimeout(() => { + // require('../../../api/parts/jobs').job('server-stats:stats').replace().schedule('every 1 day'); + // }, 10000); + // }); /** * @param {string} events - events to be mapped diff --git a/plugins/server-stats/api/jobs/stats.js b/plugins/server-stats/api/jobs/stats.js index d112fc1913c..647f6c96bad 100644 --- a/plugins/server-stats/api/jobs/stats.js +++ b/plugins/server-stats/api/jobs/stats.js @@ -1,12 +1,12 @@ 'use strict'; -const job = require('../../../../api/parts/jobs/job.js'), - tracker = require('../../../../api/parts/mgmt/tracker.js'), - log = require('../../../../api/utils/log.js')('job:stats'), - config = require("../../../../frontend/express/config.js"), - pluginManager = require('../../../pluginManager.js'), - moment = require('moment-timezone'), - request = require('countly-request')(pluginManager.getConfig("security")); +// const job = require('../../../../api/parts/jobs/job.js'); +const Job = require('../../../../jobServer/Job'); +const tracker = require('../../../../api/parts/mgmt/tracker.js'); +const log = require('../../../../api/utils/log.js')('job:stats'); +const config = require("../../../../frontend/express/config.js"); +const pluginManager = require('../../../pluginManager.js'); +const moment = require('moment-timezone'); const promisedLoadConfigs = function(db) { return new Promise((resolve) => { @@ -17,7 +17,19 @@ const promisedLoadConfigs = function(db) { }; /** Representing a StatsJob. Inherits api/parts/jobs/job.js (job.Job) */ -class StatsJob extends job.Job { +class StatsJob extends Job { + + /** + * Get the schedule configuration for this job + * @returns {GetScheduleConfig} schedule configuration + */ + getSchedule() { + return { + type: "schedule", + value: "0 3 * * *" // Every day at 2:00 AM + }; + } + /** * Inherits api/parts/jobs/job.js, please review for detailed description * @param {string|Object} name - Name for job @@ -67,6 +79,8 @@ class StatsJob extends job.Job { var usersData = []; await promisedLoadConfigs(db); + // getConfig for security after loadConfigs + const request = require('countly-request')(pluginManager.getConfig("security")); let domain = ''; From 774822c324d93ef6c36e08bf119a36c675cad5ac Mon Sep 17 00:00:00 2001 From: Kanwar Ujjaval Singh <4216199+kanwarujjaval@users.noreply.github.com> Date: Thu, 23 Jan 2025 23:29:39 +0530 Subject: [PATCH 37/90] migrate views jobs --- plugins/views/api/api.js | 12 ++++++------ plugins/views/api/jobs/cleanupMeta.js | 17 +++++++++++++++-- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/plugins/views/api/api.js b/plugins/views/api/api.js index 8d5d9cdbca6..0dcb8764ae0 100644 --- a/plugins/views/api/api.js +++ b/plugins/views/api/api.js @@ -33,12 +33,12 @@ const escapedViewSegments = { "name": true, "segment": true, "height": true, "wi common.dbUniqueMap.users.push("vc"); }); - plugins.register("/master", function() { - // Allow configs to load & scanner to find all jobs classes - setTimeout(() => { - require('../../../api/parts/jobs').job('views:cleanupMeta')?.replace()?.schedule("every 1 day"); - }, 3000); - }); + // plugins.register("/master", function() { + // // Allow configs to load & scanner to find all jobs classes + // setTimeout(() => { + // require('../../../api/parts/jobs').job('views:cleanupMeta')?.replace()?.schedule("every 1 day"); + // }, 3000); + // }); plugins.register("/i/user_merge", function(ob) { var newAppUser = ob.newAppUser; diff --git a/plugins/views/api/jobs/cleanupMeta.js b/plugins/views/api/jobs/cleanupMeta.js index 939655a05ff..f44321e57f2 100644 --- a/plugins/views/api/jobs/cleanupMeta.js +++ b/plugins/views/api/jobs/cleanupMeta.js @@ -1,4 +1,5 @@ -const job = require('../../../../api/parts/jobs/job.js'); +// const job = require('../../../../api/parts/jobs/job.js'); +const Job = require('../../../../jobServer/Job'); const log = require('../../../../api/utils/log.js')('job:views:cleanup_meta'); var Promise = require("bluebird"); @@ -8,7 +9,19 @@ const viewsUtils = require("../parts/viewsUtils.js"); /** * Class for running job */ -class CleanupMetaJob extends job.Job { +class CleanupMetaJob extends Job { + + /** + * Get the schedule configuration for this job + * @returns {GetScheduleConfig} schedule configuration + */ + getSchedule() { + return { + type: "schedule", + value: "0 4 * * *" // every day at 4am + }; + } + /** * Get's called to run the job * @param {mongoDatabase} countlyDb ref to countlyDb From 254a52c7bb9f6cadbe853cd9017d37580c682a16 Mon Sep 17 00:00:00 2001 From: Kanwar Ujjaval Singh <4216199+kanwarujjaval@users.noreply.github.com> Date: Thu, 23 Jan 2025 23:30:26 +0530 Subject: [PATCH 38/90] disable api.js jobs --- api/api.js | 58 +++++++++++++++++++++++++++++------------------------- 1 file changed, 31 insertions(+), 27 deletions(-) diff --git a/api/api.js b/api/api.js index df712833b8b..31f3404d393 100644 --- a/api/api.js +++ b/api/api.js @@ -4,7 +4,7 @@ const formidable = require('formidable'); const os = require('os'); const countlyConfig = require('./config', 'dont-enclose'); const plugins = require('../plugins/pluginManager.js'); -const jobs = require('./parts/jobs'); +// const jobs = require('./parts/jobs'); const log = require('./utils/log.js')('core:api'); const common = require('./utils/common.js'); const {processRequest} = require('./utils/requestProcessor'); @@ -123,22 +123,26 @@ plugins.connectToAllDatabases().then(function() { /** * Set Plugins Logs Config */ - plugins.setConfigs('logs', { - debug: (countlyConfig.logging && countlyConfig.logging.debug) ? countlyConfig.logging.debug.join(', ') : '', - info: (countlyConfig.logging && countlyConfig.logging.info) ? countlyConfig.logging.info.join(', ') : '', - warn: (countlyConfig.logging && countlyConfig.logging.warn) ? countlyConfig.logging.warn.join(', ') : '', - error: (countlyConfig.logging && countlyConfig.logging.error) ? countlyConfig.logging.error.join(', ') : '', - default: (countlyConfig.logging && countlyConfig.logging.default) ? countlyConfig.logging.default : 'warn', - }, undefined, () => { - const cfg = plugins.getConfig('logs'), msg = { - cmd: 'log', - config: cfg - }; - if (process.send) { - process.send(msg); + plugins.setConfigs('logs', + { + debug: (countlyConfig.logging && countlyConfig.logging.debug) ? countlyConfig.logging.debug.join(', ') : '', + info: (countlyConfig.logging && countlyConfig.logging.info) ? countlyConfig.logging.info.join(', ') : '', + warn: (countlyConfig.logging && countlyConfig.logging.warn) ? countlyConfig.logging.warn.join(', ') : '', + error: (countlyConfig.logging && countlyConfig.logging.error) ? countlyConfig.logging.error.join(', ') : '', + default: (countlyConfig.logging && countlyConfig.logging.default) ? countlyConfig.logging.default : 'warn', + }, + undefined, + () => { + const cfg = plugins.getConfig('logs'), msg = { + cmd: 'log', + config: cfg + }; + if (process.send) { + process.send(msg); + } + require('./utils/log.js').ipcHandler(msg); } - require('./utils/log.js').ipcHandler(msg); - }); + ); /** * Initialize Plugins @@ -305,17 +309,17 @@ plugins.connectToAllDatabases().then(function() { plugins.dispatch("/master", {}); // Allow configs to load & scanner to find all jobs classes - setTimeout(() => { - jobs.job('api:topEvents').replace().schedule('at 00:01 am ' + 'every 1 day'); - jobs.job('api:ping').replace().schedule('every 1 day'); - jobs.job('api:clear').replace().schedule('every 1 day'); - jobs.job('api:clearTokens').replace().schedule('every 1 day'); - jobs.job('api:clearAutoTasks').replace().schedule('every 1 day'); - jobs.job('api:task').replace().schedule('every 5 minutes'); - jobs.job('api:userMerge').replace().schedule('every 10 minutes'); - jobs.job("api:ttlCleanup").replace().schedule("every 1 minute"); - //jobs.job('api:appExpire').replace().schedule('every 1 day'); - }, 10000); + // setTimeout(() => { + // jobs.job('api:topEvents').replace().schedule('at 00:01 am ' + 'every 1 day'); // PORTED + // jobs.job('api:ping').replace().schedule('every 1 day'); // PORTED + // jobs.job('api:clear').replace().schedule('every 1 day'); // REMOVED + // jobs.job('api:clearTokens').replace().schedule('every 1 day'); // PORTED + // jobs.job('api:clearAutoTasks').replace().schedule('every 1 day'); // PORTED + // jobs.job('api:task').replace().schedule('every 5 minutes'); // PORTED + // jobs.job('api:userMerge').replace().schedule('every 10 minutes'); // PORTED + // jobs.job("api:ttlCleanup").replace().schedule("every 1 minute"); // PORTED + //jobs.job('api:appExpire').replace().schedule('every 1 day'); // Deprecated + // }, 10000); //Record as restarted From dbb0981a4b8f498dde5a2e9d22335a8f678bf64f Mon Sep 17 00:00:00 2001 From: Kanwar Ujjaval Singh <4216199+kanwarujjaval@users.noreply.github.com> Date: Thu, 23 Jan 2025 23:30:39 +0530 Subject: [PATCH 39/90] migrate core jobs --- api/jobs/clearAutoTasks.js | 17 ++++++++++++-- api/jobs/clearTokens.js | 19 ++++++++++++--- api/jobs/ping.js | 47 ++++++++++++++++++++++++++------------ api/jobs/task.js | 23 +++++++++++++++---- api/jobs/topEvents.js | 16 +++++++++++-- api/jobs/ttlCleanup.js | 17 ++++++++++++-- api/jobs/userMerge.js | 25 +++++++++++++++----- 7 files changed, 129 insertions(+), 35 deletions(-) diff --git a/api/jobs/clearAutoTasks.js b/api/jobs/clearAutoTasks.js index f2bf6e929ac..8174626a96b 100644 --- a/api/jobs/clearAutoTasks.js +++ b/api/jobs/clearAutoTasks.js @@ -1,8 +1,10 @@ "use strict"; -const job = require("../parts/jobs/job.js"); +// const job = require("../parts/jobs/job.js"); const log = require('../utils/log.js')('job:clearAutoTasks'); const taskManager = require('../utils/taskmanager'); +const Job = require("../../jobServer/Job"); + /** * clear task record in db with task id * @param {string} taskId - the id of task in db. @@ -20,7 +22,18 @@ const clearTaskRecord = (taskId) => { }; /** Class for job of clearing auto tasks created long time ago **/ -class ClearAutoTasks extends job.Job { +class ClearAutoTasks extends Job { + /** + * Get the schedule configuration for this job + * @returns {GetScheduleConfig} schedule configuration + */ + getSchedule() { + return { + type: "schedule", + value: "0 2 * * *" // Every day at 2:00 AM + }; + } + /** * Run the job * @param {Db} db connection diff --git a/api/jobs/clearTokens.js b/api/jobs/clearTokens.js index 2a3932c2313..9587479acc7 100644 --- a/api/jobs/clearTokens.js +++ b/api/jobs/clearTokens.js @@ -1,10 +1,23 @@ 'use strict'; -const job = require('../parts/jobs/job.js'), - authorize = require('../utils/authorizer.js'); +// const job = require('../parts/jobs/job.js'), +const authorize = require('../utils/authorizer.js'); +const Job = require("../../jobServer/Job"); /** Class for job of clearing tokens **/ -class CleanTokensJob extends job.Job { +class CleanTokensJob extends Job { + + /** + * Get the schedule configuration for this job + * @returns {GetScheduleConfig} schedule configuration + */ + getSchedule() { + return { + type: "schedule", + value: "30 2 * * *" // Every day at 2:30 AM + }; + } + /** * Run the job * @param {Db} db connection diff --git a/api/jobs/ping.js b/api/jobs/ping.js index 984e82c566c..f9c81a5780d 100644 --- a/api/jobs/ping.js +++ b/api/jobs/ping.js @@ -1,28 +1,45 @@ 'use strict'; -const job = require('../parts/jobs/job.js'), - log = require('../utils/log.js')('job:ping'), - countlyConfig = require("../../frontend/express/config.js"), - versionInfo = require('../../frontend/express/version.info'), - plugins = require('../../plugins/pluginManager.js'), - request = require('countly-request')(plugins.getConfig("security")); +// const job = require('../parts/jobs/job.js'); +const log = require('../utils/log.js')('job:ping'); +const countlyConfig = require("../../frontend/express/config.js"); +const versionInfo = require('../../frontend/express/version.info'); +const plugins = require('../../plugins/pluginManager.js'); + + +const Job = require("../../jobServer/Job"); /** Class for the job of pinging servers **/ -class PingJob extends job.Job { +class PingJob extends Job { + + /** + * Get the schedule configuration for this job + * @returns {GetScheduleConfig} schedule configuration + */ + getSchedule() { + return { + type: "schedule", + value: "0 1 * * *" // Every day at 1:00 AM + }; + } + /** * Run the ping job * @param {Db} db connection * @param {done} done callback */ run(db, done) { - request({strictSSL: false, uri: (process.env.COUNTLY_CONFIG_PROTOCOL || "http") + "://" + (process.env.COUNTLY_CONFIG_HOSTNAME || "localhost") + (countlyConfig.path || "") + "/configs"}, function() {}); - var countlyConfigOrig = JSON.parse(JSON.stringify(countlyConfig)); - var url = "https://count.ly/configurations/ce/tracking"; - if (versionInfo.type !== "777a2bf527a18e0fffe22fb5b3e322e68d9c07a6") { - url = "https://count.ly/configurations/ee/tracking"; - } + plugins.loadConfigs(db, function() { + const request = require('countly-request')(plugins.getConfig("security")); + request({strictSSL: false, uri: (process.env.COUNTLY_CONFIG_PROTOCOL || "http") + "://" + (process.env.COUNTLY_CONFIG_HOSTNAME || "localhost") + (countlyConfig.path || "") + "/configs"}, function() {}); + var countlyConfigOrig = JSON.parse(JSON.stringify(countlyConfig)); + var url = "https://count.ly/configurations/ce/tracking"; + if (versionInfo.type !== "777a2bf527a18e0fffe22fb5b3e322e68d9c07a6") { + url = "https://count.ly/configurations/ee/tracking"; + } + const offlineMode = plugins.getConfig("api").offline_mode; const { countly_tracking } = plugins.getConfig('frontend'); if (!offlineMode) { @@ -56,12 +73,12 @@ class PingJob extends job.Job { let domain = plugins.getConfig('api').domain; try { - // try to extract hostname from full domain url + // try to extract hostname from full domain url const urlObj = new URL(domain); domain = urlObj.hostname; } catch (_) { - // do nothing, domain from config will be used as is + // do nothing, domain from config will be used as is } request({ diff --git a/api/jobs/task.js b/api/jobs/task.js index 06b6893a7e7..adf4d2cc294 100644 --- a/api/jobs/task.js +++ b/api/jobs/task.js @@ -1,9 +1,10 @@ 'use strict'; -const job = require('../parts/jobs/job.js'), - log = require('../utils/log.js')('api:task'), - asyncjs = require("async"), - plugins = require('../../plugins/pluginManager.js'); +// const job = require('../parts/jobs/job.js'); +const Job = require("../../jobServer/Job"); +const log = require('../utils/log.js')('api:task'); +const asyncjs = require("async"); +const plugins = require('../../plugins/pluginManager.js'); const common = require('../utils/common.js'); const taskmanager = require('../utils/taskmanager.js'); @@ -14,7 +15,19 @@ common.processRequest = processRequest; /** * Task Monitor Job extend from Countly Job */ -class MonitorJob extends job.Job { +class MonitorJob extends Job { + + /** + * Get the schedule configuration for this job + * @returns {GetScheduleConfig} schedule configuration + */ + getSchedule() { + return { + type: "schedule", + value: "*/5 * * * *" // Every 5 minutes + }; + } + /** * Run the job * @param {Db} db connection diff --git a/api/jobs/topEvents.js b/api/jobs/topEvents.js index 44913509632..10d6161ffe4 100644 --- a/api/jobs/topEvents.js +++ b/api/jobs/topEvents.js @@ -1,4 +1,5 @@ -const job = require("../parts/jobs/job.js"); +// const job = require("../parts/jobs/job.js"); +const Job = require("../../jobServer/Job"); const crypto = require("crypto"); const Promise = require("bluebird"); const countlyApi = { @@ -14,7 +15,7 @@ const common = require('../utils/common.js'); const log = require('../utils/log.js')('job:topEvents'); /** Class for job of top events widget **/ -class TopEventsJob extends job.Job { +class TopEventsJob extends Job { /** * TopEvents initialize function @@ -215,6 +216,17 @@ class TopEventsJob extends job.Job { this.init(); done(); } + + /** + * Get schedule + * @returns {GetScheduleConfig} schedule + */ + getSchedule() { + return { + type: "schedule", + value: "1 0 * * *" // every day at 00:01 + }; + } } /** diff --git a/api/jobs/ttlCleanup.js b/api/jobs/ttlCleanup.js index 1c168d38b21..5de0df26dbd 100644 --- a/api/jobs/ttlCleanup.js +++ b/api/jobs/ttlCleanup.js @@ -1,12 +1,25 @@ const plugins = require("../../plugins/pluginManager.js"); const common = require('../utils/common'); -const job = require("../parts/jobs/job.js"); +// const job = require("../parts/jobs/job.js"); const log = require("../utils/log.js")("job:ttlCleanup"); +const Job = require("../../jobServer/Job"); /** * Class for job of cleaning expired records inside ttl collections */ -class TTLCleanup extends job.Job { +class TTLCleanup extends Job { + + /** + * Get the schedule configuration for this job + * @returns {GetScheduleConfig} schedule configuration + */ + getSchedule() { + return { + type: "schedule", + value: "* * * * *" // Every minute + }; + } + /** * Run the job */ diff --git a/api/jobs/userMerge.js b/api/jobs/userMerge.js index 17d3a8022b7..871a2b1ceff 100644 --- a/api/jobs/userMerge.js +++ b/api/jobs/userMerge.js @@ -1,10 +1,11 @@ 'use strict'; -const job = require('../parts/jobs/job.js'), - plugins = require('../../plugins/pluginManager.js'), - log = require('../utils/log.js')('job:userMerge'); -var Promise = require("bluebird"); -var usersApi = require('../parts/mgmt/app_users.js'); +// const job = require('../parts/jobs/job.js'); +const Job = require("../../jobServer/Job"); +const plugins = require('../../plugins/pluginManager.js'); +const log = require('../utils/log.js')('job:userMerge'); +const Promise = require("bluebird"); +const usersApi = require('../parts/mgmt/app_users.js'); var getMergeDoc = function(data) { @@ -223,7 +224,19 @@ var handleMerges = function(db, callback) { }); }; /** Class for the user mergind job **/ -class UserMergeJob extends job.Job { +class UserMergeJob extends Job { + + /** + * Get the schedule configuration for this job + * @returns {GetScheduleConfig} schedule configuration + */ + getSchedule() { + return { + type: "schedule", + value: "*/5 * * * *" // Every 5 minutes + }; + } + /** * Run the job * @param {Db} db connection From b87ae763b16c13c2166831df5eaad320db94d135 Mon Sep 17 00:00:00 2001 From: Kanwar Ujjaval Singh <4216199+kanwarujjaval@users.noreply.github.com> Date: Thu, 23 Jan 2025 23:30:58 +0530 Subject: [PATCH 40/90] prevent double loading of jobs in old and new --- api/parts/jobs/scanner.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/api/parts/jobs/scanner.js b/api/parts/jobs/scanner.js index 771e234ed97..2050a0eb1c7 100644 --- a/api/parts/jobs/scanner.js +++ b/api/parts/jobs/scanner.js @@ -83,7 +83,12 @@ module.exports = (db, filesObj, classesObj) => { } } catch (e) { - log.e('Error when loading job %s: %j ', job.file, e, e.stack); + if (e.message === "Job class must extend Job, IPCJob, IPCFaçadeJob, or TransientJob") { + // do nothing + } + else { + log.e('Error when loading job %s: %j ', job.file, e, e.stack); + } } }); }); From b7edf022505820e051ebda3ca18b78e36e37ed4c Mon Sep 17 00:00:00 2001 From: Kanwar Ujjaval Singh <4216199+kanwarujjaval@users.noreply.github.com> Date: Thu, 23 Jan 2025 23:31:32 +0530 Subject: [PATCH 41/90] fixes for runnow and priority/retry configs --- jobServer/jobRunner/impl/pulse/PulseJobExecutor.js | 4 ++-- jobServer/jobRunner/impl/pulse/PulseJobScheduler.js | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/jobServer/jobRunner/impl/pulse/PulseJobExecutor.js b/jobServer/jobRunner/impl/pulse/PulseJobExecutor.js index a55c3bc1fd1..2be672c1279 100644 --- a/jobServer/jobRunner/impl/pulse/PulseJobExecutor.js +++ b/jobServer/jobRunner/impl/pulse/PulseJobExecutor.js @@ -60,7 +60,7 @@ class PulseJobExecutor extends IJobExecutor { concurrency, lockLifetime, shouldSaveResult: true, - attempts: retryConfig?.enabled ? retryConfig.attempts : 1, + attempts: retryConfig?.enabled ? retryConfig.attempts : 0, backoff: retryConfig?.enabled ? { type: 'exponential', delay: retryConfig.delay @@ -200,7 +200,7 @@ class PulseJobExecutor extends IJobExecutor { [JOB_PRIORITIES.HIGHEST]: JobPriority.highest }; - if (!priority || !priorityMap[priority]) { + if (!priority || priorityMap[priority] === undefined) { this.log.w(`Invalid priority "${priority}", defaulting to normal`); return JobPriority.normal; } diff --git a/jobServer/jobRunner/impl/pulse/PulseJobScheduler.js b/jobServer/jobRunner/impl/pulse/PulseJobScheduler.js index 8c1d5f50b8a..951494bd4cd 100644 --- a/jobServer/jobRunner/impl/pulse/PulseJobScheduler.js +++ b/jobServer/jobRunner/impl/pulse/PulseJobScheduler.js @@ -162,11 +162,12 @@ class PulseJobScheduler extends IJobScheduler { * @param {string} jobName - Name of the job to execute * @returns {Promise} Resolves when job is successfully triggered * @throws {Error} If immediate execution fails + * @note Implement data passing if needed */ async runJobNow(jobName) { try { this.log.d(`Attempting to trigger immediate execution of job '${jobName}'`); - await this.pulseRunner.now({ name: jobName }); + await this.pulseRunner.now(jobName, {}); this.log.i(`Successfully triggered immediate execution of job '${jobName}'`); } catch (error) { From 2f331105b5a344b6416a62a1187b9764736f0e56 Mon Sep 17 00:00:00 2001 From: Kanwar Ujjaval Singh <4216199+kanwarujjaval@users.noreply.github.com> Date: Thu, 23 Jan 2025 23:31:52 +0530 Subject: [PATCH 42/90] scan new job every 30 seconds --- jobServer/config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jobServer/config.js b/jobServer/config.js index ceef4454397..22a9125b63a 100644 --- a/jobServer/config.js +++ b/jobServer/config.js @@ -15,7 +15,7 @@ * @property {string} db.collection - MongoDB collection name for storing jobs */ const DEFAULT_PULSE_CONFIG = { - processEvery: '60 seconds', + processEvery: '30 seconds', maxConcurrency: 1, defaultConcurrency: 1, lockLimit: 1, From 70f5b6af8722e09b22e40483f58dfa2b0900fd48 Mon Sep 17 00:00:00 2001 From: Kanwar Ujjaval Singh <4216199+kanwarujjaval@users.noreply.github.com> Date: Thu, 23 Jan 2025 23:32:48 +0530 Subject: [PATCH 43/90] refactoring and backward compatiblity injections in jobserver --- jobServer/Job.js | 28 ++--- jobServer/JobManager.js | 32 +++-- jobServer/JobScanner.js | 4 +- jobServer/JobServer.js | 95 +++++---------- jobServer/index.js | 255 ++++++++++++++++++++++++++++++++++++---- 5 files changed, 303 insertions(+), 111 deletions(-) diff --git a/jobServer/Job.js b/jobServer/Job.js index 4699c862e42..76f2271e617 100644 --- a/jobServer/Job.js +++ b/jobServer/Job.js @@ -1,4 +1,4 @@ -const defaultLogger = { d: console.debug, w: console.warn, e: console.error, i: console.info }; +const Logger = require('../api/utils/log.js'); const { JOB_PRIORITIES } = require('./constants/JobPriorities'); /** @@ -19,7 +19,7 @@ const { JOB_PRIORITIES } = require('./constants/JobPriorities'); */ /** - * @typedef {Object} ScheduleConfig + * @typedef {Object} GetScheduleConfig * @property {('once'|'schedule'|'now')} type - Type of schedule * @property {(string|Date)} [value] - Schedule value (cron expression or Date) */ @@ -79,12 +79,10 @@ const { JOB_PRIORITIES } = require('./constants/JobPriorities'); * } */ class Job { + /** @type {string} Name of the job */ jobName = Job.name; - /** @type {Logger} Logger instance */ - logger; - /** @type {Function|null} Touch method from job runner */ #touchMethod = null; @@ -98,8 +96,8 @@ class Job { * Creates an instance of Job. */ constructor() { - this.logger = defaultLogger; - this.logger.d(`Job instance "${this.jobName}" created`); + this.log = Logger('jobs:job'); + this.log.d(`Job instance "${this.jobName}" created`); } /** @@ -114,8 +112,8 @@ class Job { * Sets the logger * @param {Object} [logger=defaultLogger] The logger instance */ - setLogger(logger = defaultLogger) { - this.logger = logger; + setLogger(logger = this.log) { + this.log = logger; } /** @@ -160,7 +158,7 @@ class Job { * Get the schedule type and timing for the job. * This method must be implemented by the child class. * - * @returns {Object} Schedule configuration object + * @returns {GetScheduleConfig} Schedule configuration object * @property {('once'|'schedule'|'now')} type - Type of schedule * @property {string|Date} [value] - Schedule value: * - For type='schedule': Cron expression (e.g., '0 * * * *' for hourly) @@ -209,7 +207,7 @@ class Job { * @private */ async _run(db, job, done) { - this.logger.d(`[Job:${this.jobName}] Starting execution`, { + this.log.d(`[Job:${this.jobName}] Starting execution`, { database: db?._cly_debug?.db, jobId: job?.attrs?._id, jobName: this.jobName @@ -243,14 +241,14 @@ class Job { } }); - this.logger.i(`[Job:${this.jobName}] Completed successfully`, { + this.log.i(`[Job:${this.jobName}] Completed successfully`, { result: result || null, duration: `${Date.now() - job?.attrs?.lastRunAt?.getTime()}ms` }); done(null, result); } catch (error) { - this.logger.e(`[Job:${this.jobName}] Execution failed`, { + this.log.e(`[Job:${this.jobName}] Execution failed`, { error: error.message, stack: error.stack, duration: `${Date.now() - job?.attrs?.lastRunAt?.getTime()}ms` @@ -304,7 +302,7 @@ class Job { await this.#touchMethod(progress); } - this.logger?.d(`[Job:${this.jobName}] Progress update`, progressData); + this.log?.d(`[Job:${this.jobName}] Progress update`, progressData); } /** @@ -318,7 +316,7 @@ class Job { getRetryConfig() { return { enabled: false, - attempts: 3, + attempts: 0, delay: 5 * 60 * 1000 // 5 minutes }; } diff --git a/jobServer/JobManager.js b/jobServer/JobManager.js index 1f3c675975e..6d5fc536b28 100644 --- a/jobServer/JobManager.js +++ b/jobServer/JobManager.js @@ -18,6 +18,11 @@ const JobUtils = require('./JobUtils'); * @property {Date} [updatedAt] - When the config was last updated * @property {string} checksum - Hash of the job implementation * @property {Object} defaultConfig - Original job configuration + * @property {Object} defaultConfig.schedule - Default schedule configuration + * @property {Object} defaultConfig.retry - Default retry configuration + * @property {number} defaultConfig.priority - Default job priority + * @property {number} defaultConfig.concurrency - Default concurrency limit + * @property {number} defaultConfig.lockLifetime - Default lock lifetime in milliseconds */ /** @@ -75,11 +80,24 @@ class JobManager { changeStream.on('change', async(change) => { this.#log.d('Detected config change:', { operationType: change.operationType, - jobName: change.fullDocument?.jobName + documentId: change.documentKey?._id, + updatedFields: change.updateDescription?.updatedFields }); - if (change.operationType === 'update' || change.operationType === 'insert') { - const jobConfig = change.fullDocument; - await this.#applyConfig(jobConfig); + + // Dont really need this, as we are only updating the config + // if (change.operationType === 'insert') { + // const jobConfig = change.fullDocument; + // await this.#applyConfig(jobConfig); + // } + // else + if (change.operationType === 'update') { + // Fetch the complete document after update + const jobConfig = await this.#jobConfigsCollection.findOne({ + _id: change.documentKey._id + }); + if (jobConfig) { + await this.#applyConfig({...change?.updateDescription?.updatedFields, jobName: jobConfig.jobName}); + } } }); } @@ -123,8 +141,8 @@ class JobManager { await this.#jobRunner.configureRetry(jobName, jobConfig.retry); } - if (typeof jobConfig.enabled === 'boolean') { - if (jobConfig.enabled) { + if (typeof jobConfig?.enabled === 'boolean') { + if (jobConfig?.enabled) { await this.#jobRunner.enableJob(jobName); this.#log.i(`Job ${jobName} enabled via config`); } @@ -136,7 +154,7 @@ class JobManager { } catch (error) { this.#log.e('Failed to apply job configuration:', { - jobName: jobConfig.jobName, + jobName: jobConfig?.jobName || "unknown", error: error.message, stack: error.stack }); diff --git a/jobServer/JobScanner.js b/jobServer/JobScanner.js index 616dc988375..53fefb8082b 100644 --- a/jobServer/JobScanner.js +++ b/jobServer/JobScanner.js @@ -129,7 +129,7 @@ class JobScanner { catch (err) { const message = `Failed to read directory ${jobConfig.dir}: ${err.message}`; if (jobConfig.required) { - this.#log.e(message, err); + this.#log.w(message, err); throw new Error(message); } this.#log.w(message); @@ -159,7 +159,7 @@ class JobScanner { }; } catch (err) { - this.#log.e(`Failed to load job ${job.file}: ${err.message}`, err); + this.#log.w(`Failed to load job ${job.file}: ${err.message}`, err); return null; } } diff --git a/jobServer/JobServer.js b/jobServer/JobServer.js index e73415c2838..7d62ec807d4 100644 --- a/jobServer/JobServer.js +++ b/jobServer/JobServer.js @@ -31,13 +31,6 @@ class JobServer { */ #pluginManager; - /** - * The Countly common object - * @private - * @type {import('../api/utils/common.js')} - */ - #common; - /** * The job manager instance * @private @@ -78,31 +71,44 @@ class JobServer { */ #isShuttingDown = false; + /** + * @typedef {Object} DbConnections + * @property {MongoDb} countlyDb - Main Countly database connection + * @property {MongoDb} drillDb - Drill database connection + * @property {MongoDb} outDb - Output database connection + */ + /** + * The database connections object + * @private + * @type {DbConnections} + */ + #dbConnections; + /** * Factory method to create and initialize a new JobServer instance. - * @param {Object} common - Countly common utilities * @param {Logger} Logger - Logger constructor * @param {PluginManager} pluginManager - Plugin manager instance + * @param {DbConnections} dbConnections - Database connections object * @returns {Promise} A fully initialized JobServer instance * @throws {Error} If initialization fails */ - static async create(common, Logger, pluginManager) { - const process = new JobServer(common, Logger, pluginManager); + static async create(Logger, pluginManager, dbConnections) { + const process = new JobServer(Logger, pluginManager, dbConnections); await process.init(); return process; } /** * Creates a new JobServer instance - * @param {Object} common Countly common - * @param {function} Logger - Logger constructor - * @param {pluginManager} pluginManager - Plugin manager instance + * @param {Logger} Logger - Logger constructor + * @param {PluginManager} pluginManager - Plugin manager instance + * @param {Object} dbConnections - Database connections object */ - constructor(common, Logger, pluginManager) { - this.#common = common; + constructor(Logger, pluginManager, dbConnections) { this.Logger = Logger; - this.#log = Logger('jobs:server'); this.#pluginManager = pluginManager; + this.#dbConnections = dbConnections; + this.#log = Logger('jobs:server'); } /** @@ -114,16 +120,13 @@ class JobServer { async init() { try { this.#log.d('Initializing job server...'); - await this.#connectToDb(); - this.#jobManager = new JobManager(this.#db, this.Logger); - this.#jobScanner = new JobScanner(this.#db, this.Logger, this.#pluginManager); + this.#jobManager = new JobManager(this.#dbConnections.countlyDb, this.Logger); + this.#jobScanner = new JobScanner(this.#dbConnections.countlyDb, this.Logger, this.#pluginManager); - this.#jobConfigsCollection = this.#db.collection(JOBS_CONFIG_COLLECTION); + this.#jobConfigsCollection = this.#dbConnections.countlyDb.collection(JOBS_CONFIG_COLLECTION); // await this.#jobConfigsCollection.createIndex({ jobName: 1 }, /*{ unique: true }*/); - this.#setupSignalHandlers(); - this.#log.i('Job server initialized successfully'); } catch (error) { @@ -155,52 +158,16 @@ class JobServer { } catch (error) { this.#log.e('Critical error during server startup: %j', error); - await this.#shutdown(1); + await this.shutdown(1); } } - /** - * Connects to the mongo database. - * @returns {Promise} A promise that resolves once the connection is established. - */ - async #connectToDb() { - try { - this.#db = await this.#pluginManager.dbConnection('countly'); - } - catch (e) { - this.#log.e('Failed to connect to database:', e); - throw e; - } - } - - /** - * Sets up process signal handlers for graceful shutdown and error handling. - * @private - */ - #setupSignalHandlers() { - process.on('SIGTERM', () => { - this.#log.i('Received SIGTERM signal'); - this.#shutdown(); - }); - - process.on('SIGINT', () => { - this.#log.i('Received SIGINT signal'); - this.#shutdown(); - }); - - process.on('uncaughtException', (error) => { - this.#log.e('Uncaught exception in job server: %j', error); - this.#shutdown(1); - }); - } - /** * Gracefully shuts down the job server, closing connections and stopping jobs. - * @private * @param {number} [exitCode=0] - Process exit code * @returns {Promise} A promise that resolves once the job server is shut down */ - async #shutdown(exitCode = 0) { + async shutdown(exitCode = 0) { if (this.#isShuttingDown) { this.#log.d('Shutdown already in progress, skipping duplicate request'); return; @@ -209,9 +176,11 @@ class JobServer { this.#log.i('Initiating job server shutdown...'); - if (this.#db && typeof this.#db.close === 'function') { - this.#log.d('Closing database connection'); - await this.#db.close(); + for (const [key, db] of Object.entries(this.#dbConnections)) { + if (db && typeof db.close === 'function') { + this.#log.d(`Closing ${key} database connection`); + await db.close(); + } } if (!this.#isRunning) { diff --git a/jobServer/index.js b/jobServer/index.js index c942a38f769..6f443327fea 100644 --- a/jobServer/index.js +++ b/jobServer/index.js @@ -2,16 +2,16 @@ * @module jobServer * @version 2.0 * @author Countly - * + * * @typedef {import('../api/utils/common.js')} Common * @typedef {import('../plugins/pluginManager.js')} PluginManager * @typedef {import('./Job')} JobType * @typedef {import('./JobServer')} JobServerType - * + * * @note * Dependencies like common utilities and plugin manager should only be imported in this entry file * and injected into the respective modules via their constructors or create methods. - * + * * @description * This module serves as the entry point for Countly's job management system. * It provides a robust infrastructure for: @@ -19,14 +19,14 @@ * - Managing job lifecycles and states * - Handling job dependencies and priorities * - Providing process isolation for job execution - * + * * @exports {Object} module.exports * @property {JobType} Job - Class for creating and managing individual jobs * @property {JobServerType} JobServer - Class for running jobs in a separate process - * + * * @throws {Error} DatabaseConnectionError When database connection fails during initialization * @throws {Error} InvalidJobError When job definition is invalid - * + * * @example * // Import and create a new job * const { Job } = require('./jobs'); @@ -38,43 +38,250 @@ */ const JobServer = require('./JobServer'); -const Job = require('./Job'); +const Logger = require('../api/utils/log.js'); +const log = new Logger('jobServer:index'); +const CountlyRequest = require("countly-request"); +const {ReadBatcher, WriteBatcher} = require('../api/parts/data/batcher'); // Start the process if this file is run directly if (require.main === module) { const common = require('../api/utils/common.js'); const pluginManager = require('../plugins/pluginManager.js'); - const Logger = common.log; - const log = Logger('jobs:server'); + + /** + * @type {JobServer|null} + */ + let jobServer = null; log.i('Initializing job server process...'); - JobServer.create(common, Logger, pluginManager) - .then(process => { + + /** + * Initialize configuration + * @returns {Promise} A promise that resolves to an object containing configuration + */ + const initializeConfig = async() => { + try { + const config = await pluginManager.getConfig(); + log.d('Configuration initialized successfully'); + return config; + } + catch (error) { + log.e('Failed to initialize configuration:', { + error: error.message, + stack: error.stack + }); + throw new Error('Configuration initialization failed: ' + error.message); + } + }; + + /** + * Initialize countly request + * @param {Object} config - Configuration object + * @returns {Promise} A promise that resolves to an object containing countly request + */ + const initializeCountlyRequest = async(config) => { + try { + const countlyRequest = CountlyRequest(config.security); + log.d('Countly request initialized successfully'); + return countlyRequest; + } + catch (error) { + log.e('Failed to initialize countly request:', { + error: error.message, + stack: error.stack, + config: config ? 'present' : 'missing' + }); + throw new Error('Countly request initialization failed: ' + error.message); + } + }; + + /** + * Override common db objects with the provided connections + * @param {Object} dbConnections - Object containing database connections + */ + const overrideCommonDb = async(dbConnections) => { + try { + if (!dbConnections || !dbConnections.countlyDb || !dbConnections.drillDb || !dbConnections.outDb) { + throw new Error('Invalid database connections provided'); + } + const { countlyDb, drillDb, outDb } = dbConnections; + common.db = countlyDb; + common.drillDb = drillDb; + common.outDb = outDb; + log.d('Common db objects overridden successfully'); + } + catch (error) { + log.e('Failed to override common db objects:', { + error: error.message, + stack: error.stack, + connections: Object.keys(dbConnections || {}) + }); + throw new Error('Common db override failed: ' + error.message); + } + }; + + /** + * Override common batcher with the provided connections + * @param {Db} commonDb - Object containing database connections + */ + const overrideCommonBatcher = async(commonDb) => { + try { + common.writeBatcher = new WriteBatcher(commonDb); + common.readBatcher = new ReadBatcher(commonDb); + } + catch (error) { + log.e('Failed to override common batcher:', { + error: error.message, + stack: error.stack + }); + } + }; + + /** + * Initialize database connections + * @returns {Promise} A promise that resolves to an object containing database connections + */ + const initializeDbConnections = async() => { + try { + log.d('Initializing database connections...'); + const countlyDb = await pluginManager.dbConnection('countly'); + log.d('Countly DB connection established'); + + const drillDb = await pluginManager.dbConnection('countly_drill'); + log.d('Drill DB connection established'); + + const outDb = await pluginManager.dbConnection('countly_out'); + log.d('Out DB connection established'); + + return { + countlyDb, + drillDb, + outDb, + }; + } + catch (error) { + log.e('Failed to initialize database connections:', { + error: error.message, + stack: error.stack + }); + throw new Error('Database connections initialization failed: ' + error.message); + } + }; + + /** + * Override common dispatch with a Countly server request + * @param {Function} countlyRequest - Countly request function + * @param {Object} countlyConfig - Countly config object + */ + const overrideCommonDispatch = async(countlyRequest, countlyConfig) => { + try { + if (!countlyRequest || !countlyConfig) { + throw new Error('Invalid parameters for dispatch override'); + } + + const protocol = process.env.COUNTLY_CONFIG_PROTOCOL || "http"; + const hostname = process.env.COUNTLY_CONFIG_HOSTNAME || "localhost"; + const pathPrefix = countlyConfig.path || ""; + + // The base URL of the running Countly server + const baseUrl = protocol + "://" + hostname + pathPrefix; + log.d('Configuring dispatch override with base URL:', baseUrl); + + /** + * Keep the same signature, but perform a request to the running Countly server + * @param {string} event - The event name + * @param {Object} params - The event parameters + * @param {Function} callback - Callback function + */ + common.dispatch = function(event, params, callback) { + // Construct the request to your Countly server + countlyRequest.post({ + url: baseUrl + "/i/plugins/dispatch", + json: { event: event, params: params } + }, function(err, res, body) { + if (err) { + log.e("Error dispatching event to Countly server:", { + error: err, + event: event, + params: params, + baseUrl: baseUrl, + }); + } + if (typeof callback === 'function') { + return callback(err, res, body); + } + else { + log.e("Callback is not a function"); + } + }); + }; + log.d('Common dispatch successfully overridden'); + } + catch (error) { + log.e('Failed to override common dispatch:', { + error: error.message, + stack: error.stack, + config: countlyConfig ? 'present' : 'missing', + request: countlyRequest ? 'present' : 'missing' + }); + throw new Error('Dispatch override failed: ' + error.message); + } + }; + + + initializeConfig() + .then(async config => { + log.i('Starting job server initialization sequence...'); + + const countlyRequest = await initializeCountlyRequest(config); + await overrideCommonDispatch(countlyRequest, config); + + const dbConnections = await initializeDbConnections(); + await overrideCommonDb(dbConnections); + await overrideCommonBatcher(dbConnections.countlyDb); + + jobServer = await JobServer.create(Logger, pluginManager, dbConnections); + log.i('Job server successfully created, starting process...'); - return process.start(); + return jobServer.start(); }) .catch(error => { log.e('Critical error during job server initialization:', { error: error.message, - stack: error.stack + stack: error.stack, + type: error.constructor.name, + time: new Date().toISOString() }); - process.exit(1); + if (require.main === module) { + log.e('Exiting process due to critical initialization error'); + process.exit(1); + } }); - // Handle process signals for graceful shutdown - ['SIGTERM', 'SIGINT'].forEach(signal => { - process.on(signal, () => { - log.i(`Received ${signal}, initiating graceful shutdown...`); - // Allow time for cleanup before force exit - setTimeout(() => { - log.e('Forced shutdown after timeout'); - process.exit(1); - }, 10000); + if (require.main === module) { + ['SIGTERM', 'SIGINT'].forEach(signal => { + process.on(signal, () => { + log.i(`Received ${signal}, initiating graceful shutdown...`); + if (jobServer && typeof jobServer.shutdown === 'function') { + jobServer.shutdown(); + } + else { + setTimeout(() => { + log.e('Forced shutdown after timeout'); + process.exit(1); + }, 3000); + } + }); }); - }); + } } +/** + * Keeping old interface for backward compatibility + * @type {Job|{}} + */ +const Job = require('./Job'); module.exports = { Job: Job }; \ No newline at end of file From b25f4db7d2a03de8e83e845983fec05baa362910 Mon Sep 17 00:00:00 2001 From: Kanwar Ujjaval Singh <4216199+kanwarujjaval@users.noreply.github.com> Date: Thu, 23 Jan 2025 23:33:02 +0530 Subject: [PATCH 44/90] basic api for jobs --- api/utils/requestProcessor.js | 12 +- jobServer/api.js | 572 ++++++++++++++++++++++++++++++++++ 2 files changed, 582 insertions(+), 2 deletions(-) create mode 100644 jobServer/api.js diff --git a/api/utils/requestProcessor.js b/api/utils/requestProcessor.js index eaa5bffad52..17f71afe129 100644 --- a/api/utils/requestProcessor.js +++ b/api/utils/requestProcessor.js @@ -23,7 +23,14 @@ const validateUserForDataWriteAPI = validateUserForWrite; const validateUserForGlobalAdmin = validateGlobalAdmin; const validateUserForMgmtReadAPI = validateUser; const request = require('countly-request')(plugins.getConfig("security")); -const Handle = require('../../api/parts/jobs/index.js'); + +try { + require('../../jobServer/api'); + log.i('Job api loaded'); +} +catch (ex) { + log.e('Job api not available'); +} var loaded_configs_time = 0; @@ -2732,7 +2739,8 @@ const processRequest = (params) => { * */ validateUserForGlobalAdmin(params, async() => { - await Handle.suspendJob(params); + // await Handle.suspendJob(params); + // TODO: UPDATE TO NEW JOB PROCESSOR }); break; } diff --git a/jobServer/api.js b/jobServer/api.js new file mode 100644 index 00000000000..0908c517f17 --- /dev/null +++ b/jobServer/api.js @@ -0,0 +1,572 @@ +const Logger = require('../api/utils/log.js'); +const log = new Logger('jobs:api'); + +const plugins = require("../plugins/pluginManager"); +const {validateGlobalAdmin} = require("../api/utils/rights"); +const common = require("../api/utils/common"); +const cronstrue = require('cronstrue'); +const moment = require('moment'); + +/** + * Maps job status based on job properties + * @param {Object} job - The job object + * @param {Date} [job.lockedAt] - When the job was locked + * @param {Date} [job.failedAt] - When the job failed + * @param {Date} [job.lastFinishedAt] - When the job last finished + * @returns {string} Status - "RUNNING", "FAILED", "COMPLETED", or "SCHEDULED" + */ +const getJobStatus = (job) => { + if (job.lockedAt) { + return "RUNNING"; + } + if (job.failedAt) { + return "FAILED"; + } + if (job.lastFinishedAt) { + return "COMPLETED"; + } + return "SCHEDULED"; +}; + +/** + * Maps job run status based on job properties + * @param {Object} job - The job object + * @param {Date} [job.failedAt] - When the job failed + * @param {Date} [job.lastFinishedAt] - When the job last finished + * @returns {string} Status - "failed", "success", or "pending" + */ +const getRunStatus = (job) => { + if (job.failedAt) { + return "failed"; + } + if (job.lastFinishedAt) { + return "success"; + } + return "pending"; +}; + +// Format job duration helper +const formatJobDuration = (startDate, endDate) => { + if (!startDate || !endDate) { + return null; + } + return ((new Date(endDate) - new Date(startDate)) / 1000).toFixed(2); +}; + +// Get schedule label helper +const getScheduleLabel = (schedule) => { + if (!schedule) { + return ''; + } + try { + return cronstrue.toString(schedule); + } + catch (e) { + return schedule; + } +}; + +/** + * Job management API endpoint for controlling job configurations + * @name /i/jobs + * @example + * // Enable a job + * POST /i/jobs + * { + * "jobName": "cleanupJob", + * "action": "enable" + * } + * Response: { "code": 200, "message": "Success" } + * + * // Disable a job + * POST /i/jobs + * { + * "jobName": "cleanupJob", + * "action": "disable" + * } + * Response: { "code": 200, "message": "Success" } + * + * // Trigger immediate job execution + * POST /i/jobs + * { + * "jobName": "cleanupJob", + * "action": "runNow" + * } + * Response: { "code": 200, "message": "Success" } + * + * // Update schedule + * POST /i/jobs + * { + * "jobName": "cleanupJob", + * "action": "updateSchedule", + * "schedule": "0 0 * * *" // Runs at midnight every day + * } + * Response: { "code": 200, "message": "Success" } + * + * // Update retry configuration + * POST /i/jobs + * { + * "jobName": "cleanupJob", + * "action": "updateRetry", + * "retry": { + * "attempts": 3, // Number of retry attempts + * "delay": 300 // Delay between retries in seconds + * } + * } + * Response: { "code": 200, "message": "Success" } + * + * @returns {Object} Response + * @returns {number} Response.code - Status codes: + * 200: Success + * 400: Invalid parameters or missing required fields + * 500: Internal server error + * @returns {string} Response.message - Status message describing the result + */ +plugins.register('/i/jobs', async function(ob) { + validateGlobalAdmin(ob.params, async function() { + const {jobName, schedule, retry} = ob.params.qstring || {}; + const action = ob.params.qstring?.action; + + if (!jobName) { + common.returnMessage(ob.params, 400, 'Job name is required'); + return; + } + + const jobsCollection = common.db.collection('jobConfigs'); + const updateData = { updatedAt: new Date() }; + + try { + switch (action) { + case 'enable': + updateData.enabled = true; + break; + case 'disable': + updateData.enabled = false; + break; + case 'runNow': + updateData.runNow = true; + break; + case 'updateSchedule': + if (!schedule) { + common.returnMessage(ob.params, 400, 'Schedule configuration is required'); + return; + } + updateData.schedule = schedule; + break; + case 'updateRetry': + if (!retry) { + common.returnMessage(ob.params, 400, 'Retry configuration is required'); + return; + } + updateData.retry = retry; + break; + default: + common.returnMessage(ob.params, 400, 'Invalid action'); + return; + } + + await jobsCollection.updateOne( + { jobName }, + { $set: updateData } + ); + common.returnMessage(ob.params, 200, 'Success'); + } + catch (error) { + log.e('Error in jobs API:', { error: error.message, stack: error.stack }); + common.returnMessage(ob.params, 500, 'Internal server error'); + } + }); + return true; +}); + +/** + * Job query API endpoint for retrieving job information + * @name /o/jobs + * @example + * // Get all jobs with pagination and search + * GET /o/jobs + * { + * "iDisplayStart": 0, // Pagination start index + * "iDisplayLength": 50, // Number of records per page + * "sSearch": "backup", // Search term for job names + * "iSortCol_0": "nextRunAt", // Column to sort by + * "sSortDir_0": "desc" // Sort direction: "asc" or "desc" + * } + * Response: { + * "sEcho": "1", + * "iTotalRecords": 100, + * "iTotalDisplayRecords": 5, + * "aaData": [{ + * "job": { + * "name": "backupJob", + * "status": "SCHEDULED", + * "schedule": "0 0 * * *", + * "scheduleLabel": "At 12:00 AM", + * "nextRunAt": "2024-03-20T00:00:00.000Z", + * "lastFinishedAt": "2024-03-19T00:00:00.000Z", + * "lastRunStatus": "success", + * "failReason": null + * }, + * "config": { + * "enabled": true, + * "schedule": "0 0 * * *", + * "retry": { "attempts": 3, "delay": 300 } + * } + * }] + * } + * + * // Get specific job details with run history + * GET /o/jobs + * { + * "name": "cleanupJob", + * "iDisplayStart": 0, + * "iDisplayLength": 50 + * } + * Response: { + * "sEcho": "1", + * "iTotalRecords": 10, + * "iTotalDisplayRecords": 10, + * "aaData": [{ + * "lastRunAt": "2024-03-19T00:00:00.000Z", + * "status": "COMPLETED", + * "lastRunStatus": "success", + * "duration": "45.32", + * "failReason": null, + * "result": { "processedRecords": 1000 }, + * "runCount": 1, + * "dataAsString": "{\n \"processedRecords\": 1000\n}" + * }], + * "jobDetails": { + * "config": { + * "enabled": true, + * "defaultConfig": { + * "schedule": { "value": "0 0 * * *" }, + * "retry": { "attempts": 3, "delay": 300 } + * }, + * "scheduleLabel": "At 12:00 AM", + * "scheduleOverride": null, + * "retryOverride": null + * }, + * "currentState": { + * "status": "SCHEDULED", + * "nextRun": "2024-03-20T00:00:00.000Z", + * "lastRun": "2024-03-19T00:00:00.000Z", + * "lastRunStatus": "success", + * "failReason": null, + * "lastRunDuration": "45.32", + * "finishedCount": 10, + * "totalRuns": 10 + * } + * } + * } + * + * @returns {Object} Response + * @returns {string} Response.sEcho - Echo parameter from request for DataTables + * @returns {number} Response.iTotalRecords - Total number of records before filtering + * @returns {number} Response.iTotalDisplayRecords - Number of records after filtering + * @returns {Array} Response.aaData - Array of job data or job runs + * @returns {Object} [Response.jobDetails] - Detailed job information when querying specific job + * @returns {Object} Response.jobDetails.config - Job configuration including schedule and retry settings + * @returns {Object} Response.jobDetails.currentState - Current job state including next run and statistics + */ +plugins.register('/o/jobs', async function(ob) { + validateGlobalAdmin(ob.params, async function() { + const db = common.db; + const jobsCollection = db.collection('pulseJobs'); + const jobConfigsCollection = db.collection('jobConfigs'); + + try { + const { + name: jobName, + iDisplayStart, + iDisplayLength, + sSearch, + iSortCol_0, + sSortDir_0 + } = ob.params.qstring; + + // If jobName is provided, fetch detailed view + if (jobName) { + // Get job definition and config + const [jobConfig, latestJob] = await Promise.all([ + jobConfigsCollection.findOne({ jobName }), + jobsCollection.findOne( + { name: jobName, type: { $ne: 'single' } }, // Exclude single runs + { sort: { lastRunAt: -1 } } + ) + ]); + + // Get individual job runs + const jobRuns = await jobsCollection + .find({ + name: jobName, + type: 'single' // Only get single run instances + }) + .sort({ lastRunAt: -1 }) + .skip(Number(iDisplayStart) || 0) + .limit(Number(iDisplayLength) || 50) + .toArray(); + + const total = await jobsCollection.countDocuments({ + name: jobName, + type: 'single' + }); + + // Process job runs with more detailed information + const processedRuns = jobRuns.map(run => ({ + lastRunAt: run.lastRunAt, + status: getJobStatus(run), + lastRunStatus: getRunStatus(run), + duration: formatJobDuration(run.lastRunAt, run.lastFinishedAt), + failReason: run.failReason, + result: run.result, + runCount: run.runCount, + dataAsString: JSON.stringify(run.result || {}, null, 2) + })); + + // Structure job details with clear separation + const jobDetails = { + config: { + ...jobConfig, + enabled: jobConfig?.enabled ?? true, + defaultConfig: { + schedule: { value: latestJob?.repeatInterval }, + retry: { + attempts: latestJob?.attempts, + delay: latestJob?.backoff + } + }, + scheduleLabel: getScheduleLabel(jobConfig?.defaultConfig?.schedule?.value), + scheduleOverride: jobConfig?.schedule, + retryOverride: jobConfig?.retry + }, + currentState: latestJob ? { + status: getJobStatus(latestJob), + nextRun: latestJob.nextRunAt, + lastRun: latestJob.lastFinishedAt, + lastRunStatus: getRunStatus(latestJob), + failReason: latestJob.failReason, + lastRunDuration: formatJobDuration(latestJob.lastRunAt, latestJob.lastFinishedAt), + finishedCount: latestJob.finishedCount, + totalRuns: latestJob.runCount + } : null + }; + + common.returnOutput(ob.params, { + sEcho: ob.params.qstring.sEcho, + iTotalRecords: total, + iTotalDisplayRecords: total, + aaData: processedRuns, + jobDetails: jobDetails + }); + return; + } + + // Handle list view if no jobName provided + const skip = Number(iDisplayStart) || 0; + const limit = Number(iDisplayLength) || 50; + const search = sSearch || ''; + const sort = { + [iSortCol_0 || 'nextRunAt']: sSortDir_0 === 'desc' ? -1 : 1 + }; + + await handleListView(ob, jobsCollection, jobConfigsCollection, search, sort, skip, limit); + } + catch (error) { + log.e('Error in jobs API:', { error: error.message, stack: error.stack }); + common.returnMessage(ob.params, 500, 'Internal server error'); + } + }); + return true; +}); + +/** + * Handles detailed view for a specific job + * @param {Object} ob - Request object + * @param {string} jobName - Name of the job + * @param {Collection} jobsCollection - MongoDB jobs collection + * @param {Collection} jobConfigsCollection - MongoDB job configs collection + * @param {number} skip - Number of records to skip + * @param {number} limit - Number of records to return + * @returns {Promise} + */ +async function handleDetailedView(ob, jobName, jobsCollection, jobConfigsCollection, skip, limit) { + const query = { name: jobName }; + + const [jobRuns, jobConfig, latestJob] = await Promise.all([ + jobsCollection.find({ ...query, type: "single" }) + .sort({ nextRunAt: -1 }) + .skip(skip) + .limit(limit) + .toArray(), + jobConfigsCollection.findOne({ jobName }), + jobsCollection.findOne( + { name: jobName }, + { sort: { lastFinishedAt: -1, lastRunAt: -1 } } + ) + ]); + + const total = await jobsCollection.countDocuments({ ...query, type: "single" }); + + const processedRuns = jobRuns.map(run => ({ + name: run.name, + status: getJobStatus(run), + schedule: run.repeatInterval, + scheduleLabel: run.repeatInterval ? getScheduleLabel(run.repeatInterval) : 'Manual Run', + next: run.nextRunAt, + finished: run.lastFinishedAt, + lastRunStatus: getRunStatus(run), + failReason: run.failReason, + data: run.data, + dataAsString: JSON.stringify(run.data, null, 2), + duration: formatJobDuration(run.lastRunAt, run.lastFinishedAt), + durationInSeconds: formatJobDuration(run.lastRunAt, run.lastFinishedAt) + 's', + lastRunAt: run.lastRunAt, + nextRunDate: run.nextRunAt ? moment(run.nextRunAt).format('D MMM, YYYY') : '-', + nextRunTime: run.nextRunAt ? moment(run.nextRunAt).format('HH:mm:ss') : '-', + lastRun: run.lastFinishedAt ? moment(run.lastFinishedAt).fromNow() : '-' + })); + + const jobDetails = { + config: { + ...jobConfig, + scheduleLabel: jobConfig?.defaultConfig?.schedule?.value ? + getScheduleLabel(jobConfig.defaultConfig.schedule.value) : '-', + }, + currentState: latestJob ? { + status: getJobStatus(latestJob), + nextRun: latestJob.nextRunAt, + lastRun: latestJob.lastFinishedAt, + lastRunStatus: getRunStatus(latestJob), + failReason: latestJob.failReason, + lastRunDuration: formatJobDuration(latestJob.lastRunAt, latestJob.lastFinishedAt) + } : null + }; + + common.returnOutput(ob.params, { + sEcho: ob.params.qstring.sEcho, + iTotalRecords: total, + iTotalDisplayRecords: total, + aaData: processedRuns, + jobDetails: jobDetails + }); +} + +/** + * Handles list view for all jobs + * @param {Object} ob - Request object + * @param {Collection} jobsCollection - MongoDB jobs collection + * @param {Collection} jobConfigsCollection - MongoDB job configs collection + * @param {string} search - Search string to filter jobs + * @param {Object} sort - Sort configuration + * @param {number} skip - Number of records to skip + * @param {number} limit - Number of records to return + * @returns {Promise} + */ +async function handleListView(ob, jobsCollection, jobConfigsCollection, search, sort, skip, limit) { + const query = search ? { name: new RegExp(search, 'i') } : {}; + + const pipeline = [ + { $match: query }, + { + $group: { + _id: "$name", + doc: { $first: "$$ROOT" }, + lastFinishedAt: { $max: "$lastFinishedAt" }, + lastRunAt: { $max: "$lastRunAt" }, + lastFailedAt: { $max: "$failedAt" }, + lastStatus: { + $first: { + $cond: [ + { $eq: ["$type", "single"] }, + { + status: { + $switch: { + branches: [ + { case: { $ne: ["$lockedAt", null] }, then: "RUNNING" }, + { case: { $ne: ["$failedAt", null] }, then: "FAILED" }, + { case: { $ne: ["$lastFinishedAt", null] }, then: "COMPLETED" } + ], + default: "SCHEDULED" + } + }, + failReason: "$failReason", + lastRunStatus: { + $switch: { + branches: [ + { case: { $ne: ["$failedAt", null] }, then: "failed" }, + { case: { $ne: ["$lastFinishedAt", null] }, then: "success" } + ], + default: "pending" + } + } + }, + "$$ROOT" + ] + } + } + } + }, + { + $addFields: { + "doc.lastFinishedAt": "$lastFinishedAt", + "doc.lastRunAt": "$lastRunAt", + "doc.failedAt": "$lastFailedAt", + "doc.status": "$lastStatus.status", + "doc.failReason": "$lastStatus.failReason", + "doc.lastRunStatus": "$lastStatus.lastRunStatus" + } + }, + { $replaceRoot: { newRoot: "$doc" }}, + { $sort: sort }, + { $skip: skip }, + { $limit: limit } + ]; + + const [jobs, configs, total] = await Promise.all([ + jobsCollection.aggregate(pipeline).toArray(), + jobConfigsCollection.find({}).toArray(), + jobsCollection.distinct('name', query).then(names => names.length) + ]); + + const configMap = configs.reduce((map, config) => { + map[config.jobName] = config; + return map; + }, {}); + + const processedJobs = jobs.map(job => { + const config = configMap[job.name] || { + enabled: true, + defaultConfig: { + schedule: { value: job.repeatInterval }, + retry: { attempts: job.attempts, delay: job.backoff } + } + }; + + return { + job: { + name: job.name, + status: getJobStatus(job), + schedule: config.defaultConfig.schedule.value, + scheduleLabel: getScheduleLabel(config.defaultConfig.schedule.value), + nextRunAt: job.nextRunAt, + lastFinishedAt: job.lastFinishedAt, + lastRunStatus: getRunStatus(job), + failReason: job.failReason, + total + }, + config: { + enabled: config.enabled, + schedule: config.defaultConfig.schedule.value, + retry: config.defaultConfig.retry + } + }; + }); + + common.returnOutput(ob.params, { + sEcho: ob.params.qstring.sEcho, + iTotalRecords: total, + iTotalDisplayRecords: total, + aaData: processedJobs + }); +} \ No newline at end of file From ceaf8b934a8943ab2ff38411d283174588c4677b Mon Sep 17 00:00:00 2001 From: Kanwar Ujjaval Singh <4216199+kanwarujjaval@users.noreply.github.com> Date: Thu, 23 Jan 2025 23:33:16 +0530 Subject: [PATCH 45/90] init jobs fe --- .../core/jobs/javascripts/countly.views.js | 346 +++++++++++++----- .../core/jobs/templates/jobs-details.html | 154 +++++--- .../public/core/jobs/templates/jobs.html | 94 ++++- 3 files changed, 444 insertions(+), 150 deletions(-) diff --git a/frontend/express/public/core/jobs/javascripts/countly.views.js b/frontend/express/public/core/jobs/javascripts/countly.views.js index 1bfc8c5ad48..630ac7540d9 100644 --- a/frontend/express/public/core/jobs/javascripts/countly.views.js +++ b/frontend/express/public/core/jobs/javascripts/countly.views.js @@ -1,36 +1,34 @@ -/*global countlyAuth, countlyCommon, app, countlyVue, CV, countlyGlobal, CountlyHelpers, $ */ +/*global countlyAuth, countlyCommon, app, countlyVue, CV, countlyGlobal, CountlyHelpers, $, moment */ (function() { var getColor = function(row) { - if (row.status === "SCHEDULED") { + if (row.status === "RUNNING") { + return "green"; + } + else if (row.status === "SCHEDULED" && row.enabled) { return "yellow"; } - else if (row.status === "SUSPENDED") { + else if (row.status === "SUSPENDED" || !row.enabled) { return "gray"; } - else if (row.status === "CANCELLED") { + else if (row.status === "CANCELLED" || row.lastRunStatus === "failed") { return "red"; } - else if (row.status === "RUNNING") { - return "green"; - } }; var updateScheduleRow = function(row) { - var index; - row.nextRunDate = countlyCommon.getDate(row.next); - row.nextRunTime = countlyCommon.getTime(row.next); - row.lastRun = countlyCommon.formatTimeAgo(row.finished); - row.scheduleLabel = row.schedule || ""; - index = row.scheduleLabel.indexOf("starting on"); - if (index > (-1)) { - row.scheduleLabel = row.schedule.substring(0, index).trim(); - row.scheduleDetail = row.schedule.substring(index).trim(); - } - if (row.schedule && row.schedule.startsWith("at")) { - index = row.schedule.indexOf("every"); - row.scheduleDetail = row.schedule.substring(0, index).trim(); - row.scheduleLabel = row.schedule.substring(index).trim(); + // Format dates using moment + row.nextRunDate = row.nextRunAt ? moment(row.nextRunAt).format('YYYY-MM-DD') : ''; + row.nextRunTime = row.nextRunAt ? moment(row.nextRunAt).format('HH:mm:ss') : ''; + row.lastRun = row.lastFinishedAt ? moment(row.lastFinishedAt).fromNow() : ''; + + // Handle schedule display + if (row.config && row.config.defaultConfig && row.config.defaultConfig.schedule) { + row.schedule = row.config.defaultConfig.schedule.value; + row.configuredSchedule = row.config.schedule; + row.scheduleOverridden = row.configuredSchedule && + row.configuredSchedule !== row.schedule; } + }; var JobsView = countlyVue.views.create({ template: CV.T('/core/jobs/templates/jobs.html'), @@ -42,25 +40,46 @@ self.loaded = false; return { type: "GET", - url: countlyCommon.API_URL + "/o", + url: countlyCommon.API_URL + "/o/jobs", data: { app_id: countlyCommon.ACTIVE_APP_ID, - method: 'jobs' + iDisplayStart: 0, + iDisplayLength: 50 } }; }, onReady: function(context, rows) { self.loaded = true; - var row; - for (var i = 0; i < rows.length; i++) { - row = rows[i]; + var processedRows = []; + for (var i = 0; i < rows.aaData.length; i++) { + var row = rows.aaData[i].job; + var config = rows.aaData[i].config; + + // Process job status - now directly from API + row.enabled = config.enabled; + row.config = config; + + // Schedule handling + row.schedule = config.schedule; + row.scheduleLabel = row.scheduleLabel || ''; + updateScheduleRow(row); + processedRows.push(row); } - return rows; + return processedRows; } })); return { loaded: true, + saving: false, + scheduleDialogVisible: false, + selectedJobConfig: { + name: '', + schedule: '', + defaultSchedule: '', + scheduleLabel: '', + enabled: true + }, tableStore: tableStore, remoteTableDataSource: countlyVue.vuex.getServerDataSource(tableStore, "jobsTable") }; @@ -71,6 +90,33 @@ }, }, methods: { + formatDateTime(date) { + return date ? moment(date).format('D MMM, YYYY HH:mm:ss') : '-'; + }, + getStatusColor(details) { + if (!details.config.enabled) { + return 'gray'; + } + if (details.currentState.status === 'RUNNING') { + return 'blue'; + } + if (details.currentState.status === 'FAILED') { + return 'red'; + } + if (details.currentState.status === 'COMPLETED') { + return 'green'; + } + return 'yellow'; + }, + getRunStatusColor(status) { + if (status === 'success') { + return 'green'; + } + if (status === 'failed') { + return 'red'; + } + return 'gray'; + }, refresh: function(force) { if (this.loaded || force) { this.loaded = false; @@ -82,94 +128,186 @@ }, getColor: getColor, handleCommand: function(command, row) { - if (row.rowId) { + if (row.name) { var self = this; - if (command === "change-job-status") { - const suspend = row.status !== "SUSPENDED" ? true : false; - var notifyType = "ok"; - $.ajax({ - type: "GET", - url: countlyCommon.API_URL + "/o", - data: { - app_id: countlyCommon.ACTIVE_APP_ID, - method: 'suspend_job', - id: row.rowId, - suspend: suspend - }, - contentType: "application/json", - success: function(res) { - if (res.result) { - self.refresh(true); - } - else { - notifyType = "error"; - } + if (command === 'schedule') { + this.selectedJobConfig = { + name: row.name, + schedule: row.configuredSchedule || row.schedule, + defaultSchedule: row.schedule, + enabled: row.enabled + }; + this.scheduleDialogVisible = true; + return; + } + + var data = { + app_id: countlyCommon.ACTIVE_APP_ID, + jobName: row.name, + action: command + }; + + // You can switch to type: "POST" if desired; + // server code accepts query params so GET also works. + $.ajax({ + type: "GET", + url: countlyCommon.API_URL + "/i/jobs", + data: data, + success: function(res) { + if (res.result === "Success") { + self.refresh(true); CountlyHelpers.notify({ - type: notifyType, - message: res.message + type: "ok", + message: CV.i18n("jobs." + command + "-success") }); - }, - error: function(err) { + } + else { CountlyHelpers.notify({ type: "error", - message: err.responseJSON.error + message: res.result }); } + }, + error: function(err) { + CountlyHelpers.notify({ + type: "error", + message: err.responseJSON?.result || "Error" + }); + } + }); + } + }, + saveSchedule: function() { + var self = this; + self.saving = true; + + $.ajax({ + type: "GET", + url: countlyCommon.API_URL + "/i/jobs", + data: { + app_id: countlyCommon.ACTIVE_APP_ID, + jobName: this.selectedJobConfig.name, + action: 'updateSchedule', + schedule: this.selectedJobConfig.schedule + }, + success: function() { + self.saving = false; + self.scheduleDialogVisible = false; + self.refresh(true); + CountlyHelpers.notify({ + type: "ok", + message: CV.i18n("jobs.schedule-updated") + }); + }, + error: function(err) { + self.saving = false; + CountlyHelpers.notify({ + type: "error", + message: err.responseJSON?.result || "Error" }); } - } + }); }, } }); - var JobDetailView = countlyVue.views.create({ - template: CV.T('/core/jobs/templates/jobs-details.html'), + var JobDetailsView = countlyVue.views.BaseView.extend({ + template: "#jobs-details-template", data: function() { - var self = this; - var tableStore = countlyVue.vuex.getLocalStore(countlyVue.vuex.ServerDataTable("jobsTable", { - columns: ['name', "schedule", "next", "finished", "status", "total"], - onRequest: function() { - self.loaded = false; - return { - type: "GET", - url: countlyCommon.API_URL + "/o", - data: { - app_id: countlyCommon.ACTIVE_APP_ID, - method: 'jobs', - name: self.job_name - } - }; - }, - onReady: function(context, rows) { - self.loaded = true; - var row; - for (var i = 0; i < rows.length; i++) { - row = rows[i]; - row.dataAsString = JSON.stringify(row.data, null, 2); - row.durationInSeconds = (row.duration / 1000) + 's'; - updateScheduleRow(row); - } - return rows; - } - })); return { - job_name: this.$route.params.job_name, - loaded: true, - tableStore: tableStore, - remoteTableDataSource: countlyVue.vuex.getServerDataSource(tableStore, "jobsTable") + job_name: this.$route.params.jobName, + jobDetails: null, + jobRuns: [], + isLoading: false, + jobRunColumns: [ + { prop: "lastRunAt", label: CV.i18n('jobs.run-time'), sortable: true }, + { prop: "status", label: CV.i18n('jobs.status'), sortable: true }, + { prop: "duration", label: CV.i18n('jobs.duration'), sortable: true }, + { prop: "result", label: CV.i18n('jobs.result') } + ] }; }, + computed: { + hasOverrides: function() { + return this.jobDetails && + (this.jobDetails.config.scheduleOverride || + this.jobDetails.config.retryOverride); + } + }, methods: { - refresh: function(force) { - if (this.loaded || force) { - this.loaded = false; - this.tableStore.dispatch("fetchJobsTable"); + fetchJobDetails: function() { + var self = this; + self.isLoading = true; + + CV.$.ajax({ + type: "GET", + url: countlyCommon.API_PARTS.data.r + "/jobs", + data: { + "app_id": countlyCommon.ACTIVE_APP_ID, + "name": self.job_name, + "iDisplayStart": 0, + "iDisplayLength": 50 + }, + dataType: "json", + success: function(response) { + // API now returns jobDetails directly + self.jobDetails = response.jobDetails; + + // Process job runs from aaData + self.jobRuns = (response.aaData || []).map(function(run) { + return { + lastRunAt: run.lastRunAt, + status: run.status, + duration: run.duration, + result: run.result, + failReason: run.failReason, + dataAsString: run.dataAsString + }; + }); + + self.isLoading = false; + }, + error: function() { + self.isLoading = false; + CountlyHelpers.notify({ + title: CV.i18n("common.error"), + message: CV.i18n("jobs.details-fetch-error"), + type: "error" + }); + } + }); + }, + formatDateTime: function(date) { + return date ? moment(date).format('D MMM, YYYY HH:mm:ss') : '-'; + }, + calculateDuration: function(run) { + if (!run.lastRunAt || !run.lastFinishedAt) { + return '-'; } + return ((new Date(run.lastFinishedAt) - new Date(run.lastRunAt)) / 1000).toFixed(2); }, - navigate: function(id) { - app.navigate("#/manage/jobs/" + id); + getStatusColor: function(jobDetails) { + if (!jobDetails.config.enabled) { + return "grey"; + } + switch (jobDetails.currentState.status) { + case "RUNNING": return "blue"; + case "FAILED": return "red"; + case "COMPLETED": return "green"; + default: return "yellow"; + } }, - getColor: getColor + getRunStatusColor: function(run) { + switch (run.status) { + case "success": return "green"; + case "failed": return "red"; + case "running": return "blue"; + default: return "yellow"; + } + } + }, + mounted: function() { + this.fetchJobDetails(); } }); @@ -182,7 +320,7 @@ var getDetailedView = function() { return new countlyVue.views.BackboneWrapper({ - component: JobDetailView, + component: JobDetailsView, vuex: [] //empty array if none }); }; @@ -192,10 +330,20 @@ this.renderWhenReady(getMainView()); }); - app.route("/manage/jobs/:name", "manageJobName", function(name) { - var view = getDetailedView(); - view.params = {job_name: name}; - this.renderWhenReady(view); + app.route("/manage/jobs/:jobName", 'jobs-details', function(jobName) { + var jobDetailsView = new countlyVue.views.BackboneWrapper({ + component: JobDetailsView, + templates: [ + { + namespace: "jobs", + mapping: { + "details-template": "/core/jobs/templates/jobs-details.html" + } + } + ] + }); + jobDetailsView.params = { jobName: jobName }; + this.renderWhenReady(jobDetailsView); }); } })(); \ No newline at end of file diff --git a/frontend/express/public/core/jobs/templates/jobs-details.html b/frontend/express/public/core/jobs/templates/jobs-details.html index 4062a534513..637e9cbc94e 100644 --- a/frontend/express/public/core/jobs/templates/jobs-details.html +++ b/frontend/express/public/core/jobs/templates/jobs-details.html @@ -1,49 +1,121 @@
- + - - - - - +
\ No newline at end of file diff --git a/frontend/express/public/core/jobs/templates/jobs.html b/frontend/express/public/core/jobs/templates/jobs.html index 0d0585488fc..6dfeb7410c9 100644 --- a/frontend/express/public/core/jobs/templates/jobs.html +++ b/frontend/express/public/core/jobs/templates/jobs.html @@ -5,7 +5,13 @@ - +