349 lines
		
	
	
		
			9.5 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			349 lines
		
	
	
		
			9.5 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
| 'use strict'
 | |
| 
 | |
| const DEFAULT_OPTIONS = {
 | |
|           workerOptions               : {}
 | |
|         , maxCallsPerWorker           : Infinity
 | |
|         , maxConcurrentWorkers        : (require('os').cpus() || { length: 1 }).length
 | |
|         , maxConcurrentCallsPerWorker : 10
 | |
|         , maxConcurrentCalls          : Infinity
 | |
|         , maxCallTime                 : Infinity // exceed this and the whole worker is terminated
 | |
|         , maxRetries                  : Infinity
 | |
|         , forcedKillTime              : 100
 | |
|         , autoStart                   : false
 | |
|         , onChild                     : function() {}
 | |
|       }
 | |
| 
 | |
| const fork                    = require('./fork')
 | |
|     , TimeoutError            = require('errno').create('TimeoutError')
 | |
|     , ProcessTerminatedError  = require('errno').create('ProcessTerminatedError')
 | |
|     , MaxConcurrentCallsError = require('errno').create('MaxConcurrentCallsError')
 | |
| 
 | |
| 
 | |
| function Farm (options, path) {
 | |
|   this.options     = Object.assign({}, DEFAULT_OPTIONS, options)
 | |
|   this.path        = path
 | |
|   this.activeCalls = 0
 | |
| }
 | |
| 
 | |
| 
 | |
| // make a handle to pass back in the form of an external API
 | |
| Farm.prototype.mkhandle = function (method) {
 | |
|   return function () {
 | |
|     let args = Array.prototype.slice.call(arguments)
 | |
|     if (this.activeCalls + this.callQueue.length >= this.options.maxConcurrentCalls) {
 | |
|       let err = new MaxConcurrentCallsError('Too many concurrent calls (active: ' + this.activeCalls + ', queued: ' + this.callQueue.length + ')')
 | |
|       if (typeof args[args.length - 1] == 'function')
 | |
|         return process.nextTick(args[args.length - 1].bind(null, err))
 | |
|       throw err
 | |
|     }
 | |
|     this.addCall({
 | |
|         method   : method
 | |
|       , callback : args.pop()
 | |
|       , args     : args
 | |
|       , retries  : 0
 | |
|     })
 | |
|   }.bind(this)
 | |
| }
 | |
| 
 | |
| 
 | |
| // a constructor of sorts
 | |
| Farm.prototype.setup = function (methods) {
 | |
|   let iface
 | |
|   if (!methods) { // single-function export
 | |
|     iface = this.mkhandle()
 | |
|   } else { // multiple functions on the export
 | |
|     iface = {}
 | |
|     methods.forEach(function (m) {
 | |
|       iface[m] = this.mkhandle(m)
 | |
|     }.bind(this))
 | |
|   }
 | |
| 
 | |
|   this.searchStart    = -1
 | |
|   this.childId        = -1
 | |
|   this.children       = {}
 | |
|   this.activeChildren = 0
 | |
|   this.callQueue      = []
 | |
| 
 | |
|   if (this.options.autoStart) {
 | |
|     while (this.activeChildren < this.options.maxConcurrentWorkers)
 | |
|       this.startChild()
 | |
|   }
 | |
| 
 | |
|   return iface
 | |
| }
 | |
| 
 | |
| 
 | |
| // when a child exits, check if there are any outstanding jobs and requeue them
 | |
| Farm.prototype.onExit = function (childId) {
 | |
|   // delay this to give any sends a chance to finish
 | |
|   setTimeout(function () {
 | |
|     let doQueue = false
 | |
|     if (this.children[childId] && this.children[childId].activeCalls) {
 | |
|       this.children[childId].calls.forEach(function (call, i) {
 | |
|         if (!call) return
 | |
|         else if (call.retries >= this.options.maxRetries) {
 | |
|           this.receive({
 | |
|               idx   : i
 | |
|             , child : childId
 | |
|             , args  : [ new ProcessTerminatedError('cancel after ' + call.retries + ' retries!') ]
 | |
|           })
 | |
|         } else {
 | |
|           call.retries++
 | |
|           this.callQueue.unshift(call)
 | |
|           doQueue = true
 | |
|         }
 | |
|       }.bind(this))
 | |
|     }
 | |
|     this.stopChild(childId)
 | |
|     doQueue && this.processQueue()
 | |
|   }.bind(this), 10)
 | |
| }
 | |
| 
 | |
| 
 | |
| // start a new worker
 | |
| Farm.prototype.startChild = function () {
 | |
|   this.childId++
 | |
| 
 | |
|   let forked = fork(this.path, this.options.workerOptions)
 | |
|     , id     = this.childId
 | |
|     , c      = {
 | |
|           send        : forked.send
 | |
|         , child       : forked.child
 | |
|         , calls       : []
 | |
|         , activeCalls : 0
 | |
|         , exitCode    : null
 | |
|       }
 | |
| 
 | |
|   this.options.onChild(forked.child);
 | |
| 
 | |
|   forked.child.on('message', function(data) {
 | |
|     if (data.owner !== 'farm') {
 | |
|       return;
 | |
|     }
 | |
|     this.receive(data);
 | |
|   }.bind(this))
 | |
|   forked.child.once('exit', function (code) {
 | |
|     c.exitCode = code
 | |
|     this.onExit(id)
 | |
|   }.bind(this))
 | |
| 
 | |
|   this.activeChildren++
 | |
|   this.children[id] = c
 | |
| }
 | |
| 
 | |
| 
 | |
| // stop a worker, identified by id
 | |
| Farm.prototype.stopChild = function (childId) {
 | |
|   let child = this.children[childId]
 | |
|   if (child) {
 | |
|     child.send({owner: 'farm', event: 'die'})
 | |
|     setTimeout(function () {
 | |
|       if (child.exitCode === null)
 | |
|         child.child.kill('SIGKILL')
 | |
|     }, this.options.forcedKillTime).unref()
 | |
|     ;delete this.children[childId]
 | |
|     this.activeChildren--
 | |
|   }
 | |
| }
 | |
| 
 | |
| 
 | |
| // called from a child process, the data contains information needed to
 | |
| // look up the child and the original call so we can invoke the callback
 | |
| Farm.prototype.receive = function (data) {
 | |
|   let idx     = data.idx
 | |
|     , childId = data.child
 | |
|     , args    = data.args
 | |
|     , child   = this.children[childId]
 | |
|     , call
 | |
| 
 | |
|   if (!child) {
 | |
|     return console.error(
 | |
|         'Worker Farm: Received message for unknown child. '
 | |
|       + 'This is likely as a result of premature child death, '
 | |
|       + 'the operation will have been re-queued.'
 | |
|     )
 | |
|   }
 | |
| 
 | |
|   call = child.calls[idx]
 | |
|   if (!call) {
 | |
|     return console.error(
 | |
|         'Worker Farm: Received message for unknown index for existing child. '
 | |
|       + 'This should not happen!'
 | |
|     )
 | |
|   }
 | |
| 
 | |
|   if (this.options.maxCallTime !== Infinity)
 | |
|     clearTimeout(call.timer)
 | |
| 
 | |
|   if (args[0] && args[0].$error == '$error') {
 | |
|     let e = args[0]
 | |
|     switch (e.type) {
 | |
|       case 'TypeError': args[0] = new TypeError(e.message); break
 | |
|       case 'RangeError': args[0] = new RangeError(e.message); break
 | |
|       case 'EvalError': args[0] = new EvalError(e.message); break
 | |
|       case 'ReferenceError': args[0] = new ReferenceError(e.message); break
 | |
|       case 'SyntaxError': args[0] = new SyntaxError(e.message); break
 | |
|       case 'URIError': args[0] = new URIError(e.message); break
 | |
|       default: args[0] = new Error(e.message)
 | |
|     }
 | |
|     args[0].type = e.type
 | |
|     args[0].stack = e.stack
 | |
| 
 | |
|     // Copy any custom properties to pass it on.
 | |
|     Object.keys(e).forEach(function(key) {
 | |
|       args[0][key] = e[key];
 | |
|     });
 | |
|   }
 | |
| 
 | |
|   process.nextTick(function () {
 | |
|     call.callback.apply(null, args)
 | |
|   })
 | |
| 
 | |
|   ;delete child.calls[idx]
 | |
|   child.activeCalls--
 | |
|   this.activeCalls--
 | |
| 
 | |
|   if (child.calls.length >= this.options.maxCallsPerWorker
 | |
|       && !Object.keys(child.calls).length) {
 | |
|     // this child has finished its run, kill it
 | |
|     this.stopChild(childId)
 | |
|   }
 | |
| 
 | |
|   // allow any outstanding calls to be processed
 | |
|   this.processQueue()
 | |
| }
 | |
| 
 | |
| 
 | |
| Farm.prototype.childTimeout = function (childId) {
 | |
|   let child = this.children[childId]
 | |
|     , i
 | |
| 
 | |
|   if (!child)
 | |
|     return
 | |
| 
 | |
|   for (i in child.calls) {
 | |
|     this.receive({
 | |
|         idx   : i
 | |
|       , child : childId
 | |
|       , args  : [ new TimeoutError('worker call timed out!') ]
 | |
|     })
 | |
|   }
 | |
|   this.stopChild(childId)
 | |
| }
 | |
| 
 | |
| 
 | |
| // send a call to a worker, identified by id
 | |
| Farm.prototype.send = function (childId, call) {
 | |
|   let child = this.children[childId]
 | |
|     , idx   = child.calls.length
 | |
| 
 | |
|   child.calls.push(call)
 | |
|   child.activeCalls++
 | |
|   this.activeCalls++
 | |
| 
 | |
|   child.send({
 | |
|       owner  : 'farm'
 | |
|     , idx    : idx
 | |
|     , child  : childId
 | |
|     , method : call.method
 | |
|     , args   : call.args
 | |
|   })
 | |
| 
 | |
|   if (this.options.maxCallTime !== Infinity) {
 | |
|     call.timer =
 | |
|       setTimeout(this.childTimeout.bind(this, childId), this.options.maxCallTime)
 | |
|   }
 | |
| }
 | |
| 
 | |
| 
 | |
| // a list of active worker ids, in order, but the starting offset is
 | |
| // shifted each time this method is called, so we work our way through
 | |
| // all workers when handing out jobs
 | |
| Farm.prototype.childKeys = function () {
 | |
|   let cka = Object.keys(this.children)
 | |
|     , cks
 | |
| 
 | |
|   if (this.searchStart >= cka.length - 1)
 | |
|     this.searchStart = 0
 | |
|   else
 | |
|     this.searchStart++
 | |
| 
 | |
|   cks = cka.splice(0, this.searchStart)
 | |
| 
 | |
|   return cka.concat(cks)
 | |
| }
 | |
| 
 | |
| 
 | |
| // Calls are added to a queue, this processes the queue and is called
 | |
| // whenever there might be a chance to send more calls to the workers.
 | |
| // The various options all impact on when we're able to send calls,
 | |
| // they may need to be kept in a queue until a worker is ready.
 | |
| Farm.prototype.processQueue = function () {
 | |
|   let cka, i = 0, childId
 | |
| 
 | |
|   if (!this.callQueue.length)
 | |
|     return this.ending && this.end()
 | |
| 
 | |
|   if (this.activeChildren < this.options.maxConcurrentWorkers)
 | |
|     this.startChild()
 | |
| 
 | |
|   for (cka = this.childKeys(); i < cka.length; i++) {
 | |
|     childId = +cka[i]
 | |
|     if (this.children[childId].activeCalls < this.options.maxConcurrentCallsPerWorker
 | |
|         && this.children[childId].calls.length < this.options.maxCallsPerWorker) {
 | |
| 
 | |
|       this.send(childId, this.callQueue.shift())
 | |
|       if (!this.callQueue.length)
 | |
|         return this.ending && this.end()
 | |
|     } /*else {
 | |
|       console.log(
 | |
|         , this.children[childId].activeCalls < this.options.maxConcurrentCallsPerWorker
 | |
|         , this.children[childId].calls.length < this.options.maxCallsPerWorker
 | |
|         , this.children[childId].calls.length , this.options.maxCallsPerWorker)
 | |
|     }*/
 | |
|   }
 | |
| 
 | |
|   if (this.ending)
 | |
|     this.end()
 | |
| }
 | |
| 
 | |
| 
 | |
| // add a new call to the call queue, then trigger a process of the queue
 | |
| Farm.prototype.addCall = function (call) {
 | |
|   if (this.ending)
 | |
|     return this.end() // don't add anything new to the queue
 | |
|   this.callQueue.push(call)
 | |
|   this.processQueue()
 | |
| }
 | |
| 
 | |
| 
 | |
| // kills child workers when they're all done
 | |
| Farm.prototype.end = function (callback) {
 | |
|   let complete = true
 | |
|   if (this.ending === false)
 | |
|     return
 | |
|   if (callback)
 | |
|     this.ending = callback
 | |
|   else if (this.ending == null)
 | |
|     this.ending = true
 | |
|   Object.keys(this.children).forEach(function (child) {
 | |
|     if (!this.children[child])
 | |
|       return
 | |
|     if (!this.children[child].activeCalls)
 | |
|       this.stopChild(child)
 | |
|     else
 | |
|       complete = false
 | |
|   }.bind(this))
 | |
| 
 | |
|   if (complete && typeof this.ending == 'function') {
 | |
|     process.nextTick(function () {
 | |
|       this.ending()
 | |
|       this.ending = false
 | |
|     }.bind(this))
 | |
|   }
 | |
| }
 | |
| 
 | |
| 
 | |
| module.exports              = Farm
 | |
| module.exports.TimeoutError = TimeoutError
 |