UNPKG

worker-server

Version:

Worker server to run jobs instructed by central server

650 lines (540 loc) 17.5 kB
/* -------------------- * worker-server module * ------------------*/ // Modules const pathModule = require('path'), Promise = require('bluebird'), fs = require('fs-extra-promise').usePromise(Promise), co = require('co-bluebird'), cow = co.wrap, cos = require('co-series').use(Promise), promisify = require('promisify-any'), Locker = require('lock-queue'), request = require('request'), configLoad = require('config-load'), requireFolderTree = require('require-folder-tree'), uuid = require('uuid'), _ = require('lodash'); // Promisify request.postAsync = Promise.promisify(request.post, request); // Imports const Errors = require('./errors'), Worker = require('./worker'), Job = require('./job'), Timer = require('./timer'), logger = require('./logger'); // Exports /** * Server constructor. * * @param {Object} [options] - Options object */ function Server(options) { if (!(this instanceof Server)) return new Server(options); if (!options || !options.noInit) this.init(options); } module.exports = Server; /* * Server static attributes. */ Object.assign(Server, { Errors, Job, Worker, Utils: { Promise, promisify, co, coSeries: cos, _ } }); /** * Called by Server constructor. * * @param {Object} [options] - Options object * @returns {Object} - Server object */ Server.prototype.init = function(options) { // Get options options = this.getOptions(options, console.log); console.log('Loaded config', options); const {paths} = options; // Get server version this.version = require(pathModule.join(paths.root, 'package.json')).version; // Create logger this.createLogger(); // Log server initialized this.log('Initialized server', {version: this.version, options}); // Init onConnecting + onConnected methods if (options.onConnecting) this.onConnecting = promisify(options.onConnecting, 1); if (options.onConnected) this.onConnected = promisify(options.onConnected, 0); if (options.onPing) this.onPing = promisify(options.onPing, 1); // Load workers this.log('Loading workers', {path: paths.workers}); const workers = requireFolderTree(paths.workers, {flatten: true, flattenCamel: true}); this.workers = _.mapValues(workers, (worker, name) => { this.log('Loading worker', {worker: name}); return new Worker(name, worker, this); }); this.log('Loaded workers'); // Create jobs object this.jobs = {}; // Create timer and lock for getting next job this.jobLock = new Locker(); this.jobTimer = new Timer(); // Create lock for connecting to master this.connectLock = new Locker(); // Flag as not started this.started = false; this.connected = false; this.stopped = false; // Done this.log('Initialized server'); return this; }; /** * Reads config from file and combines with options provided. * @param {Object} [options] - Options object * @param {Function} [log] - Logging function * @returns {Object} - Options object */ Server.prototype.getOptions = function(options, log) { // Default logger if (!log) log = function() {}; // Conform options if (typeof options == 'string') { options = {paths: {root: options}}; } else { options = Object.assign({}, options); } let {paths} = options; if (typeof paths == 'string') { paths = {root: paths}; } else { paths = Object.assign({root: process.cwd()}, paths); } options.paths = paths; // Load config const configPath = paths.config || pathModule.join(paths.root, 'config'); log('Initializing server', {version: this.version}); log('Loading config', {path: configPath}); if (configPath && fs.existsSync(configPath)) { const config = configLoad(configPath, {selectors: {local: null}}); options = _.merge(config, options); paths = options.paths; } // Default options _.defaults(options, { name: 'worker-server app', jobInterval: 30000, messageInterval: 10000, connectInterval: 10000, log: {} }); if (!options.logName) options.logName = kebabCase(options.name); // Define paths for (let pathType of ['config', 'workers', 'jobs', 'log']) { if (!paths[pathType]) paths[pathType] = pathModule.join(paths.root, pathType); } // Save some options to server ['serverId', 'password', 'master'].forEach(prop => this[prop] = options[prop]); // Save options to server + return this.options = options; return options; }; /** * Creates logger and attaches to `server`. * @returns {Function} - Logging function */ Server.prototype.createLogger = function() { // Create logger const {options} = this; this.log = logger(options.name, options.logName, options.paths.log, options.log); // Save logging options to server options options.log = this.log.options; return this.log; }; /** * Start server. * Returns promise that resolves when server connects to master * or server is stopped externally before connection occurs. * Promise never rejects. * * @returns {Promise<undefined>} */ Server.prototype.start = cow(function*() { // Set handler for shutdown (SIGINT for ctrl-C in terminal, SIGTERM for `pm2 stop`) process.once('SIGINT', this.stop.bind(this, 'SIGINT')); process.once('SIGTERM', this.stop.bind(this, 'SIGTERM')); this.log('Starting server'); // Connect to master server yield this.connect(); }); /** * Attempt to connect/reconnect to master server once. * If succeeds in connecting, sends the job cache + sends 'online' status message. * @returns {Promise} - Resolved if success, rejected if failed */ const connectDo = cow(function*() { // Connect to server const status = this.started ? 'Reconnecting' : 'Connecting'; this.log(`${status} to master server`); try { yield this.sendServerStatus(status); } catch (err) { this.log.warn(`${status} to master server failed`, err); throw new Errors.Connection('Could not connect to master server', err); } this.log(`${status.slice(0, -3)}ed to master server`); // Send job cache yield this.sendJobCache(); // Send server online message this.log('Onlining server'); try { let data = {}; if (!this.started) { const workers = _.toPairs(this.workers).map(pair => ({code: pair[0], version: pair[1].version}) ); data = {startup: true, version: this.version, workers}; } if (this.onConnecting) yield this.onConnecting(data); yield this.sendServerStatus('Online', data); } catch (err) { this.log.warn('Onlining server failed', err); throw err; } // Flag as connected this.connected = true; this.started = true; this.log('Onlined server'); // Run onConnected handler if (this.onConnected) yield this.onConnected(); // Get a job from master server this.nextJob(); }); /** * Attempt to connect/reconnect repeatedly until succeeds. * Returns a promise that resolves when successfully connected, or if `.stopped()` called. * Promise never rejects. * @returns {Promise} */ Server.prototype.connect = cow(function*() { // Connect to master, and retry until succeed while (!this.connected && !this.stopped) { try { // Wait for any currently running connection/stopping/sending messages to complete // then try to connect yield this.connectLock.lock(function*() { // If connected or stopped by time get lock, exit if (this.connected || this.stopped) return; // Connect yield connectDo.call(this); }, this); } catch (err) { // Failed - wait and try again yield Promise.delay(this.options.connectInterval); } } }); /** * Called internally when server is disconnected. * @returns {undefined} */ Server.prototype.disconnected = function() { // If already disconnected, exit if (!this.connected) return; this.log('Disconnected from master server'); // Flag server as disconnected this.connected = false; // Cancel get next job timer this.jobTimer.clear(); // Reconnect this.connect(); }; /** * Send the job cache from disc to master server. * @returns {Promise} - Resolves if sent OK, rejected if failed */ Server.prototype.sendJobCache = cow(function*() { try { this.log('Sending job cache'); // Read all job cache and send to master server const jobsPath = this.options.paths.jobs; const files = yield fs.readdirAsync(jobsPath); yield files.map(cow(function*(filename) { if (filename.slice(-5) != '.json') return; const jobId = filename.slice(0, -5) * 1, path = pathModule.join(jobsPath, filename); let job = yield fs.readFileAsync(path, {encoding: 'utf8'}); job = JSON.parse(job); yield this.sendJobStatus(jobId, job.status, job.data, true); yield fs.unlinkAsync(path); }).bind(this)); this.log('Sent job cache'); } catch (err) { this.log.warn('Sending job cache failed', err); throw err; } }); /** * Called on SIGINT (ctrl-C in terminal) or SIGTERM (`pm2 stop`). * Cancels all running jobs and sends 'Offline' status message to master server. * Then terminates process. * @param {string} signal - Name of signal received that stopped process i.e. 'SIGINT'/'SIGTERM' */ Server.prototype.stop = cow(function*(signal) { this.log.warn('Received signal', {signal}); this.log('Stopping server'); // Flag as stopped this.stopped = true; this.connected = false; // Stop pinging for new jobs this.jobTimer.clear(); // If connecting or sending jobs, wait until finished yield this.connectLock.lock(() => this.connected = false); // Send 'Stopping' status to master server (ignore failure) const data = {reason: 'Process stopped externally'}; yield this.sendServerStatus('Stopping', data).catch(function() {}); // Cancel running jobs (ignore failures) yield _.mapValues(this.jobs, job => job.cancel()); // Send 'Offline' status to master server (ignore failure) yield this.sendServerStatus('Offline').catch(function() {}); this.log('Stopped server'); // Terminate process process.exit(); }); /** * Send server status message. * Will attempt to send even if server is disconnected. * If fails, attempts reconnect to server. * @param {string} status - Status e.g. 'Connecting' * @param {Object} data - Status message payload * @returns {Promise} - Resolved if sent, rejected if not. */ Server.prototype.sendServerStatus = cow(function*(status, data) { const path = this.master.paths.serverStatus.replace(':serverId', this.serverId); return yield this.sendMessage(path, {status, data: JSON.stringify(data)}, true); }); /** * Send job status message * If fails, attempts reconnect to server. * Should not be used externally - use `recordJobStatus` instead. * * @param {number} jobId - Job ID * @param {string} status - Status e.g. 'Connecting' * @param {Object} data - Status message payload * @param {boolean} override - If `true` will try to send even if server is disconnected. * @returns {Promise} - Resolved if sent, rejected if not. */ Server.prototype.sendJobStatus = cow(function*(jobId, status, data, override) { const path = this.master.paths.jobStatus.replace(':jobId', jobId); return yield this.sendMessage(path, {status, data: JSON.stringify(data)}, override); }); /** * Send message to master server. * If fails, attempts reconnect to server. * * @param {string} path - URL path to hit on master server. * @param {Object} data - Message payload * @param {boolean} override - If `true` will try to send even if server is disconnected. * @returns {Promise} - Resolves/rejects dependent on whether message sent successfully */ Server.prototype.sendMessage = cow(function*(path, data, override) { // If not connected, throw error (unless override flag set) if (!override && !this.connected) throw new Errors.Connection('Not connected to server'); try { return yield this._sendMessage(path, data); } catch (err) { // Server disconnected this.disconnected(); throw err; } }); /** * Send message to master server. * @returns {Promise} - Resolves/rejects dependent on whether message sent successfully */ Server.prototype._sendMessage = cow(function*(path, data) { // Add serverId and password to data if (!data) data = {}; data.serverId = this.serverId; data.serverPassword = this.password; // Hit API const url = `${this.master.host}${path}`; // Create logger for this request const log = this.log.child({messageId: uuid.v4()}); log('Sending message', {path, data}); let response; try { [response] = yield request.postAsync({ url, form: data, followRedirect: false, headers: {Accept: 'application/json'} }); } catch (err) { log.warn('Server connection error', err); throw new Errors.Connection('Could not connect to master server', err); } // Parse API JSON response let result; try { result = JSON.parse(response.body); log('Received response', {result}); } catch (err) { log.warn('Server bad response', err); throw new Errors.Connection('Bad response from master server', err); } // Check for login fail // TODO Implement sessions if (result.redirect == '/login') { const err = new Errors.Api('Login fail'); log.error('Login fail', err); throw err; } // Check for errors if (result.error) { const err = new Errors.Api('API error', result.error); log.error('API error', err); throw err; } // Check API action completed successfully // TODO Do a whitelist test rather than blacklist here // TODO generalize for standard APIs if (result.formErrors) { const err = new Errors.Api('API error', result.formErrors); log.error('API error', err); throw err; } // Done log('Received response data', {data: result.data}); return result.data; }); /** * Record job status * Tries to send to master server, if fails then records to job cache on disc. * * @param {number} jobId - Job ID * @param {string} status - Status e.g. 'Connecting' * @param {Object} data - Status message payload * @returns {Promise} - Resolved if sent, rejected if not (NB rejects if writes to disc) */ Server.prototype.recordJobStatus = cow(function*(jobId, status, data) { // Get non-exclusive lock on connect // i.e. if currently connecting, wait until finished trying to connect yield this.connectLock.run(function*() { // Try to send message to server try { yield this.sendJobStatus(jobId, status, data); } catch (err) { // Sending message failed - record to file cache instead const json = JSON.stringify({status, data}); try { yield fs.writeFileAsync(pathModule.join(this.options.paths.jobs, `${jobId}.json`), json); } catch (err) { this.log.error('Could not write job to disc', err); throw new Errors.Base('Could not write job to disc', err); } // Rethrow unexpected errors if (!(err instanceof Errors.Connection) && !(err instanceof Errors.Api)) throw err; } }, this); }); /** * Get next job to execute from master server * @returns {undefined} */ Server.prototype.nextJob = function() { this._nextJob().done(); }; /** * Get next job to execute from master server. * Do not call directly - use `.nextJob()`. * Returns promise which will only reject if there is an error in `onPing` handler. * @returns {Promise} */ Server.prototype._nextJob = cow(function*() { // If timer running, cancel it this.jobTimer.clear(); // If not connected, exit - server will request another job once connected if (!this.connected) return; // If already getting job, exit if (this.jobLock.locked) return; let result; yield this.jobLock.lock(function*() { // Ask master server for next job try { this.log('Requesting next job from server'); const path = this.master.paths.ping.replace(':serverId', this.serverId); result = yield this.sendMessage(path); } catch (err) { this.log.warn('Failed to get next job from server'); // Rethrow unexpected errors if (!(err instanceof Errors.Connection) && !(err instanceof Errors.Api)) throw err; } // Run onPing function if (result && this.onPing) yield this.onPing(result); }, this); if (!result) result = {}; // Cancel jobs if requested const {cancelJobs} = result; if (cancelJobs) { const {jobs} = this; yield cancelJobs.map(cow(function*(jobId) { const job = jobs[jobId]; if (job) yield job.cancel(); })); } // If no job found, schedule to ping again for new job after delay const jobParams = result.job; if (!jobParams) { this.log('No jobs available'); this.jobTimer.schedule(this.nextJob, this, this.options.jobInterval); return; } this.log('Job received', jobParams); // Run the job const jobPromise = this.startJob(jobParams); // Get another job this.nextJob(); // When job complete, get another job yield jobPromise; this.nextJob(); }); /** * Create new job from params and run it. * Returns promise that resolves when job completes (success or fail). * Promise will never reject. * @param {Object} params - Job parameters * @returns {Promise} */ Server.prototype.startJob = cow(function*(params) { // Create job const job = new Job(params, this); // Record job in jobs object this.jobs[job.id] = job; // Start job return yield job.start(); }); /** * Call when a job finishes (either success or failure). * Deletes job from the job list. * @param {Job} job */ Server.prototype.finishedJob = function(job) { // Remove job from jobs object delete this.jobs[job.id]; }; /** * Utility function: Convert camel case or human case to kebab-case * @param {string} txt * @return {string} - `txt` converted to kebab case */ function kebabCase(txt) { return txt.replace(/[A-Z]/g, c => ` ${c.toLowerCase()}`) .replace(/^\s+/, '') .replace(/\s+$/, '') .replace(/\s+/g, '-'); }