├── .eslintrc ├── .gitignore ├── .npmignore ├── .travis.yml ├── CHANGELOG ├── LICENSE ├── README.md ├── bin └── luster.js ├── examples ├── custom_master_and_ipc │ ├── master.js │ ├── node_modules │ │ └── luster │ ├── package.json │ └── worker.js ├── simple_extension │ ├── luster.conf.json │ ├── node_modules │ │ ├── .bin │ │ │ └── luster │ │ ├── luster-async │ │ │ ├── extension.js │ │ │ └── package.json │ │ └── luster-simple │ │ │ ├── extension.js │ │ │ └── package.json │ ├── package.json │ └── worker.js └── simple_server │ ├── luster.conf.json │ ├── node_modules │ ├── .bin │ │ └── luster │ └── luster │ ├── package.json │ └── worker.js ├── lib ├── cluster_process.js ├── configuration │ ├── check.js │ ├── helpers.js │ └── index.js ├── errors.js ├── event_emitter_ex.js ├── luster.js ├── master.js ├── port.js ├── restart_queue.js ├── rpc-callback.js ├── rpc.js ├── worker.js └── worker_wrapper.js ├── package.json └── test ├── func ├── fixtures │ ├── async_extension │ │ ├── master.js │ │ ├── node_modules │ │ │ ├── luster │ │ │ └── luster-async │ │ │ │ └── index.js │ │ └── worker.js │ ├── dead_workers │ │ ├── master.js │ │ ├── node_modules │ │ │ └── luster │ │ └── worker.js │ ├── emit_to_all │ │ ├── master.js │ │ ├── node_modules │ │ │ └── luster │ │ └── worker.js │ ├── force_kill │ │ ├── master.js │ │ ├── node_modules │ │ │ └── luster │ │ └── worker.js │ ├── manual_ready │ │ ├── master.js │ │ ├── node_modules │ │ │ └── luster │ │ └── worker.js │ ├── override_config │ │ ├── master.js │ │ ├── node_modules │ │ │ └── luster │ │ └── worker.js │ ├── remote_call_on_master │ │ ├── master.js │ │ ├── node_modules │ │ │ └── luster │ │ └── worker.js │ ├── remote_call_on_worker │ │ ├── master.js │ │ ├── node_modules │ │ │ └── luster │ │ └── worker.js │ ├── restart_queue │ │ ├── master.js │ │ ├── node_modules │ │ │ └── luster │ │ └── worker.js │ ├── simple_extension │ │ ├── master.js │ │ ├── node_modules │ │ │ ├── luster │ │ │ └── luster-simple │ │ │ │ └── index.js │ │ └── worker.js │ ├── suspend │ │ ├── master.js │ │ ├── node_modules │ │ │ └── luster │ │ └── worker.js │ ├── twice_ready_throws │ │ ├── master.js │ │ ├── node_modules │ │ │ └── luster │ │ └── worker.js │ └── worker_logs │ │ ├── master.js │ │ ├── node_modules │ │ └── luster │ │ └── worker.js ├── helpers │ └── luster_instance.js └── test │ ├── async_extension.js │ ├── dead_workers.js │ ├── emit_to_all.js │ ├── force_kill.js │ ├── manual_ready.js │ ├── override_config.js │ ├── remote_call_on_master.js │ ├── remote_call_on_worker.js │ ├── restart_queue.js │ ├── simple_extension.js │ ├── suspend.js │ ├── twice_ready_throws.js │ └── worker_logs.js ├── mocha.opts ├── setup.js └── unit ├── fixtures └── luster.conf.json └── test ├── cluster_process.js ├── configuration.js └── restart_queue.js /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "extends": "nodules", 4 | "rules": { 5 | "arrow-parens": ["error", "as-needed"], 6 | "arrow-spacing": "error", 7 | "no-console": 0, 8 | "no-mixed-requires": 0, 9 | "no-multiple-empty-lines": [2, {"max": 1}], 10 | "object-shorthand": "error", 11 | "no-var": "error", 12 | "global-require": 0, 13 | "prefer-arrow-callback": "error" 14 | }, 15 | "env": { 16 | "es6": true, 17 | "node": true, 18 | "mocha": true 19 | }, 20 | "parserOptions": { 21 | "ecmaVersion": 2017 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | examples/*/tmp 3 | coverage 4 | *.log 5 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .* 2 | examples 3 | test 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 8 4 | - 10 5 | script: npm test -- --timeout 10000 6 | -------------------------------------------------------------------------------- /CHANGELOG: -------------------------------------------------------------------------------- 1 | luster (2.0.0); urgency=low 2 | 3 | * Removed `Master.restartQueue` and `Master.processRestartQueue`. Use `Master.softRestart`, 4 | `Master.scheduleWorkerRestart` and `WorkerWrapper.softRestart` instead. 5 | 6 | * Fixed #44 - working becoming dead will not stop restart queue now. 7 | 8 | -- Slava Baginov Wed Mar 29 17:21:00 2017 +0300 9 | 10 | luster (1.2.0); urgency=low 11 | 12 | * Added event 'disconnect' in workers. This is re-emitted from 'cluser.worker' 13 | 14 | -- Slava Baginov Wed Mar 22 19:44:11 2017 +0300 15 | 16 | luster (1.1.1); urgency=low 17 | 18 | * Fixed `wid` to be Number (was String before) in workers 19 | 20 | luster (1.1.0); urgency=low 21 | 22 | * Remade most of examples as functional tests 23 | 24 | * Fixed linter warnings 25 | 26 | * wid is now passed as an environment variable and initialized in constructor 27 | This allows to use wid in extensions & fixed EventEmitterEx logs 28 | 29 | -- Slava Baginov Mon, 20 Mar 2017 16:04:59 +0300 30 | 31 | luster (1.0.0); urgency=low 32 | 33 | * Dropped node<4 support: 34 | --debug support and workarounds 35 | setImmediate fallback 36 | https://github.com/nodules/luster/pull/50 37 | 38 | * stopTimeout parameter now uses worker.process.kill() to kill hung workers 39 | stopTimeout time now starts counting from restart(), stop() calls or 40 | disconnect event, whichever happens first 41 | https://github.com/nodules/luster/pull/45 42 | 43 | -- Alexey Rybakov Thu, 16 Feb 2017 14:42:16 +0300 44 | 45 | luster (0.8.1); urgency=low 46 | 47 | * Configurable: support overriding of getters 48 | https://github.com/nodules/luster/pull/43 49 | 50 | -- Alexey Rybakov Wed, 30 Nov 2016 15:49:55 +0300 51 | 52 | luster (0.8.0); urgency=low 53 | 54 | * Pass process wrapper to rpc callback 55 | https://github.com/nodules/luster/pull/42 56 | 57 | -- Alexey Rybakov Wed, 23 Nov 2016 12:38:56 +0300 58 | 59 | luster (0.7.3); urgency=low 60 | 61 | * Implement callbacks for remote commands 62 | https://github.com/nodules/luster/pull/41 63 | 64 | -- Alexey Rybakov Wed, 16 Nov 2016 16:17:25 +0300 65 | 66 | luster (0.6.3); urgency=low 67 | 68 | * Configurable: nested property override via LUSTER_CONF is broken 69 | https://github.com/nodules/luster/pull/38 70 | 71 | -- Vladimir Varankin Mon Oct 31 15:05:39 2016 +0300 72 | 73 | luster (0.6.2); urgency=high 74 | 75 | * add methods to fire events on all processes via RPC 76 | 77 | -- Phillip Kovalev Fri Feb 26 18:22:25 2016 +0300 78 | 79 | luster (0.6.1); urgency=low 80 | 81 | * fix error message "LusterPortError: Can not unlink unix socket "undefined" 82 | https://github.com/nodules/luster/pull/32 83 | 84 | -- Vladimir Varankin Tue, 02 Feb 2016 12:50:15 +0300 85 | 86 | luster (0.6.0); urgency=high 87 | 88 | * allow workers to trigger "ready" state manually (by https://github.com/mutantcornholio) 89 | * fix custom workers events flow (Worker -> RPC -> WorkerWrapper -> Master) 90 | * better debug output formatting 91 | 92 | -- Phillip Kovalev Mon, 21 Dec 2015 17:10:25 +0300 93 | 94 | luster (0.5.8); urgency=high 95 | 96 | * fix WorkerWrapper#restart() (by https://github.com/an9eldust) 97 | 98 | -- Phillip Kovalev Mon, 14 Sep 2015 12:53:16 +0300 99 | 100 | luster (0.5.7); urgency=high 101 | 102 | * prevent workes from respawning on WorkerWrapper#stop() call, 103 | btw fixes Master#shutdown(). 104 | 105 | -- Phillip Kovalev Tue, 01 Sep 2015 17:37:05 +0300 106 | 107 | luster (0.5.6); urgency=high 108 | 109 | * update dependencies 110 | 111 | -- Phillip Kovalev Wed, 15 Jul 2015 13:47:06 +0300 112 | 113 | luster (0.5.5); urgency=high 114 | 115 | * don't count worker restart as "death" 116 | * emit Master#restarted event when all concurrent "soft restart" operations 117 | completed (previous versions fire it when first done) 118 | 119 | -- Phillip Kovalev Mon, 13 Jul 2015 18:29:41 +0300 120 | 121 | luster (0.5.4); urgency=high 122 | 123 | * pass worker pid to master (by https://github.com/corpix) 124 | 125 | -- Phillip Kovalev Fri, 27 Mar 2015 19:31:20 +0300 126 | 127 | luster (0.5.3); urgency=high 128 | 129 | * load worker codebase only when foreign properties received. 130 | 131 | -- Phillip Kovalev Wed, 10 Dec 2014 14:35:05 +0300 132 | 133 | luster (0.5.2); urgency=high 134 | 135 | * fix master failure if some worker send "initialized" event before all 136 | workers forked. 137 | 138 | -- Phillip Kovalev Wed, 26 Nov 2014 18:06:22 +0300 139 | 140 | luster (0.5.1); urgency=low 141 | 142 | * allow to run worker without master process 143 | (only if worker doesn't use any plugins via extensions api). 144 | 145 | -- Phillip Kovalev Thu, 20 Nov 2014 12:18:42 +0300 146 | 147 | luster (0.5.0); urgency=high 148 | 149 | * delay sending message to worker is ready (if not) in Master#remoteCallToAll() 150 | * emit "ready" as worker initalization done for listening workers too 151 | 152 | -- Phillip Kovalev Wed, 05 Nov 2014 16:16:56 +0300 153 | 154 | luster (0.4.1); urgency=high 155 | 156 | * Fix resolution of relative paths in the options "extensionsPath" 157 | (by ErBlack, https://github.com/ErBlack) 158 | 159 | -- Phillip Kovalev Sun, 26 Oct 2014 05:15:58 +0300 160 | 161 | luster (0.4.0); urgency=low 162 | 163 | * fix exception in the Master if configuration doesn't include any 164 | extensions (bug was introduced in 0.3.0) 165 | * add missing dependency "extend" (bug was introduced in 0.3.1) 166 | * add method ClusterProcess#hasRegisteredRemoteCommand(name) 167 | * add event WorkerWrapper#initialized (transmitted from Worker#initalized 168 | via IPC) 169 | * emit the event WorkerWrapper#ready only after Worker#initialized event of 170 | served worker process 171 | 172 | -- Phillip Kovalev Wed, 22 Oct 2014 06:40:00 +0300 173 | 174 | luster (0.3.1); urgency=low 175 | 176 | * Allow plugins to change master options using Master#setup() 177 | 178 | -- Phillip Kovalev Mon, 20 Oct 2014 08:09:28 +0300 179 | 180 | luster (0.3.0); urgency=low 181 | 182 | * Add support for asynchronous extensions initialization 183 | 184 | -- Phillip Kovalev Fri, 17 Oct 2014 19:32:15 +0300 185 | 186 | luster (0.2.6); urgency=low 187 | 188 | * Drop useless domain wrapper around luster configuration and launch 189 | * Update terror to 0.4.x 190 | * Update objex to 0.3.x 191 | 192 | -- Phillip Kovalev Fri, 03 Oct 2014 20:39:29 +0300 193 | 194 | luster (0.2.5); urgency=low 195 | 196 | * Fix issue which appears if "port" is not defined in the luster config 197 | * Drop domain wrapper around worker code requirement 198 | 199 | -- Phillip Kovalev Wed, 1 Mar 2014 00:00:00 +0400 200 | 201 | luster (0.2.4); urgency=low 202 | 203 | * Fix dead mark setup condition 204 | 205 | -- Phillip Kovalev Wed, 12 Feb 2014 16:45:57 +0400 206 | 207 | luster (0.2.3); urgency=low 208 | 209 | * Emit Master#shutdown if all workers are dead. 210 | 211 | -- Phillip Kovalev Mon, 03 Feb 2014 17:08:29 +0400 212 | 213 | luster (0.2.2); urgency=low 214 | 215 | * Option "control.allowedSequentialDeaths" has been revived 216 | 217 | -- Phillip Kovalev Mon, 03 Feb 2014 17:07:51 +0400 218 | 219 | luster (0.2.1); urgency=low 220 | 221 | * Fix master failure on workers stopping 222 | 223 | -- Phillip Kovalev Mon, 03 Feb 2014 17:06:49 +0400 224 | 225 | luster (0.2.0); urgency=low 226 | 227 | * Add legacy support for Node.js 0.8 228 | * Fix invalid initial port increment for first workers group 229 | 230 | -- Phillip Kovalev Fri, 10 Jan 2014 20:58:47 +0400 231 | 232 | luster (0.1.7); urgency=low 233 | 234 | * Initial public release 235 | 236 | -- Phillip Kovalev Wed, 25 Dec 2013 17:53:49 +0400 237 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013 Yandex LLC 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the "Software"), 5 | to deal in the Software without restriction, including without limitation 6 | the rights to use, copy, modify, merge, publish, distribute, sublicense, 7 | and/or sell copies of the Software, and to permit persons to whom the Software 8 | is furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included 11 | in all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS 14 | OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 16 | THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS 19 | IN THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Luster [![NPM version][npm-image]][npm-link] [![Build status][build-image]][build-link] 2 | ====== 3 | 4 | [![Dependency status][deps-image]][deps-link] 5 | [![Development Dependency status][devdeps-image]][devdeps-link] 6 | 7 | ## Core features 8 | 9 | * No worker code modification is necessary. 10 | * Provides common solution for master process. 11 | * Maintains specified quantity of running workers. 12 | * Runs groups of workers on the different ports for 3rd party load balancing (nginx or haproxy, for example). 13 | * Allows configuration via JSON, JS or anything that can be `require`d out of the box. 14 | * Zero downtime successive workers' restart. 15 | * Simple and flexible API for building extensions and development of custom master-workers solutions. 16 | 17 | ## Node.js versions support 18 | In `luster@1.0.0` we dropped support for `node<4`. 19 | If you desperately need to make it run on older `node` versions, use `luster@0.8.1`. 20 | 21 | ## Quick start 22 | 23 | Install `luster` module and save it as runtime dependency: 24 | 25 | ```console 26 | $ npm install --save luster 27 | ``` 28 | 29 | Write minimal required configuration file for luster: 30 | 31 | ```console 32 | $ echo '{ "app" : "worker.js" }' > ./luster.conf.json 33 | ``` 34 | 35 | Run the cluster: 36 | 37 | ```console 38 | $ ./node_modules/.bin/luster 39 | ``` 40 | 41 | Read configuration manual to know more about luster features. 42 | 43 | ## Configuration 44 | 45 | ### How luster tries to resolve a path to configuration file 46 | 47 | Following example written in plain JavaScript, not JSON, so you can name it 48 | `luster.conf.js` to launch luster without options, 49 | or pass the configuration file path as the first argument to the script: 50 | 51 | ```console 52 | $ ./node_modules/.bin/luster ./configs/my_luster_configuration.js 53 | ``` 54 | 55 | Internally, luster tries to call the `require()` in the following way: 56 | 57 | ```javascript 58 | require(path.resolve(process.cwd(), process.argv[2] || './luster.conf')); 59 | ``` 60 | 61 | ### Annotated example of configuration 62 | 63 | ```javascript 64 | module.exports = { 65 | // required, absolute or relative path to configuration file 66 | // of worker source file 67 | app : "./worker.js", 68 | 69 | // workers number 70 | // number of cpu threads is used by default 71 | workers : 4, 72 | 73 | // options to control workers startup and shutdown processes 74 | control : { 75 | // time to wait for 'online' event from worker 76 | // after spawning it (in milliseconds) 77 | forkTimeout : 3000, 78 | 79 | // time to wait for 'exit' event from worker 80 | // after disconnecting it (in milliseconds) 81 | stopTimeout : 10000, 82 | 83 | // if worker dies in `exitThreshold` time (in milliseconds) after start, 84 | // then its' `sequentialDeaths` counter will be increased 85 | exitThreshold : 5000, 86 | 87 | // max allowed value of `sequentialDeaths` counter 88 | // for each worker; on exceeding this limit worker will 89 | // be marked as `dead` and no more automatic restarts will follow. 90 | allowedSequentialDeaths : 10, 91 | 92 | // if falsy, worker is considered ready after 'online' event 93 | // it happens between forking worker and executing it 94 | // if truly, worker is considered ready 95 | // when you call require('luster').ready inside of it 96 | // notice that it's only affect startup/restart logic 97 | // worker will start handling requests right after you call 'listen' inside of it 98 | triggerReadyStateManually : false 99 | }, 100 | 101 | // use "server" group if you want to use web workers 102 | server : { 103 | // initial port for the workers; 104 | // can be tcp port number or path to the unix socket; 105 | // if you use unix sockets with groups of the workers, 106 | // then path must contain '*' char, which will be replaced 107 | // with group number 108 | // 109 | // worker can get port number to listen from the environment variable 110 | // `port`, for example: 111 | // > server.listen(process.env.port) 112 | port : 8080, 113 | 114 | // number of workers' groups; each group will 115 | // have its own port number (port + group number * ports per group..port + (group number + 1) * ports per group - 1) 116 | groups : 2, 117 | 118 | // number of ports per worker group; default 1 119 | portsPerGroup: 2, 120 | }, 121 | 122 | // extensions to load 123 | // each key in the "extensions" hash is a npm module name 124 | extensions : { 125 | // luster-log-file extension example 126 | "luster-log-file" : { 127 | stdout : "/var/log/luster/app.stdout.log", 128 | stderr : "/var/log/luster/app.stderr.log" 129 | }, 130 | 131 | // luster-guard extension example 132 | "luster-guard" : { 133 | include: [ '**/*.js' ], 134 | exclude: [ '**/node_modules/**' ] 135 | } 136 | }, 137 | 138 | // if extensions' modules can't be resolved as related to 139 | // luster module or worker path, then absolute path 140 | // to the directory, which contains extensions modules 141 | // must be declared here: 142 | extensionsPath : "/usr/local/luster-extensions", 143 | 144 | // max time to wait for extensions initialization 145 | extensionsLoadTimeout : 10000, 146 | 147 | // if your app or used extensions extensively use luster 148 | // internal events then you can tweak internal event emitters 149 | // listeners number limit using following option. 150 | // default value is `100`, option must be a number else EventEmitter 151 | // throws an error on configuration. 152 | maxEventListeners : 100 153 | }; 154 | ``` 155 | 156 | ## Extensions 157 | 158 | ### [List of extensions](https://github.com/nodules/luster/wiki/Extensions) 159 | 160 | ### Extensions development 161 | 162 | Extensions is a simple Node.js module, which must export object with `configure` function, 163 | which will be called during master and worker configuration. 164 | 165 | Synchronous extension initialization: 166 | ```javascript 167 | module.exports = { 168 | configure : function(config, clusterProcess) { 169 | // has `get` method: 170 | // var someProp = config.get('some.property.path', defaultValue); 171 | this.config = config; 172 | 173 | if (clusterProcess.isMaster) { 174 | this.initializeOnMaster(clusterProcess); 175 | } else { 176 | this.initializeOnWorker(clusterProcess); 177 | } 178 | } 179 | } 180 | ``` 181 | 182 | Asynchronous extension initalization: 183 | ```javascript 184 | module.exports = { 185 | initializeOnMaster : function(master, done) { 186 | // emulate async operation 187 | setTimeout(function() { 188 | // do something 189 | done(); 190 | }, 500); 191 | }, 192 | 193 | initializeOnWorker : function(worker, done) { 194 | // emulate async operation 195 | setTimeout(function() { 196 | // do something 197 | done(); 198 | }, 300); 199 | }, 200 | 201 | configure : function(config, clusterProcess, done) { 202 | // has `get` method: 203 | // var someProp = config.get('some.property.path', defaultValue); 204 | this.config = config; 205 | 206 | if (clusterProcess.isMaster) { 207 | this.initializeOnMaster(clusterProcess, done); 208 | } else { 209 | this.initializeOnWorker(clusterProcess, done); 210 | } 211 | } 212 | } 213 | ``` 214 | 215 | To enable asynchronous initalization of an extension, `configure` function must be declared with 3 or more arguments, 216 | where 3-rd argument is callback, which must be called by extensions when initialization has been finished. 217 | Callback accepts one optional argument: an error, if initalization failed. 218 | 219 | [npm-image]: https://img.shields.io/npm/v/luster.svg?style=flat 220 | [npm-link]: https://npmjs.org/package/luster 221 | [deps-image]: https://img.shields.io/david/nodules/luster.svg?style=flat 222 | [deps-link]: https://david-dm.org/nodules/luster 223 | [devdeps-image]: https://img.shields.io/david/dev/nodules/luster.svg?style=flat 224 | [devdeps-link]: https://david-dm.org/nodules/luster#info=devDependencies 225 | [build-image]: https://travis-ci.org/nodules/luster.svg?branch=master 226 | [build-link]: https://travis-ci.org/nodules/luster 227 | 228 | ## Debuggability 229 | 230 | If you are somehow lost in how master-worker interaction works, feel free to use `NODE_DEBUG=luster:eex` when launching your app. 231 | For example, you can check it within luster `examples` folder: 232 | ``` 233 | cd examples/custom_master_and_ipc/ 234 | NODE_DEBUG=luster:eex npm run start 235 | ``` 236 | You will see the sequence of events both on master and workers, along with underlying IPC messages. 237 | -------------------------------------------------------------------------------- /bin/luster.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const /** @type {ClusterProcess} */ 3 | luster = require('../lib/luster'), 4 | fs = require('fs'), 5 | path = require('path'); 6 | 7 | // config path is right after this script in process.argv 8 | // path in the argument may be relative or symlink 9 | const scriptArgvIndex = process.argv.findIndex(arg => arg === __filename || fs.realpathSync(path.resolve(arg)) === __filename); 10 | const configFilePath = path.resolve(process.cwd(), process.argv[scriptArgvIndex + 1] || 'luster.conf'); 11 | 12 | luster.configure(require(configFilePath), true, path.dirname(configFilePath)).run(); 13 | -------------------------------------------------------------------------------- /examples/custom_master_and_ipc/master.js: -------------------------------------------------------------------------------- 1 | /** @type {ClusterProcess} Master or Worker instance */ 2 | const proc = require('luster'); 3 | 4 | if (proc.isMaster) { 5 | // register command repeater in the master 6 | // if some worker call 'updateCounter' command, 7 | // master repeat it to another workers 8 | proc.registerRemoteCommand( 9 | 'updateCounter', 10 | /** 11 | * Called by workers via IPC 12 | * @param {WorkerWrapper} sender 13 | * @param {*} value 14 | */ 15 | (sender, value) => { 16 | proc.forEach(worker => { 17 | // repeat command to all workers except `sender` 18 | if (worker.id !== sender.id) { 19 | // pass sender.wid to another workers know command source 20 | worker.remoteCall('updateCounter', sender.id, value); 21 | } 22 | }); 23 | }); 24 | } 25 | 26 | proc 27 | .configure({ 28 | app: 'worker.js', 29 | workers: 2, 30 | server: { 31 | port: 10080 32 | } 33 | }, true, __dirname) 34 | .run(); 35 | -------------------------------------------------------------------------------- /examples/custom_master_and_ipc/node_modules/luster: -------------------------------------------------------------------------------- 1 | ../../.. -------------------------------------------------------------------------------- /examples/custom_master_and_ipc/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "luster-example-custom-master-and-ipc", 3 | "version": "0.0.0", 4 | "scripts": { 5 | "start": "node master.js" 6 | }, 7 | "bundlesDependencies": [ 8 | "luster" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /examples/custom_master_and_ipc/worker.js: -------------------------------------------------------------------------------- 1 | const http = require('http'), 2 | worker = require('luster'), 3 | counters = {}; 4 | 5 | if (worker.id === 1) { 6 | console.log('try to open http://localhost:%s', process.env.port); 7 | } 8 | 9 | counters[worker.id] = 0; 10 | 11 | worker.registerRemoteCommand( 12 | 'updateCounter', 13 | (target, workerId, value) => { 14 | // update recieved counter 15 | counters[workerId] = value; 16 | }); 17 | 18 | http 19 | .createServer((req, res) => { 20 | res.end('Worker #' + worker.id + ' at your service, sir!\n\nCounters: ' + JSON.stringify(counters)); 21 | 22 | // update counter in another workers 23 | worker.remoteCall('updateCounter', ++counters[worker.id]); 24 | }) 25 | .listen(process.env.port); 26 | -------------------------------------------------------------------------------- /examples/simple_extension/luster.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "app": "worker.js", 3 | "workers" : 2, 4 | "server" : { 5 | "port" : 10080 6 | }, 7 | "control" : { 8 | "exitThreshold" : 10000, 9 | "allowedSequentialDeaths" : 2 10 | }, 11 | "extensionsLoadTimeout": 6000, 12 | "extensions" : { 13 | "luster-simple" : { 14 | "param1" : 1, 15 | "param2" : "World" 16 | }, 17 | "luster-async" : { 18 | "param1" : 2, 19 | "param2" : "Hello" 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /examples/simple_extension/node_modules/.bin/luster: -------------------------------------------------------------------------------- 1 | ../../../../bin/luster.js -------------------------------------------------------------------------------- /examples/simple_extension/node_modules/luster-async/extension.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | configure(config, proc, done) { 3 | if (proc.isMaster) { 4 | setTimeout(() => { 5 | console.log('luster-async extension configured on master process'); 6 | console.log('param1 = %s', config.get('param1')); 7 | console.log('param2 = %s', config.get('param2')); 8 | done(); 9 | }, 1000); 10 | } else { 11 | setTimeout(() => { 12 | console.log('luster-async extension configured on worker process #%s', proc.wid); 13 | console.log('param1 = %s', config.get('param1')); 14 | console.log('param2 = %s', config.get('param2')); 15 | done(); 16 | }, 5000); 17 | } 18 | } 19 | }; 20 | -------------------------------------------------------------------------------- /examples/simple_extension/node_modules/luster-async/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name" : "luster-async", 3 | "version" : "0.0.0", 4 | "main" : "./extension.js" 5 | } 6 | -------------------------------------------------------------------------------- /examples/simple_extension/node_modules/luster-simple/extension.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | configure(config, proc) { 3 | if (proc.isMaster) { 4 | console.log('luster-simple extension configured on master process'); 5 | console.log('param1 = %s', config.get('param1')); 6 | console.log('param2 = %s', config.get('param2')); 7 | } else { 8 | console.log('luster-simple extension configured on worker process #%s', proc.wid); 9 | console.log('param1 = %s', config.get('param1')); 10 | console.log('param2 = %s', config.get('param2')); 11 | } 12 | } 13 | }; 14 | -------------------------------------------------------------------------------- /examples/simple_extension/node_modules/luster-simple/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name" : "luster-simple", 3 | "version" : "0.0.0", 4 | "main" : "./extension.js" 5 | } 6 | -------------------------------------------------------------------------------- /examples/simple_extension/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "luster-example-simple-server", 3 | "version": "0.0.0", 4 | "scripts": { 5 | "start": "luster" 6 | }, 7 | "bundlesDependencies": [ 8 | "luster" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /examples/simple_extension/worker.js: -------------------------------------------------------------------------------- 1 | const http = require('http'), 2 | cluster = require('cluster'); 3 | 4 | if (cluster.worker.id === 1) { 5 | console.log('try to open http://localhost:%s', process.env.port); 6 | } 7 | 8 | http 9 | .createServer((req, res) => { 10 | res.end('Worker #' + cluster.worker.id + ' at your service, sir!'); 11 | }) 12 | .listen(process.env.port); 13 | -------------------------------------------------------------------------------- /examples/simple_server/luster.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "app": "worker.js", 3 | 4 | "workers" : 10, 5 | 6 | "control" : { 7 | "forkTimeout" : 1000, 8 | "stopTimeout" : 1000, 9 | "exitThreshold" : 3000, 10 | "allowedSequentialDeaths" : 3 11 | }, 12 | 13 | "server" : { 14 | "port" : 8080, 15 | "groups" : 3 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /examples/simple_server/node_modules/.bin/luster: -------------------------------------------------------------------------------- 1 | ../../../../bin/luster.js -------------------------------------------------------------------------------- /examples/simple_server/node_modules/luster: -------------------------------------------------------------------------------- 1 | ../../.. -------------------------------------------------------------------------------- /examples/simple_server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "luster-example-simple-server", 3 | "version": "0.0.0", 4 | "scripts": { 5 | "start": "luster" 6 | }, 7 | "bundlesDependencies": [ 8 | "luster" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /examples/simple_server/worker.js: -------------------------------------------------------------------------------- 1 | const http = require('http'), 2 | worker = require('luster'); 3 | 4 | console.log('Worker #%s: try to open http://localhost:%s', worker.wid, process.env.port); 5 | 6 | http 7 | .createServer((req, res) => { 8 | res.end('Worker #' + worker.wid + ' at your service, sir!'); 9 | }) 10 | .listen(process.env.port); 11 | -------------------------------------------------------------------------------- /lib/cluster_process.js: -------------------------------------------------------------------------------- 1 | const cluster = require('cluster'), 2 | path = require('path'), 3 | RPC = require('./rpc'), 4 | RPCCallback = require('./rpc-callback'), 5 | Configuration = require('./configuration'), 6 | EventEmitterEx = require('./event_emitter_ex'), 7 | LusterClusterProcessError = require('./errors').LusterClusterProcessError, 8 | LusterConfigurationError = require('./errors').LusterConfigurationError; 9 | 10 | /** 11 | * @param {Object} context 12 | * @param {String} propName 13 | * @returns {Boolean} 14 | */ 15 | function has(context, propName) { 16 | return Object.prototype.hasOwnProperty.call(context, propName); 17 | } 18 | 19 | /** 20 | * Add `basedir`, `node_modules` contained in the `basedir` and its ancestors to `module.paths` 21 | * @param {String} basedir 22 | */ 23 | function extendResolvePath(basedir) { 24 | // using module internals isn't good, but restarting with corrected NODE_PATH looks more ugly, IMO 25 | module.paths.push(basedir); 26 | 27 | const _basedir = basedir.split('/'), 28 | size = basedir.length; 29 | let i = 0; 30 | 31 | while (size > i++) { 32 | const modulesPath = _basedir.slice(0, i).join('/') + '/node_modules'; 33 | 34 | if (module.paths.indexOf(modulesPath) === -1) { 35 | module.paths.push(modulesPath); 36 | } 37 | } 38 | } 39 | 40 | /** 41 | * @typedef Extension 42 | * @property {Function} [configure] (Object config, ClusterProcess proc) 43 | */ 44 | 45 | /** 46 | * @constructor 47 | * @class ClusterProcess 48 | * @augments EventEmitterEx 49 | */ 50 | class ClusterProcess extends EventEmitterEx { 51 | constructor() { 52 | super(); 53 | 54 | /** @private */ 55 | this._remoteCommands = {}; 56 | /** @private */ 57 | this.extensions = {}; 58 | /** 59 | * @type Promise 60 | * @private 61 | * */ 62 | this._initPromise = new Promise(resolve => { 63 | this.once('initialized', resolve); 64 | }); 65 | 66 | /** 67 | * @type {Configuration} 68 | * @public 69 | */ 70 | this.config = null; 71 | 72 | this.once('configured', this._onConfigured.bind(this)); 73 | 74 | this._setupIPCMessagesHandler(); 75 | 76 | this.registerRemoteCommand(RPC.fns.callback, RPCCallback.processCallback.bind(RPCCallback)); 77 | } 78 | 79 | /** 80 | * @memberOf ClusterProcess 81 | * @property {Boolean} isMaster 82 | * @readonly 83 | * @public 84 | */ 85 | get isMaster() { 86 | return cluster.isMaster; 87 | } 88 | 89 | /** 90 | * @memberOf ClusterProcess 91 | * @property {Boolean} isWorker 92 | * @readonly 93 | * @public 94 | */ 95 | get isWorker() { 96 | return cluster.isWorker; 97 | } 98 | 99 | /** 100 | * @event ClusterProcess#configured 101 | */ 102 | 103 | /** 104 | * @fires ClusterProcess#configured 105 | * @param {Object} config 106 | * @param {Boolean} [applyEnv=true] 107 | * @param {String} [basedir=process.cwd()] for Configuration#resolve relative paths 108 | * @returns {ClusterProcess} this 109 | * @throws {LusterConfigurationError} if configuration check failed (check errors will be logged to STDERR) 110 | * @public 111 | */ 112 | configure(config, applyEnv, basedir) { 113 | if (typeof applyEnv === 'undefined' || applyEnv) { 114 | Configuration.applyEnvironment(config); 115 | } 116 | 117 | if (typeof(basedir) === 'undefined') { 118 | basedir = process.cwd(); 119 | } 120 | 121 | if (Configuration.check(config) > 0) { 122 | this.emit('error', 123 | LusterConfigurationError.createError( 124 | LusterConfigurationError.CODES.CONFIGURATION_CHECK_FAILED)); 125 | } else { 126 | this.config = Configuration.extend(config, basedir); 127 | 128 | // hack to tweak underlying EventEmitter max listeners 129 | // if your luster-based app extensively use luster events 130 | this.setMaxListeners(this.config.get('maxEventListeners', 100)); 131 | 132 | this.emit('configured'); 133 | } 134 | 135 | return this; 136 | } 137 | 138 | /** 139 | * @param {String} name 140 | * @param {Function} callback function(error) 141 | */ 142 | loadExtension(name, callback) { 143 | const /** @type Extension */ 144 | extension = require(name); 145 | let config = this.config.get('extensions.' + name); 146 | 147 | this.extensions[name] = extension; 148 | 149 | // if `config` was an Object then it became instance of Configuration 150 | // else returns original value 151 | config = Configuration.extend(config, this.config.getBaseDir()); 152 | 153 | if (extension.configure.length > 2) { 154 | setImmediate(() => extension.configure(config, this, callback)); 155 | } else { 156 | setImmediate(() => { 157 | extension.configure(config, this); 158 | callback(); 159 | }); 160 | } 161 | } 162 | 163 | /** 164 | * @event ClusterProcess#initialized 165 | */ 166 | 167 | /** 168 | * @fires ClusterProcess#initialized 169 | * @private 170 | */ 171 | _onConfigured() { 172 | cluster.setMaxListeners(this.getMaxListeners()); 173 | 174 | // try to use `extensionsPath` option to resolve extensions' modules 175 | // use worker file directory as fallback 176 | extendResolvePath(path.resolve( 177 | this.config.resolve('extensionsPath', path.dirname(this.config.resolve('app'))) 178 | )); 179 | 180 | const extensions = this.config.getKeys('extensions'), 181 | wait = extensions.length, 182 | loadedExtensions = new Set(), 183 | loadTimeout = this.config.get('extensionsLoadTimeout', 10000); 184 | let loadTimer; 185 | 186 | if (wait === 0) { 187 | this.emit('initialized'); 188 | return; 189 | } 190 | 191 | extensions.forEach(name => { 192 | this.loadExtension(name, error => { 193 | if (error) { 194 | return this.emit('error', error); 195 | } 196 | 197 | loadedExtensions.add(name); 198 | this.emit('extension loaded', name); 199 | 200 | if (loadedExtensions.size === wait) { 201 | clearTimeout(loadTimer); 202 | this.emit('initialized'); 203 | } 204 | }); 205 | }); 206 | 207 | loadTimer = setTimeout(() => { 208 | const timeouted = extensions.filter(name => !loadedExtensions.has(name)), 209 | error = LusterClusterProcessError.createError( 210 | LusterClusterProcessError.CODES.EXTENSIONS_LOAD_TIMEOUT, 211 | {timeouted, timeout: loadTimeout}); 212 | 213 | this.emit('error', error); 214 | }, loadTimeout); 215 | } 216 | 217 | /** 218 | * Resolves when ClusterProcess done initialization. 219 | * @this {ClusterProcess} 220 | * @returns {Promise} 221 | */ 222 | whenInitialized() { 223 | return this._initPromise; 224 | } 225 | 226 | /** 227 | * Register `fn` as allowed for remote call via IPC. 228 | * @param {String} name 229 | * @param {Function} fn 230 | * @throws LusterClusterProcessError if remote procedure with `name` already registered. 231 | * @public 232 | */ 233 | registerRemoteCommand(name, fn) { 234 | if (has(this._remoteCommands, name)) { 235 | throw LusterClusterProcessError.createError( 236 | LusterClusterProcessError.CODES.REMOTE_COMMAND_ALREADY_REGISTERED, 237 | {name}); 238 | } 239 | 240 | this._remoteCommands[name] = fn; 241 | } 242 | 243 | /** 244 | * Remove previously registered remote command 245 | * @param {String} name 246 | * @public 247 | */ 248 | unregisterRemoteCommand(name) { 249 | delete this._remoteCommands[name]; 250 | } 251 | 252 | /** 253 | * Checks is remote command registered. 254 | * @param {String} name 255 | * @returns {Boolean} 256 | */ 257 | hasRegisteredRemoteCommand(name) { 258 | return has(this._remoteCommands, name); 259 | } 260 | 261 | /** 262 | * @abstract 263 | * @throws LusterClusterProcessError if method is not overriden in the inheritor of ClusterProcess 264 | * @private 265 | */ 266 | _setupIPCMessagesHandler() { 267 | throw LusterClusterProcessError.createError( 268 | LusterClusterProcessError.CODES.ABSTRACT_METHOD_IS_NOT_IMPLEMENTED, 269 | { 270 | method: 'ClusterProcess#_setupIPCMessagesHandler', 271 | klass: this.constructor.name 272 | }); 273 | } 274 | 275 | /** 276 | * Call function registered as remote command if `rawMessage` is valid luster IPC message 277 | * @param {WorkerWrapper|Worker} target object with `remoteCall` method which can be used to respond to message 278 | * @param {*} rawMessage 279 | * @see RPC 280 | * @private 281 | */ 282 | _onMessage(target, rawMessage) { 283 | const message = RPC.parseMessage(rawMessage); 284 | 285 | if (message === null) { 286 | return; 287 | } 288 | 289 | if (!has(this._remoteCommands, message.cmd)) { 290 | throw LusterClusterProcessError.createError( 291 | LusterClusterProcessError.CODES.REMOTE_COMMAND_IS_NOT_REGISTERED, 292 | { 293 | name: message.cmd, 294 | klass: this.constructor.name 295 | }); 296 | } else if (typeof message.args === 'undefined') { 297 | this._remoteCommands[message.cmd](target); 298 | } else { 299 | this._remoteCommands[message.cmd](target, ...message.args); 300 | } 301 | } 302 | 303 | /** 304 | * Register remote command with respect to the presence of callback 305 | * @param {String} command 306 | * @param {Function} handler 307 | */ 308 | registerRemoteCommandWithCallback(command, handler) { 309 | /** 310 | * @param {ClusterProcess} proc 311 | * @param {*} [data] 312 | * @param {String} callbackId 313 | */ 314 | this.registerRemoteCommand(command, (proc, data, callbackId) => { 315 | /** 316 | * @param {*} [callbackData] 317 | */ 318 | return handler(callbackData => { 319 | proc.remoteCall(RPC.fns.callback, callbackId, callbackData); 320 | }, data); 321 | }); 322 | } 323 | } 324 | 325 | module.exports = ClusterProcess; 326 | -------------------------------------------------------------------------------- /lib/configuration/check.js: -------------------------------------------------------------------------------- 1 | const LusterConfigurationError = require('../errors').LusterConfigurationError, 2 | typeOf = require('./helpers').typeOf, 3 | get = require('./helpers').get; 4 | 5 | /** 6 | * @typedef PropertyCheck 7 | * @property {Boolean} required default: `false` 8 | * @property {String|String[]} type `typeOf()` result 9 | */ 10 | 11 | /** 12 | * Hash of configuration properties checks. 13 | * Keys are properties paths, values – checks descriptors. 14 | * @const 15 | * @type {Object} 16 | * @property {PropertyCheck} * 17 | */ 18 | const CHECKS = { 19 | // path to worker main module 20 | 'app': { required: true, type: 'string' }, 21 | // number of workers to spawn 22 | 'workers': { type: 'number' }, 23 | 24 | // time (in ms) to wait for `online` event from worker 25 | 'control.forkTimeout': { type: 'number' }, 26 | // time (in ms) to wait for `exit` event from worker after `disconnect` 27 | 'control.stopTimeout': { type: 'number' }, 28 | // if worker dies in `threshold` ms then it's restarts counter increased 29 | 'control.exitThreshold': { type: 'number' }, 30 | // allowed restarts before mark worker as dead 31 | 'control.allowedSequentialDeaths': { type: 'number' }, 32 | 33 | // initial port for workers 34 | 'server.port': { type: ['number', 'string'] }, 35 | // increase port for every group 36 | 'server.groups': { type: 'number' }, 37 | // number of ports for each group 38 | 'server.portsPerGroup': { type: 'number' }, 39 | // hash of extensions; keys – modules' names, values – extensions' configs 40 | 'extensions': { type: 'object' }, 41 | // path to node_modules directory which contains extensions 42 | // configuration directory used by default 43 | 'extensionsPath': { type: 'string' }, 44 | // time to wait for configuration of all extensions 45 | 'extensionsLoadTimeout': { type: 'number' } 46 | }; 47 | 48 | /** 49 | * @param {String} path to property 50 | * @param {*} value 51 | * @param {PropertyCheck} check value description 52 | * @throws {LusterConfigurationError} if property check has been failed 53 | */ 54 | function checkProperty(path, value, check) { 55 | const type = typeOf(value); 56 | 57 | // required property 58 | if (type === 'undefined') { 59 | if (check.required) { 60 | throw LusterConfigurationError.createError( 61 | LusterConfigurationError.CODES.PROP_REQUIRED, 62 | { property: path }); 63 | } else { 64 | return; 65 | } 66 | } 67 | 68 | // allowed types 69 | const allowedTypes = check.type && [].concat(check.type); 70 | if (allowedTypes && allowedTypes.indexOf(type) === -1) { 71 | throw LusterConfigurationError.createError( 72 | LusterConfigurationError.CODES.PROP_TYPE_CHECK_FAILED, 73 | { 74 | property: path, 75 | type, 76 | expected: allowedTypes.join(' or ') 77 | }); 78 | } 79 | } 80 | 81 | /** 82 | * Validate configuration object using descriptions from CHECKS const 83 | * @param {Object} conf configuration object 84 | * @returns {Number} of failed checks 85 | */ 86 | function checkConfiguration(conf) { 87 | let failedChecks = 0; 88 | 89 | for (const path of Object.keys(CHECKS)) { 90 | // @todo avoid try..catch 91 | try { 92 | checkProperty(path, get(conf, path), CHECKS[path]); 93 | } catch (error) { 94 | LusterConfigurationError.ensureError(error).log(); 95 | ++failedChecks; 96 | } 97 | } 98 | 99 | return failedChecks; 100 | } 101 | 102 | module.exports = checkConfiguration; 103 | -------------------------------------------------------------------------------- /lib/configuration/helpers.js: -------------------------------------------------------------------------------- 1 | const util = require('util'), 2 | LusterConfigurationError = require('../errors').LusterConfigurationError; 3 | 4 | /** 5 | * @param {*} value 6 | * @returns {String} `typeof` result extended with 'array', 'regexp', 'date' and 'error' 7 | */ 8 | function typeOf(value) { 9 | let type = typeof value; 10 | 11 | if (type === 'object') { 12 | if (util.isArray(value)) { 13 | type = 'array'; 14 | } else if (util.isRegExp(value)) { 15 | type = 'regexp'; 16 | } else if (util.isDate(value)) { 17 | type = 'date'; 18 | } else if (util.isError(value)) { 19 | type = 'error'; 20 | } 21 | } 22 | 23 | return type; 24 | } 25 | 26 | /** 27 | * @param {Object} context 28 | * @param {String} path 29 | * @param {*} value 30 | */ 31 | function set(context, path, value) { 32 | let ctx = context; 33 | 34 | const props = path.split('.'), 35 | target = props.pop(), 36 | size = props.length; 37 | 38 | for (let i = 0; i < size; i++) { 39 | const propName = props[i]; 40 | const type = typeOf(ctx[propName]); 41 | 42 | if (type === 'undefined') { 43 | ctx[propName] = {}; 44 | } else if (type !== 'object') { 45 | throw LusterConfigurationError.createError( 46 | LusterConfigurationError.CODES.CAN_NOT_SET_ATOMIC_PROPERTY_FIELD, 47 | { path: props.slice(0, size).join('.') }); 48 | } 49 | 50 | ctx = ctx[propName]; 51 | } 52 | 53 | delete ctx[target]; 54 | ctx[target] = value; 55 | } 56 | 57 | /** 58 | * @param {*} context 59 | * @param {String} [path] 60 | * @param {*} [defaultValue] 61 | * @returns {*} property by path or default value if absent 62 | */ 63 | function get(context, path, defaultValue) { 64 | if (typeof path === 'undefined' || path === '') { 65 | return context; 66 | } 67 | 68 | const props = path.split('.'), 69 | size = props.length; 70 | 71 | let ctx = context; 72 | 73 | for (let i = 0, prop = props[0]; i < size; prop = props[++i]) { 74 | if (typeof ctx === 'undefined' || ctx === null || 75 | ! Object.prototype.hasOwnProperty.call(ctx, prop)) { 76 | return defaultValue; 77 | } 78 | 79 | ctx = ctx[prop]; 80 | } 81 | 82 | return ctx; 83 | } 84 | 85 | /** 86 | * @param {*} context 87 | * @param {String} [path] 88 | * @returns {Boolean} `true` if property exists 89 | */ 90 | function has(context, path) { 91 | if (typeof path === 'undefined' || path === '') { 92 | return context; 93 | } 94 | 95 | const props = path.split('.'), 96 | size = props.length; 97 | 98 | let ctx = context; 99 | 100 | for (let i = 0, prop = props[0]; i < size; prop = props[++i]) { 101 | if (typeof ctx === 'undefined' || ctx === null || 102 | ! Object.prototype.hasOwnProperty.call(ctx, prop)) { 103 | return false; 104 | } 105 | 106 | ctx = ctx[prop]; 107 | } 108 | 109 | return true; 110 | } 111 | 112 | module.exports = { 113 | typeOf, 114 | has, 115 | get, 116 | set, 117 | }; 118 | -------------------------------------------------------------------------------- /lib/configuration/index.js: -------------------------------------------------------------------------------- 1 | const path = require('path'), 2 | typeOf = require('./helpers').typeOf, 3 | get = require('./helpers').get, 4 | set = require('./helpers').set, 5 | has = require('./helpers').has; 6 | 7 | /** 8 | * @constructor 9 | * @class Configuration 10 | */ 11 | class Configuration { 12 | constructor(config, basedir) { 13 | /** @private */ 14 | this._resolveBaseDir = basedir || process.cwd(); 15 | 16 | if (config instanceof Configuration) { 17 | config = config._rawConfig; 18 | } 19 | this._rawConfig = {}; 20 | Object.assign(this._rawConfig, config); 21 | } 22 | 23 | /** 24 | * @param {String} path 25 | * @param {*} [defaultValue] 26 | * @returns {*} 27 | * @see get 28 | * @public 29 | */ 30 | get(path, defaultValue) { 31 | return get(this._rawConfig, path, defaultValue); 32 | } 33 | 34 | /** 35 | * @param {String} path 36 | * @returns {Boolean} 37 | * @see has 38 | * @public 39 | */ 40 | has(path) { 41 | return has(this._rawConfig, path); 42 | } 43 | 44 | /** 45 | * Shortcut for `Object.keys(c.get('some.obj.prop', {}))` 46 | * @param {String} [path] 47 | * @returns {String[]} keys of object property by path or 48 | * empty array if property doesn't exists or not an object 49 | * @public 50 | */ 51 | getKeys(path) { 52 | const obj = get(this._rawConfig, path); 53 | 54 | if (typeOf(obj) !== 'object') { 55 | return []; 56 | } else { 57 | return Object.keys(obj); 58 | } 59 | } 60 | 61 | /** 62 | * Shortcut for `path.resolve(process.cwd(), c.get(path, 'default.file'))` 63 | * @param {String} propPath 64 | * @param {String} [defaultPath] 65 | * @returns {String} absolute path 66 | * @public 67 | */ 68 | resolve(propPath, defaultPath) { 69 | return path.resolve( 70 | this._resolveBaseDir, 71 | get(this._rawConfig, propPath, defaultPath)); 72 | } 73 | 74 | /** 75 | * @returns {String} base dir used in `resolve()` 76 | * @public 77 | */ 78 | getBaseDir() { 79 | return this._resolveBaseDir; 80 | } 81 | 82 | /** 83 | * Create Configuration instance from plain object 84 | * @param {Object|*} config 85 | * @param {String} basedir - base dir for `resolve` method 86 | * @returns {Configuration|*} Configuration instance if `config` is object or `config` itself in other case 87 | * @public 88 | * @static 89 | */ 90 | static extend(config, basedir) { 91 | return typeOf(config) === 'object' ? 92 | new Configuration(config, basedir) : 93 | config; 94 | } 95 | 96 | /** 97 | * Override config properties using `LUSTER_CONF` environment variable. 98 | * 99 | * @description 100 | * LUSTER_CONF='PATH=VALUE;...' 101 | * 102 | * ; – properties separator; 103 | * = – property and value separator; 104 | * PATH – property path; 105 | * VALUE – property value, JSON.parse applied to it, 106 | * if JSON.parse failed, then value used as string. 107 | * You MUST quote a string if it contains semicolon. 108 | * 109 | * Spaces between PATH, "=", ";" and VALUE are insignificant. 110 | * 111 | * @example 112 | * LUSTER_CONF='server.port=8080' 113 | * # { server: { port: 8080 } } 114 | * 115 | * LUSTER_CONF='app=./worker_debug.js; workers=1' 116 | * # { app: "./worker_debug.js", workers : 1 } 117 | * 118 | * LUSTER_CONF='logStream=' 119 | * # remove option "logStream" 120 | * 121 | * LUSTER_CONF='server={"port":8080}' 122 | * # { server: { port: 8080 } } 123 | * 124 | * @param {Object} config 125 | * @throws {LusterConfigurationError} if you trying to 126 | * set property of atomic property, for example, 127 | * error will be thrown if you have property 128 | * `extensions.sample.x = 10` in the configuration and 129 | * environment variable `LUSTER_EXTENSIONS_SAMPLE_X_Y=5` 130 | */ 131 | static applyEnvironment(config) { 132 | if (!process.env.LUSTER_CONF) { 133 | return; 134 | } 135 | 136 | if (config instanceof Configuration) { 137 | config = config._rawConfig; 138 | } 139 | 140 | function parseProp(prop) { 141 | const delimeterPos = prop.indexOf('='); 142 | 143 | if (delimeterPos === 0 || delimeterPos === -1) { 144 | return; 145 | } 146 | 147 | const propPath = prop.substr(0, delimeterPos).trim(); 148 | let propValue = prop.substr(delimeterPos + 1).trim(); 149 | 150 | if (propValue === '') { 151 | propValue = undefined; 152 | } else { 153 | try { 154 | // try to parse propValue as JSON, 155 | // if parsing failed use raw `propValue` as string 156 | propValue = JSON.parse(propValue); 157 | } catch (error) { // eslint-disable-line no-empty 158 | } 159 | } 160 | 161 | set(config, propPath, propValue); 162 | } 163 | 164 | const conf = process.env.LUSTER_CONF; 165 | 166 | let lastSeparator = -1, 167 | i = 0, 168 | openQuote = false; 169 | 170 | while (conf.length > i++) { 171 | switch (conf[i]) { 172 | case '"' : 173 | openQuote = !openQuote; 174 | break; 175 | case ';' : 176 | if (!openQuote) { 177 | parseProp(conf.substring(lastSeparator + 1, i)); 178 | lastSeparator = i; 179 | } 180 | } 181 | } 182 | 183 | if (lastSeparator < conf.length) { 184 | parseProp(conf.substring(lastSeparator + 1)); 185 | } 186 | } 187 | } 188 | 189 | Configuration.check = require('./check'); 190 | 191 | module.exports = Configuration; 192 | -------------------------------------------------------------------------------- /lib/errors.js: -------------------------------------------------------------------------------- 1 | const Terror = require('terror'), 2 | errors = {}; 3 | 4 | /** 5 | * @constructor 6 | * @class LusterError 7 | * @augments Terror 8 | */ 9 | const LusterError = errors.LusterError = Terror.create('LusterError', 10 | { 11 | ABSTRACT_METHOD_IS_NOT_IMPLEMENTED: 12 | 'Abstract method "%method%" is not implemented in the %klass%' 13 | }); 14 | 15 | /** 16 | * @constructor 17 | * @class LusterWorkerError 18 | * @augments LusterError 19 | */ 20 | errors.LusterWorkerError = LusterError.create('LusterWorkerError', 21 | { 22 | ALREADY_READY: 23 | 'Worker#ready() called when worker is is "ready" state already' 24 | }); 25 | 26 | /** 27 | * @constructor 28 | * @class LusterConfigurationError 29 | * @augments LusterError 30 | */ 31 | errors.LusterConfigurationError = LusterError.create('LusterConfigurationError', 32 | { 33 | CONFIGURATION_CHECK_FAILED: 34 | 'Configuration check failed', 35 | PROP_REQUIRED: 36 | 'Required property "%property%" is absent', 37 | PROP_TYPE_CHECK_FAILED: 38 | 'Property "%property%" type is "%type%", but %expected% is expected', 39 | PROP_REGEXP_CHECK_FAILED: 40 | 'Property "%property%" doesn\'t meet the regexp "%regexp%"', 41 | CAN_NOT_SET_ATOMIC_PROPERTY_FIELD: 42 | 'Property "%path%" already exists and is not an object' 43 | }); 44 | 45 | /** 46 | * @constructor 47 | * @class LusterConfigurationError 48 | * @augments LusterError 49 | */ 50 | errors.LusterWorkerWrapperError = LusterError.create('LusterWorkerWrapperError', 51 | { 52 | INVALID_ATTEMPT_TO_CHANGE_STATE: 53 | 'Invalid attempt to change worker #%wid% (pid: %pid%) state from "%state%" to "%targetState%"', 54 | REMOTE_COMMAND_CALL_TO_STOPPED_WORKER: 55 | 'Remote command call "%command%" to stopped worker #%wid%' 56 | }); 57 | 58 | /** 59 | * @constructor 60 | * @class LusterClusterProcessError 61 | * @augments LusterError 62 | */ 63 | errors.LusterClusterProcessError = LusterError.create('LusterClusterProcessError', 64 | { 65 | REMOTE_COMMAND_ALREADY_REGISTERED: 66 | 'Command "%name%" already registered as allowed for remote calls', 67 | REMOTE_COMMAND_IS_NOT_REGISTERED: 68 | 'Remote command "%name%" is not registered on %klass%', 69 | EXTENSIONS_LOAD_TIMEOUT: 70 | 'Extensions %timeouted% not loaded in %timeout% ms' 71 | }); 72 | 73 | /** 74 | * @constructor 75 | * @class LusterRPCCallbackError 76 | * @augments LusterError 77 | */ 78 | errors.LusterRPCCallbackError = LusterError.create('LusterRPCCallbackError', 79 | { 80 | REMOTE_CALL_WITH_CALLBACK_TIMEOUT: 81 | 'Remote call failed due to timeout for command "%command%"' 82 | }); 83 | 84 | /** 85 | * @constructor 86 | * @class LusterPortError 87 | * @augments LusterError 88 | */ 89 | errors.LusterPortError = LusterError.create('LusterPortError', 90 | { 91 | NOT_UNIX_SOCKET: 92 | '"%value%" is not a unix socket', 93 | CAN_NOT_UNLINK_UNIX_SOCKET: 94 | 'Can not unlink unix socket "%socketPath%"' 95 | }); 96 | 97 | module.exports = errors; 98 | -------------------------------------------------------------------------------- /lib/event_emitter_ex.js: -------------------------------------------------------------------------------- 1 | const util = require('util'), 2 | { EventEmitter } = require('events').EventEmitter; 3 | 4 | /** 5 | * @constructor 6 | * @class EventEmitterEx 7 | * @augments EventEmitter 8 | */ 9 | class EventEmitterEx extends EventEmitter {} 10 | 11 | function inspect(val) { 12 | return util.inspect(val, { depth: 1 }).replace(/^\s+/mg, ' ').replace(/\n/g, ''); 13 | } 14 | 15 | // add 'luster:eex' to the `NODE_DEBUG` environment variable to enable events logging 16 | if (process.env.NODE_DEBUG && /luster:eex/i.test(process.env.NODE_DEBUG)) { 17 | EventEmitterEx.prototype.emit = function(...args) { 18 | const inspectedArgs = args.map(inspect).join(', '); 19 | 20 | console.log('%s(%s).emit(%s)', this.constructor.name || 'EventEmitterEx', this.wid, inspectedArgs); 21 | 22 | return EventEmitter.prototype.emit.apply(this, args); 23 | }; 24 | } 25 | 26 | module.exports = EventEmitterEx; 27 | -------------------------------------------------------------------------------- /lib/luster.js: -------------------------------------------------------------------------------- 1 | const cluster = require('cluster'), 2 | /** @type {ClusterProcess} */ 3 | Proc = require(cluster.isMaster ? './master' : './worker'); 4 | 5 | module.exports = new Proc(); 6 | -------------------------------------------------------------------------------- /lib/master.js: -------------------------------------------------------------------------------- 1 | const os = require('os'), 2 | cluster = require('cluster'), 3 | ClusterProcess = require('./cluster_process'), 4 | WorkerWrapper = require('./worker_wrapper'), 5 | Port = require('./port'), 6 | RestartQueue = require('./restart_queue'), 7 | RPC = require('./rpc'); 8 | 9 | /** 10 | * @constructor 11 | * @class Master 12 | * @augments ClusterProcess 13 | */ 14 | class Master extends ClusterProcess { 15 | constructor() { 16 | super(); 17 | 18 | /** 19 | * @type {Object} 20 | * @property {WorkerWrapper} * 21 | * @public 22 | * @todo make it private or public immutable 23 | */ 24 | this.workers = {}; 25 | 26 | /** 27 | * Workers restart queue. 28 | * @type {RestartQueue} 29 | * @private 30 | */ 31 | this._restartQueue = new RestartQueue(); 32 | 33 | this.id = 0; 34 | this.wid = 0; 35 | this.pid = process.pid; 36 | 37 | this.on('worker state', this._cleanupUnixSockets.bind(this)); 38 | this.on('worker exit', this._checkWorkersAlive.bind(this)); 39 | 40 | // @todo make it optional? 41 | process.on('SIGINT', this._onSignalQuit.bind(this)); 42 | process.on('SIGQUIT', this._onSignalQuit.bind(this)); 43 | } 44 | 45 | /** 46 | * Allows same object structure as cluster.setupMaster(). 47 | * This function must be used instead of cluster.setupMaster(), 48 | * An instance of Master will call it, when running. 49 | * @param {Object} opts 50 | * @see {@link https://nodejs.org/api/cluster.html#clustersetupmastersettings} 51 | */ 52 | setup(opts) { 53 | cluster.setupMaster({...cluster.settings, ...opts}); 54 | } 55 | 56 | /** 57 | * SIGINT and SIGQUIT handler 58 | * @private 59 | */ 60 | _onSignalQuit() { 61 | this 62 | .once('shutdown', () => process.exit(0)) 63 | .shutdown(); 64 | } 65 | 66 | /** 67 | * Remove not used unix socket before worker will try to listen it. 68 | * @param {WorkerWrapper} worker 69 | * @param {WorkerWrapperState} state 70 | * @private 71 | */ 72 | _cleanupUnixSockets(worker, state) { 73 | const port = worker.options.port; 74 | 75 | if (this._restartQueue.has(worker) || 76 | state !== WorkerWrapper.STATES.LAUNCHING || 77 | port.family !== Port.UNIX) { 78 | return; 79 | } 80 | 81 | const inUse = this.getWorkersArray().some(w => 82 | worker.wid !== w.wid && 83 | w.isRunning() && 84 | port.isEqualTo(w.options.port) 85 | ); 86 | 87 | if (!inUse) { 88 | port.unlink(err => { 89 | if (err) { 90 | this.emit('error', err); 91 | } 92 | }); 93 | } 94 | } 95 | 96 | /** 97 | * Check for alive workers, if no one here, then emit "shutdown". 98 | * @private 99 | */ 100 | _checkWorkersAlive() { 101 | const workers = this.getWorkersArray(), 102 | alive = workers.reduce( 103 | (count, w) => w.dead ? count - 1 : count, 104 | workers.length 105 | ); 106 | 107 | if (alive === 0) { 108 | this.emit('shutdown'); 109 | } 110 | } 111 | 112 | /** 113 | * Repeat WorkerWrapper events on Master and add 'worker ' prefix to event names 114 | * so for example 'online' became 'worker online' 115 | * @private 116 | * @param {WorkerWrapper} worker 117 | */ 118 | _proxyWorkerEvents(worker) { 119 | WorkerWrapper.EVENTS 120 | .forEach(eventName => { 121 | const proxyEventName = 'worker ' + eventName; 122 | worker.on(eventName, this.emit.bind(this, proxyEventName, worker)); 123 | }); 124 | } 125 | 126 | /** 127 | * @returns {number[]} workers ids array 128 | */ 129 | getWorkersIds() { 130 | if (!this._workersIdsCache) { 131 | this._workersIdsCache = this.getWorkersArray().map(w => w.wid); 132 | } 133 | 134 | return this._workersIdsCache; 135 | } 136 | 137 | /** 138 | * @returns {WorkerWrapper[]} workers array 139 | */ 140 | getWorkersArray() { 141 | if (!this._workersArrayCache) { 142 | this._workersArrayCache = Object.values(this.workers); 143 | } 144 | 145 | return this._workersArrayCache; 146 | } 147 | 148 | /** 149 | * Add worker to the pool 150 | * @param {WorkerWrapper} worker 151 | * @returns {Master} self 152 | * @public 153 | */ 154 | add(worker) { 155 | // invalidate Master#getWorkersIds and Master#getWorkersArray cache 156 | this._workersIdsCache = null; 157 | this._workersArrayCache = null; 158 | 159 | this.workers[worker.wid] = worker; 160 | this._proxyWorkerEvents(worker); 161 | 162 | return this; 163 | } 164 | 165 | /** 166 | * Iterate over workers in the pool. 167 | * @param {Function} fn 168 | * @public 169 | * @returns {Master} self 170 | * 171 | * @description Shortcut for: 172 | * master.getWorkersArray().forEach(fn); 173 | */ 174 | forEach(fn) { 175 | this.getWorkersArray() 176 | .forEach(fn); 177 | 178 | return this; 179 | } 180 | 181 | /** 182 | * Broadcast an event received by IPC from worker as 'worker '. 183 | * @param {WorkerWrapper} worker 184 | * @param {String} event 185 | * @param {...*} args 186 | */ 187 | broadcastWorkerEvent(worker, event, ...args) { 188 | this.emit('received worker ' + event, worker, ...args); 189 | } 190 | 191 | /** 192 | * Configure cluster 193 | * @override ClusterProcess 194 | * @private 195 | */ 196 | _onConfigured() { 197 | super._onConfigured(); 198 | 199 | // register global remote command in the context of master to receive events from master 200 | if (!this.hasRegisteredRemoteCommand(RPC.fns.master.broadcastWorkerEvent)) { 201 | this.registerRemoteCommand( 202 | RPC.fns.master.broadcastWorkerEvent, 203 | this.broadcastWorkerEvent.bind(this) 204 | ); 205 | } 206 | 207 | const // WorkerWrapper options 208 | forkTimeout = this.config.get('control.forkTimeout'), 209 | stopTimeout = this.config.get('control.stopTimeout'), 210 | exitThreshold = this.config.get('control.exitThreshold'), 211 | allowedSequentialDeaths = this.config.get('control.allowedSequentialDeaths'), 212 | 213 | count = this.config.get('workers', os.cpus().length), 214 | isServerPortSet = this.config.has('server.port'), 215 | groups = this.config.get('server.groups', 1), 216 | portsPerGroup = this.config.get('server.portsPerGroup', 1), 217 | masterInspectPort = this.config.get('properties.masterInspectPort'), 218 | workersPerGroup = Math.floor(count / groups); 219 | 220 | let port, 221 | // workers and groups count 222 | i = 0, 223 | group = 0, 224 | workersInGroup = 0; 225 | 226 | if (isServerPortSet) { 227 | port = new Port(this.config.get('server.port')); 228 | } 229 | 230 | // create pool of workers 231 | while (count > i++) { 232 | this.add(new WorkerWrapper(this, { 233 | forkTimeout, 234 | stopTimeout, 235 | exitThreshold, 236 | allowedSequentialDeaths, 237 | port: isServerPortSet ? port.next(group * portsPerGroup) : 0, 238 | masterInspectPort, 239 | maxListeners: this.getMaxListeners(), 240 | })); 241 | 242 | // groups > 1, current group is full and 243 | // last workers can form at least more one group 244 | if (groups > 1 && 245 | ++workersInGroup >= workersPerGroup && 246 | count - (group + 1) * workersPerGroup >= workersPerGroup) { 247 | workersInGroup = 0; 248 | group++; 249 | } 250 | } 251 | } 252 | 253 | /** 254 | * @param {Number[]} wids Array of `WorkerWrapper#wid` values 255 | * @param {String} event wait for 256 | * @public 257 | * @returns {Promise} 258 | */ 259 | waitForWorkers(wids, event) { 260 | const pendingWids = new Set(wids); 261 | 262 | return new Promise(resolve => { 263 | if (pendingWids.size === 0) { 264 | resolve(); 265 | } 266 | 267 | const onWorkerState = worker => { 268 | const wid = worker.wid; 269 | pendingWids.delete(wid); 270 | if (pendingWids.size === 0) { 271 | this.removeListener(event, onWorkerState); 272 | resolve(); 273 | } 274 | }; 275 | this.on(event, onWorkerState); 276 | }); 277 | } 278 | 279 | /** 280 | * @param {String} event wait for 281 | * @public 282 | * @returns {Promise} 283 | */ 284 | waitForAllWorkers(event) { 285 | return this.waitForWorkers( 286 | this.getWorkersIds(), 287 | event 288 | ); 289 | } 290 | 291 | /** 292 | * @event Master#running 293 | */ 294 | 295 | /** 296 | * @event Master#restarted 297 | */ 298 | 299 | async _restart() { 300 | // TODO maybe run this after starting waitForAllWorkers 301 | this.forEach(worker => worker.restart()); 302 | 303 | await this.waitForAllWorkers('worker ready'); 304 | 305 | this.emit('restarted'); 306 | } 307 | 308 | /** 309 | * Hard workers restart: all workers will be restarted at same time. 310 | * CAUTION: if dead worker is restarted, it will emit 'error' event. 311 | * @public 312 | * @returns {Master} self 313 | * @fires Master#restarted when workers spawned and ready. 314 | */ 315 | restart() { 316 | this._restart(); 317 | return this; 318 | } 319 | 320 | /** 321 | * Workers will be restarted one by one using RestartQueue. 322 | * If a worker becomes dead, it will be just removed from restart queue. However, if already dead worker is pushed 323 | * into the queue, it will emit 'error' on restart. 324 | * @public 325 | * @returns {Master} self 326 | * @fires Master#restarted when workers spawned and ready. 327 | */ 328 | softRestart() { 329 | this.forEach(worker => worker.softRestart()); 330 | this._restartQueue.once('drain', this.emit.bind(this, 'restarted')); 331 | return this; 332 | } 333 | 334 | /** 335 | * Schedules one worker restart using RestartQueue. 336 | * If a worker becomes dead, it will be just removed from restart queue. However, if already dead worker is pushed 337 | * into the queue, it will emit 'error' on restart. 338 | * @public 339 | * @param {WorkerWrapper} worker 340 | * @returns {Master} self 341 | */ 342 | scheduleWorkerRestart(worker) { 343 | this._restartQueue.push(worker); 344 | return this; 345 | } 346 | 347 | /** 348 | * @override 349 | * @see ClusterProcess 350 | * @private 351 | */ 352 | _setupIPCMessagesHandler() { 353 | this.on('worker message', this._onMessage.bind(this)); 354 | } 355 | 356 | /** 357 | * RPC to all workers 358 | * @method 359 | * @param {String} name of called command in the worker 360 | * @param {...*} args 361 | * @public 362 | */ 363 | remoteCallToAll(name, ...args) { 364 | this.forEach(worker => { 365 | if (worker.ready) { 366 | worker.remoteCall(name, ...args); 367 | } else { 368 | worker.on('ready', () => { 369 | worker.remoteCall(name, ...args); 370 | }); 371 | } 372 | }); 373 | } 374 | 375 | /** 376 | * Broadcast event to all workers. 377 | * @method 378 | * @param {String} event of called command in the worker 379 | * @param {...*} args 380 | * @public 381 | */ 382 | broadcastEventToAll(event, ...args) { 383 | this.forEach(worker => { 384 | if (worker.ready) { 385 | worker.broadcastEvent(event, ...args); 386 | } 387 | }); 388 | } 389 | 390 | /** 391 | * Emit event on master and all workers in "ready" state. 392 | * @method 393 | * @param {String} event of called command in the worker 394 | * @param {...*} args 395 | * @public 396 | */ 397 | emitToAll(event, ...args) { 398 | this.emit(event, ...args); 399 | this.broadcastEventToAll(event, ...args); 400 | } 401 | 402 | /** 403 | * @event Master#shutdown 404 | */ 405 | 406 | async _shutdown() { 407 | const stoppedWorkers = []; 408 | 409 | this.forEach(worker => { 410 | if (worker.isRunning()) { 411 | worker.stop(); 412 | stoppedWorkers.push(worker.wid); 413 | } 414 | }); 415 | 416 | await this.waitForWorkers( 417 | stoppedWorkers, 418 | 'worker exit', 419 | ); 420 | 421 | this.emit('shutdown'); 422 | } 423 | 424 | /** 425 | * Stop all workers and emit `Master#shutdown` event after successful shutdown of all workers. 426 | * @fires Master#shutdown 427 | * @returns {Master} 428 | */ 429 | shutdown() { 430 | this._shutdown(); 431 | return this; 432 | } 433 | 434 | /** 435 | * Do a remote call to all workers, callbacks are registered and then executed separately for each worker 436 | * @method 437 | * @param {String} opts.command 438 | * @param {Function} opts.callback 439 | * @param {Number} [opts.timeout] in milliseconds 440 | * @param {*} [opts.data] 441 | * @public 442 | */ 443 | remoteCallToAllWithCallback(opts) { 444 | this.forEach(worker => { 445 | if (worker.isRunning()) { 446 | worker.remoteCallWithCallback(opts); 447 | } 448 | }); 449 | } 450 | 451 | async _run() { 452 | await this.whenInitialized(); 453 | 454 | // TODO maybe run this after starting waitForAllWorkers 455 | this.forEach(worker => worker.run()); 456 | 457 | await this.waitForAllWorkers('worker ready'); 458 | 459 | this.emit('running'); 460 | } 461 | 462 | /** 463 | * Fork workers. 464 | * Execution will be delayed until Master became configured 465 | * (`configured` event fired). 466 | * @method 467 | * @returns {Master} self 468 | * @public 469 | * @fires Master#running then workers spawned and ready. 470 | * 471 | * @example 472 | * // file: master.js 473 | * var master = require('luster'); 474 | * 475 | * master 476 | * .configure({ app : 'worker' }) 477 | * .run(); 478 | * 479 | * // there is master is still not running anyway 480 | * // it will run immediate once configured and 481 | * // current thread execution done 482 | */ 483 | run() { 484 | this._run(); 485 | return this; 486 | } 487 | } 488 | 489 | module.exports = Master; 490 | -------------------------------------------------------------------------------- /lib/port.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'), 2 | LusterPortError = require('./errors').LusterPortError, 3 | UNIX_SOCKET_MASK = '*'; 4 | 5 | /** 6 | * @param {String|Number} value 7 | * @returns {Boolean} 8 | * @private 9 | */ 10 | function isUnixSocket(value) { 11 | return isNaN(value); 12 | } 13 | 14 | /** 15 | * @constructor 16 | * @class Port 17 | * @param {String|Number} value 18 | */ 19 | class Port { 20 | constructor(value) { 21 | this.value = value; 22 | } 23 | 24 | /** 25 | * @memberOf {Port} 26 | * @property {String} family 27 | * @public 28 | * @readonly 29 | */ 30 | get family() { 31 | return isUnixSocket(this.value) ? Port.UNIX : Port.INET; 32 | } 33 | 34 | /** 35 | * @param {*} port 36 | * @returns {Boolean} 37 | * @public 38 | */ 39 | isEqualTo(port) { 40 | if (!(port instanceof Port)) { 41 | return false; 42 | } 43 | 44 | return this.value === port.value; 45 | } 46 | 47 | /** 48 | * @param {Number|String} [it=1] 49 | * @returns {Port} 50 | * @public 51 | */ 52 | next(it) { 53 | if (typeof it === 'undefined') { 54 | it = 1; 55 | } 56 | 57 | const newVal = this.family === Port.UNIX ? 58 | this.value.replace(UNIX_SOCKET_MASK, it.toString()) : 59 | Number(this.value) + it; 60 | 61 | return new Port(newVal); 62 | } 63 | 64 | /** 65 | * @param {Error} [err] 66 | * @param {Function} cb 67 | */ 68 | unlink(err, cb) { 69 | if (!cb && typeof err === 'function') { 70 | cb = err; 71 | err = undefined; 72 | } 73 | 74 | if (err) { 75 | cb(LusterPortError 76 | .createError(LusterPortError.CODES.UNKNOWN_ERROR, err)); 77 | return; 78 | } 79 | 80 | const value = this.value; 81 | 82 | if (this.family !== Port.UNIX) { 83 | cb(LusterPortError 84 | .createError(LusterPortError.CODES.NOT_UNIX_SOCKET) 85 | .bind({value})); 86 | return; 87 | } 88 | 89 | fs.unlink(value, err => { 90 | if (err && err.code !== 'ENOENT') { 91 | cb(LusterPortError 92 | .createError(LusterPortError.CODES.CAN_NOT_UNLINK_UNIX_SOCKET, err) 93 | .bind({socketPath: value})); 94 | return; 95 | } 96 | 97 | cb(); 98 | }); 99 | } 100 | 101 | toString() { 102 | return this.value; 103 | } 104 | 105 | valueOf() { 106 | return this.value; 107 | } 108 | 109 | /** 110 | * @property {String} UNIX 111 | * @memberOf {Port} 112 | * @readonly 113 | */ 114 | static get UNIX() { 115 | return 'unix'; 116 | } 117 | 118 | /** 119 | * @property {String} INET 120 | * @memberOf {Port} 121 | * @readonly 122 | */ 123 | static get INET() { 124 | return 'inet'; 125 | } 126 | } 127 | 128 | module.exports = Port; 129 | -------------------------------------------------------------------------------- /lib/restart_queue.js: -------------------------------------------------------------------------------- 1 | const EventEmitterEx = require('./event_emitter_ex'), 2 | WorkerWrapper = require('./worker_wrapper'); 3 | 4 | /** 5 | * Restart queue became empty 6 | * @event RestartQueue#drain 7 | */ 8 | 9 | /** 10 | * Restart queue allows only one worker to be restarted at a time, waiting for its `ready` event or when worker becomes 11 | * dead. 12 | * If a worker is restarted outside the queue or becomes dead, it will be removed from the queue. 13 | * @constructor 14 | * @class RestartQueue 15 | * @augments EventEmitterEx 16 | */ 17 | class RestartQueue extends EventEmitterEx { 18 | constructor() { 19 | super(); 20 | 21 | /** 22 | * @type {WorkerWrapper[]} 23 | * @private 24 | */ 25 | this._queue = []; 26 | } 27 | 28 | /** 29 | * Adds new worker in restart queue. Does nothing if worker is already in queue. 30 | * @public 31 | * @param {WorkerWrapper} worker 32 | */ 33 | push(worker) { 34 | if (this.has(worker)) { 35 | // Worker is already in queue, do nothing 36 | return; 37 | } 38 | 39 | const removeWorker = () => { 40 | worker.removeListener('ready', removeWorker); 41 | worker.removeListener('state', checkDead); 42 | this._remove(worker); 43 | }; 44 | 45 | const checkDead = state => { 46 | if (state === WorkerWrapper.STATES.STOPPED && worker.dead) { 47 | removeWorker(); 48 | } 49 | }; 50 | 51 | worker.on('ready', removeWorker); 52 | worker.on('state', checkDead); 53 | this._queue.push(worker); 54 | this._process(); 55 | } 56 | 57 | /** 58 | * Returns true if specified worker is in this queue. 59 | * @public 60 | * @param {WorkerWrapper} worker 61 | * @returns {Boolean} 62 | */ 63 | has(worker) { 64 | return this._queue.indexOf(worker) !== -1; 65 | } 66 | 67 | /** 68 | * Removes specified worker from queue 69 | * @private 70 | * @param {WorkerWrapper} worker 71 | */ 72 | _remove(worker) { 73 | const idx = this._queue.indexOf(worker); 74 | this._queue.splice(idx, 1); 75 | this._process(); 76 | } 77 | 78 | /** 79 | * Checks if any processing is needed. Should be called after each restart queue state change. 80 | * @private 81 | */ 82 | _process() { 83 | if (this._queue.length === 0) { 84 | this.emit('drain'); 85 | return; 86 | } 87 | 88 | const head = this._queue[0]; 89 | if (head.ready) { 90 | head.restart(); 91 | } 92 | } 93 | } 94 | 95 | module.exports = RestartQueue; 96 | -------------------------------------------------------------------------------- /lib/rpc-callback.js: -------------------------------------------------------------------------------- 1 | const LusterRPCCallbackError = require('./errors').LusterRPCCallbackError; 2 | 3 | const RPCCallback = { 4 | _storage: {}, 5 | 6 | _counter: 0, 7 | 8 | /** 9 | * @param {ClusterProcess} proc 10 | * @param {String} command 11 | * @param {Function} callback 12 | * @param {Number} [timeout=10000] in milliseconds 13 | * @returns {String} callbackId 14 | */ 15 | setCallback(proc, command, callback, timeout) { 16 | const storage = this._storage; 17 | 18 | if ( ! timeout) { 19 | timeout = 10000; 20 | } 21 | 22 | const callbackId = proc.wid + '_' + this._counter++; 23 | 24 | storage[callbackId] = { 25 | callback, 26 | timeout: 27 | setTimeout(() => { 28 | storage[callbackId].callback( 29 | proc, 30 | LusterRPCCallbackError.createError( 31 | LusterRPCCallbackError.CODES.REMOTE_CALL_WITH_CALLBACK_TIMEOUT, 32 | { command })); 33 | this.removeCallback(callbackId); 34 | }, timeout) 35 | }; 36 | 37 | return callbackId; 38 | }, 39 | 40 | /** 41 | * @param {ClusterProcess} proc 42 | * @param {String} callbackId 43 | * @param {*} [data] provided in callback 44 | */ 45 | processCallback(proc, callbackId, data) { 46 | const stored = this._storage[callbackId]; 47 | 48 | if ( ! stored) { 49 | return; 50 | } 51 | 52 | setImmediate(() => stored.callback(proc, null, data)); 53 | this.removeCallback(callbackId); 54 | }, 55 | 56 | /** 57 | * @param {String} callbackId 58 | */ 59 | removeCallback(callbackId) { 60 | const timeout = this._storage[callbackId].timeout; 61 | 62 | clearTimeout(timeout); 63 | delete this._storage[callbackId]; 64 | } 65 | }; 66 | 67 | module.exports = RPCCallback; 68 | -------------------------------------------------------------------------------- /lib/rpc.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {{createCaller: Function, parseMessage: Function}} 3 | */ 4 | const RPC = { 5 | /** 6 | * @param {Object} target must have `send` method 7 | * @returns {Function} (String name, ...args) 8 | */ 9 | createCaller(target) { 10 | return function(name, ...args) { 11 | const message = { cmd: 'luster_' + name }; 12 | 13 | if (args.length > 0) { 14 | message.args = args; 15 | } 16 | 17 | target.send(message); 18 | }; 19 | }, 20 | 21 | /** 22 | * @typedef IPCMessage 23 | * @property {String} cmd Command name, starts with the prefix 'luster_' 24 | * @property {Array} [args] Command arguments 25 | */ 26 | 27 | /** 28 | * @param {*} message 29 | * @returns IPCMessage|null IPCMessage if `message` is valid luster IPC message, null – while not 30 | */ 31 | parseMessage(message) { 32 | if (message && 33 | typeof message.cmd === 'string' && 34 | message.cmd.indexOf('luster_') === 0) { 35 | 36 | return /** @type IPCMessage */{ 37 | cmd: message.cmd.substr(7), 38 | args: message.args 39 | }; 40 | } else { 41 | return null; 42 | } 43 | }, 44 | 45 | /** 46 | * Core remote functions dictionaries 47 | */ 48 | fns: { 49 | worker: { 50 | broadcastMasterEvent: 'core.worker.broadcastMasterEvent', 51 | applyForeignProperties: 'core.worker.applyForeignProperties', 52 | suspend: 'core.worker.suspend' 53 | }, 54 | master: { 55 | broadcastWorkerEvent: 'core.master.broadcastWorkerEvent' 56 | }, 57 | callback: 'core.callback' 58 | } 59 | }; 60 | 61 | module.exports = RPC; 62 | -------------------------------------------------------------------------------- /lib/worker.js: -------------------------------------------------------------------------------- 1 | const cluster = require('cluster'), 2 | RPC = require('./rpc'), 3 | RPCCallback = require('./rpc-callback'), 4 | ClusterProcess = require('./cluster_process'), 5 | LusterWorkerError = require('./errors').LusterWorkerError; 6 | 7 | const wid = parseInt(process.env.LUSTER_WID, 10); 8 | 9 | /** 10 | * @constructor 11 | * @class Worker 12 | * @augments ClusterProcess 13 | */ 14 | class Worker extends ClusterProcess { 15 | constructor() { 16 | super(); 17 | 18 | const broadcastEvent = this._broadcastEvent; 19 | 20 | this._foreignPropertiesReceivedPromise = new Promise(resolve => { 21 | this.once('foreign properties received', () => { 22 | resolve(); 23 | }); 24 | }); 25 | 26 | this.on('configured', broadcastEvent.bind(this, 'configured')); 27 | this.on('extension loaded', broadcastEvent.bind(this, 'extension loaded')); 28 | this.on('initialized', broadcastEvent.bind(this, 'initialized')); 29 | this.on('loaded', broadcastEvent.bind(this, 'loaded')); 30 | this.on('ready', broadcastEvent.bind(this, 'ready')); 31 | cluster.worker.on('disconnect', this.emit.bind(this, 'disconnect')); 32 | 33 | this._ready = false; 34 | 35 | this.registerRemoteCommand(RPC.fns.worker.applyForeignProperties, this.applyForeignProperties.bind(this)); 36 | this.registerRemoteCommand(RPC.fns.worker.broadcastMasterEvent, this.broadcastMasterEvent.bind(this)); 37 | 38 | this._suspendFunctions = []; 39 | this.registerRemoteCommandWithCallback(RPC.fns.worker.suspend, this._suspend.bind(this)); 40 | this._suspendPromise = null; 41 | } 42 | 43 | /** 44 | * @memberOf {Worker} 45 | * @property {Number} Persistent Worker identifier 46 | * @readonly 47 | * @public 48 | */ 49 | get wid(){ 50 | return wid; 51 | } 52 | 53 | /** 54 | * Worker id (alias for cluster.worker.id) 55 | * @memberOf {Worker} 56 | * @property {Number} id 57 | * @readonly 58 | * @public 59 | */ 60 | get id() { 61 | return cluster.worker.id; 62 | } 63 | 64 | /** 65 | * Emit an event received from the master as 'master '. 66 | */ 67 | broadcastMasterEvent(proc, emitArgs) { 68 | const [eventName, ...eventArgs] = emitArgs; 69 | this.emit('master ' + eventName, ...eventArgs); 70 | } 71 | 72 | /** 73 | * Transmit worker event to master, which plays as relay, 74 | * retransmitting it as 'worker ' to all master-side listeners. 75 | * @param {String} event Event name 76 | * @param {...*} args 77 | * @private 78 | */ 79 | _broadcastEvent(event, ...args) { 80 | this.remoteCall(RPC.fns.master.broadcastWorkerEvent, event, ...args); 81 | } 82 | 83 | /** 84 | * Extend {Worker} properties with passed by {Master}. 85 | * @param {ClusterProcess} proc 86 | * @param {*} props 87 | */ 88 | applyForeignProperties(proc, props) { 89 | for (const propName of Object.keys(props)) { 90 | Object.defineProperty(this, propName, { 91 | value: props[propName], 92 | enumerable: true 93 | }); 94 | } 95 | this.emit('foreign properties received'); 96 | } 97 | 98 | whenForeignPropertiesReceived() { 99 | return this._foreignPropertiesReceivedPromise; 100 | } 101 | 102 | /** 103 | * @override 104 | * @see ClusterProcess 105 | * @private 106 | */ 107 | _setupIPCMessagesHandler() { 108 | process.on('message', this._onMessage.bind(this, this)); 109 | } 110 | 111 | /** 112 | * Turns worker to `ready` state. Must be called by worker 113 | * if option `control.triggerReadyStateManually` set `true`. 114 | * @returns {Worker} self 115 | * @public 116 | */ 117 | ready() { 118 | if (this._ready) { 119 | throw new LusterWorkerError(LusterWorkerError.CODES.ALREADY_READY); 120 | } 121 | 122 | this._ready = true; 123 | this.emit('ready'); 124 | 125 | return this; 126 | } 127 | 128 | /** 129 | * Do a remote call to master, wait for master to handle it, then execute registered callback 130 | * @method 131 | * @param {String} opts.command 132 | * @param {Function} opts.callback 133 | * @param {Number} [opts.timeout] in milliseconds 134 | * @param {*} [opts.data] 135 | * @public 136 | */ 137 | remoteCallWithCallback(opts) { 138 | const callbackId = RPCCallback.setCallback(this, opts.command, opts.callback, opts.timeout); 139 | 140 | this.remoteCall(opts.command, opts.data, callbackId); 141 | } 142 | 143 | async _run() { 144 | await this.whenInitialized(); 145 | await this.whenForeignPropertiesReceived(); 146 | 147 | const workerBase = this.config.resolve('app'); 148 | 149 | require(workerBase); 150 | this.emit('loaded', workerBase); 151 | 152 | if (!this.config.get('control.triggerReadyStateManually', false)) { 153 | setImmediate(this.ready.bind(this)); 154 | } 155 | } 156 | 157 | /** 158 | * `Require` application main script. 159 | * Execution will be delayed until Worker became configured 160 | * (`configured` event fired). 161 | * @returns {Worker} self 162 | * @public 163 | */ 164 | run() { 165 | this._run(); 166 | return this; 167 | } 168 | 169 | /** 170 | * @callback SuspendFunction 171 | * @returns void|Promise 172 | */ 173 | 174 | /** 175 | * This adds new function that will be called before stopping worker. 176 | * Worker will wait for returned promise to resolve and then report to master it suspended successfully. 177 | * Rejects will emit 'error' event, no report to master will happen. 178 | * All suspend functions are called simultaneously. 179 | * Suspend function will not be called more than once. 180 | * @param {SuspendFunction} func 181 | * @public 182 | */ 183 | registerSuspendFunction(func) { 184 | this._suspendFunctions.push(func); 185 | } 186 | 187 | _suspend(callback) { 188 | if (!this._suspendPromise) { 189 | this._suspendPromise = Promise.all(this._suspendFunctions.map(func => func())); 190 | } 191 | 192 | this._suspendPromise.then( 193 | callback, 194 | error => { 195 | this.emit('error', error); 196 | } 197 | ); 198 | } 199 | } 200 | 201 | /** 202 | * Call Master method via RPC 203 | * @method 204 | * @param {String} name of called command in the master 205 | * @param {*} ...args 206 | */ 207 | Worker.prototype.remoteCall = RPC.createCaller(process); 208 | 209 | module.exports = Worker; 210 | -------------------------------------------------------------------------------- /lib/worker_wrapper.js: -------------------------------------------------------------------------------- 1 | const cluster = require('cluster'), 2 | RPC = require('./rpc'), 3 | RPCCallback = require('./rpc-callback'), 4 | EventEmitterEx = require('./event_emitter_ex'), 5 | Port = require('./port'), 6 | LusterWorkerWrapperError = require('./errors').LusterWorkerWrapperError; 7 | 8 | /** 9 | * Identifier for next constructed WorkerWrapper. 10 | * Usage restricted to WorkerWrapper constructor only. 11 | * @type {Number} 12 | * @private 13 | */ 14 | let nextId = 0; 15 | 16 | /** 17 | * @class WorkerWrapperOptions 18 | * @property {Number|String} port Port number or socket path which worker can listen 19 | * @property {Boolean} [persistent=true] While `persistent === true` worker will be restarted on exit 20 | * @property {Number} [forkTimeout=false] 21 | * Time (in ms) to wait from 'fork' event to 'online' before launch if failed. 22 | * If evaluates to `false` then forkTimeout will not set. 23 | * @property {Number} [stopTimeout=false] 24 | * Time (in ms) to wait from 'disconnect' event to 'exit' before `worker.kill` call 25 | * If evaluates to `false` then stopTimeout will not set. 26 | * @property {Number} [exitThreshold=false] 27 | * @property {Number} [allowedSequentialDeaths=0] 28 | * How many times worker can die in `exitThreshold` time before will be marked as dead. 29 | */ 30 | class WorkerWrapperOptions { 31 | constructor(options) { 32 | this.persistent = typeof options.persistent === 'undefined' ? true : options.persistent; 33 | this.forkTimeout = options.forkTimeout; 34 | this.stopTimeout = options.stopTimeout; 35 | this.exitThreshold = options.exitThreshold; 36 | this.allowedSequentialDeaths = options.allowedSequentialDeaths || 0; 37 | this.port = options.port; 38 | this.masterInspectPort = options.masterInspectPort; 39 | } 40 | 41 | get port() { 42 | return this._port; 43 | } 44 | 45 | /** 46 | * Setter of `this.options.port` affects value of the `isListeningUnixSocket` property. 47 | * @memberOf WorkerWrapperOptions 48 | * @property {Number|String} port 49 | * @public 50 | */ 51 | set port(value) { 52 | if (!(value instanceof Port)) { 53 | value = new Port(value); 54 | } 55 | 56 | return this._port = value; 57 | } 58 | } 59 | 60 | /** 61 | * @event WorkerWrapper#state 62 | * @param {WorkerWrapperState} state Actual WorkerWrapper state 63 | * @see WorkerWrapper.STATES for possible `state` values. 64 | */ 65 | 66 | /** 67 | * @constructor 68 | * @class WorkerWrapper 69 | * @augments EventEmitterEx 70 | * @param {Master} master 71 | * @param {WorkerWrapperOptions} options 72 | * 73 | * # Worker wrapper state transitions 74 | * 75 | * WorkerWrapper has 'stopped' state by default (once created). 76 | * External events can 77 | */ 78 | class WorkerWrapper extends EventEmitterEx { 79 | constructor(master, options) { 80 | super(); 81 | 82 | if (options && 83 | typeof options.maxListeners !== 'undefined' && 84 | options.maxListeners > this.getMaxListeners()) { 85 | this.setMaxListeners(options.maxListeners); 86 | } 87 | 88 | /** 89 | * WorkerWrapper state. Must be set via private method `WorkerWrapper#_setState(value)`, 90 | * not directly. Can be retrieved via `WorkerWrapper#state` getter. 91 | * @see WorkerWrapper.STATES for possible values. 92 | * @type WorkerWrapperState 93 | */ 94 | this._state = WorkerWrapper.STATES.STOPPED; 95 | 96 | /** 97 | * @type WorkerWrapperOptions 98 | * @public 99 | * @readonly 100 | */ 101 | this.options = new WorkerWrapperOptions(options); 102 | 103 | this._wid = ++nextId; 104 | 105 | /** 106 | * Indicates worker restarting in progress. 107 | * Changing `restarting` property value outside WorkerWrapper and it inheritors is not recommended. 108 | * @property {Boolean} restarting 109 | * @memberOf {WorkerWrapper} 110 | * @public 111 | */ 112 | this.restarting = false; 113 | 114 | /** 115 | * Indicates worker stopping in progress. 116 | * Changing `stopping` property value outside WorkerWrapper and it inheritors is not recommended. 117 | * @property {Boolean} stopping 118 | * @memberOf {WorkerWrapper} 119 | * @public 120 | */ 121 | this.stopping = false; 122 | 123 | /** 124 | * Worker can be marked as dead on sequential fails of launch attempt. 125 | * Dead worker will not be restarted on event WorkerWrapper#state('stopped'). 126 | * Internally in the WorkerWrapper worker can be marked as dead, but never go alive again. 127 | * To revive the worker something outside of the WorkerWrapper 128 | * must set the `dead` property value to `false`. 129 | * @public 130 | * @type {Boolean} 131 | */ 132 | this.dead = false; 133 | 134 | /** 135 | * Port for node v8 debugger on this worker. 136 | * @type {Number} 137 | * @public 138 | */ 139 | this.inspectPort = this.options.masterInspectPort + this.wid; 140 | 141 | /** 142 | * Number of sequential deaths when worker life time was less than `exitThreshold` option value. 143 | * @type {Number} 144 | * @private 145 | */ 146 | this._sequentialDeaths = 0; 147 | 148 | /** 149 | * Time of the last WorkerWrapper#state('running') event. 150 | * @type {Number} 151 | */ 152 | this.startTime = null; 153 | 154 | /** 155 | * Indicates whether ready() method was called in worker 156 | * @public 157 | * @type {Boolean} 158 | */ 159 | this.ready = false; 160 | 161 | /** 162 | * @type {Master} 163 | * @private 164 | */ 165 | this._master = master; 166 | 167 | /** 168 | * Listen for cluster#fork and worker events. 169 | * @see WorkerWrapper#_proxyEvents to know about repeating worker events on WorkerWrapper instance. 170 | */ 171 | cluster.on('fork', this._onFork.bind(this)); 172 | this.on('online', this._onOnline.bind(this)); 173 | this.on('disconnect', this._onDisconnect.bind(this)); 174 | this.on('exit', this._onExit.bind(this)); 175 | 176 | WorkerWrapper._RPC_EVENTS.forEach(event => { 177 | master.on('received worker ' + event, WorkerWrapper.createEventTranslator(event).bind(this)); 178 | }); 179 | this.on('ready', this._onReady.bind(this)); 180 | } 181 | 182 | /** 183 | * @property {Number} wid Persistent WorkerWrapper identifier 184 | * @memberOf {WorkerWrapper} 185 | * @public 186 | * @readonly 187 | */ 188 | get wid() { 189 | return this._wid; 190 | } 191 | 192 | /** 193 | * @property {Number} pid System process identifier 194 | * @memberOf {WorkerWrapper} 195 | * @public 196 | * @readonly 197 | */ 198 | get pid() { 199 | return this.process.pid; 200 | } 201 | 202 | /** 203 | * Current WorkerWrapper instance state 204 | * @see WorkerWrapper.STATES for possible values. 205 | * @property {WorkerWrapperState} state 206 | * @memberOf {WorkerWrapper} 207 | * @public 208 | * @readonly 209 | */ 210 | get state() { 211 | return this._state; 212 | } 213 | 214 | /** 215 | * @see WorkerWrapper.STATES for possible `state` argument values 216 | * @param {WorkerWrapperState} state 217 | * @private 218 | * @fires WorkerWrapper#state 219 | */ 220 | _setState(state) { 221 | this._state = state; 222 | 223 | this['_onState' + state[0].toUpperCase() + state.slice(1)](); 224 | 225 | this.emit('state', state); 226 | } 227 | 228 | static createEventTranslator(event) { 229 | return /** @this {WorkerWrapper} */function(worker) { 230 | if (this._worker && worker.id === this._worker.id) { 231 | this.emit(event); 232 | } 233 | }; 234 | } 235 | 236 | _onReady() { 237 | this.ready = true; 238 | } 239 | 240 | /** 241 | * @private 242 | */ 243 | _onExit() { 244 | this.ready = false; 245 | this._setState(WorkerWrapper.STATES.STOPPED); 246 | } 247 | 248 | /** 249 | * event:_worker#disconnect handler 250 | * @private 251 | */ 252 | _onDisconnect() { 253 | // "disconnect" and "exit" may be triggered in any order: 254 | // https://nodejs.org/docs/latest-v22.x/api/cluster.html#clusterworkers 255 | // Check state is stoppable for coordination. 256 | if (this.isRunning()) { 257 | this.ready = false; 258 | this._setState(WorkerWrapper.STATES.STOPPING); 259 | } 260 | } 261 | 262 | /** 263 | * @event WorkerWrapper#ready 264 | */ 265 | 266 | /** 267 | * event:_worker#online handler 268 | * @fires WorkerWrapper#ready 269 | * @private 270 | */ 271 | _onOnline() { 272 | this._setState(WorkerWrapper.STATES.RUNNING); 273 | 274 | // pass some of the {WorkerWrapper} properties to {Worker} 275 | this.remoteCall(RPC.fns.worker.applyForeignProperties, { 276 | pid: this.process.pid 277 | }); 278 | } 279 | 280 | /** 281 | * event:cluster#fork handler 282 | * @private 283 | */ 284 | _onFork(worker) { 285 | if (this._worker && worker.id === this._worker.id) { 286 | this.emit('fork'); 287 | this._setState(WorkerWrapper.STATES.LAUNCHING); 288 | } 289 | } 290 | 291 | /** 292 | * event:state('launching') handler 293 | * @private 294 | */ 295 | _onStateLaunching() { 296 | this.restarting = false; 297 | 298 | if (this.options.forkTimeout) { 299 | this.launchTimeout = setTimeout(() => { 300 | this.launchTimeout = null; 301 | 302 | if (this._worker !== null) { 303 | this._worker.kill(); 304 | } 305 | }, this.options.forkTimeout); 306 | } 307 | } 308 | 309 | /** 310 | * event:state('running') handler 311 | * @private 312 | */ 313 | _onStateRunning() { 314 | if (this.launchTimeout) { 315 | clearTimeout(this.launchTimeout); 316 | this.launchTimeout = null; 317 | } 318 | 319 | this.startTime = Date.now(); 320 | } 321 | 322 | /** 323 | * event:state('stopping') handler 324 | * @private 325 | */ 326 | _onStateStopping() { 327 | this._scheduleForceStop(); 328 | } 329 | 330 | /** 331 | * event:state('stopped') handler 332 | * @private 333 | */ 334 | _onStateStopped() { 335 | this._cancelForceStop(); 336 | 337 | // increase sequential deaths count if worker life time less 338 | // than `exitThreshold` option value (and option was passed to constructor). 339 | if (this.options.exitThreshold && 340 | Date.now() - this.startTime < this.options.exitThreshold && 341 | !this.restarting && 342 | !this.stopping) { 343 | this._sequentialDeaths++; 344 | } 345 | 346 | // mark worker as dead if too much sequential deaths 347 | if (this._sequentialDeaths > this.options.allowedSequentialDeaths) { 348 | this.dead = true; 349 | } 350 | 351 | this._worker = null; 352 | 353 | // start worker again if it persistent or in the restarting state 354 | // and isn't marked as dead 355 | if (((this.options.persistent && !this.stopping) || this.restarting) && !this.dead) { 356 | setImmediate(this.run.bind(this)); 357 | } 358 | 359 | this.stopping = false; 360 | } 361 | 362 | /** 363 | * @returns {Boolean} 364 | */ 365 | isRunning() { 366 | return this.state === WorkerWrapper.STATES.LAUNCHING || 367 | this.state === WorkerWrapper.STATES.RUNNING; 368 | } 369 | 370 | /** 371 | * @event WorkerWrapper#error 372 | * @param {LusterError} error 373 | */ 374 | 375 | /** 376 | * Spawn a worker 377 | * @fires WorkerWrapper#error if worker already running 378 | * @fires WorkerWrapper#state('launching') on success 379 | * @returns {WorkerWrapper} self 380 | */ 381 | run() { 382 | if (this.isRunning()) { 383 | this.emit('error', 384 | LusterWorkerWrapperError.createError( 385 | LusterWorkerWrapperError.CODES.INVALID_ATTEMPT_TO_CHANGE_STATE, 386 | { 387 | wid: this.wid, 388 | pid: this.process.pid, 389 | state: this.state, 390 | targetState: WorkerWrapper.STATES.LAUNCHING 391 | })); 392 | 393 | return this; 394 | } 395 | 396 | setImmediate(() => { 397 | // this call of setup sets inspectPort for node js debugger 398 | // it should be here so that every worker can receive the same debugger port after restarting 399 | if (this.inspectPort) { 400 | this._master.setup({inspectPort: this.inspectPort}); 401 | } 402 | /** @private */ 403 | this._worker = cluster.fork({ 404 | port: this.options.port, 405 | LUSTER_WID: this.wid, 406 | }); 407 | 408 | /** @private */ 409 | this._remoteCall = RPC.createCaller(this._worker); 410 | 411 | this._proxyEvents(); 412 | }); 413 | 414 | return this; 415 | } 416 | 417 | /** 418 | * Disconnect worker to stop it. 419 | * @fires WorkerWrapper#error if worker status is 'stopped' or 'stopping' 420 | * @fires WorkerWrapper#status('stopping') on success 421 | * @returns {WorkerWrapper} 422 | */ 423 | stop() { 424 | if (!this.isRunning()) { 425 | this.emit('error', 426 | LusterWorkerWrapperError.createError( 427 | LusterWorkerWrapperError.CODES.INVALID_ATTEMPT_TO_CHANGE_STATE, 428 | { 429 | wid: this.wid, 430 | pid: this.process.pid, 431 | state: this.state, 432 | targetState: WorkerWrapper.STATES.STOPPING 433 | })); 434 | 435 | return this; 436 | } 437 | 438 | this.emit('stop'); 439 | this.stopping = true; 440 | const stopPid = this._worker && this.pid; 441 | 442 | setImmediate(() => { 443 | // state can be changed before function call 444 | if (this.isRunning()) { 445 | this.remoteCallWithCallback({ 446 | command: RPC.fns.worker.suspend, 447 | callback: () => { 448 | // Worker could die while suspend was in-flight, i.e. on endless loop, and _onStateStopped will drop _worker 449 | // Or a new one could be started already 450 | if (this._worker && (this.pid === stopPid)) { 451 | this._worker.disconnect(); 452 | } 453 | } 454 | }); 455 | 456 | this._scheduleForceStop(); 457 | } 458 | }); 459 | 460 | return this; 461 | } 462 | 463 | /** 464 | * Set WorkerWrapper#restarting to `true` and stop it, 465 | * which leads to worker restart. 466 | */ 467 | restart() { 468 | this.restarting = true; 469 | this.stop(); 470 | } 471 | 472 | /** 473 | * Call Worker method via RPC 474 | * @method 475 | * @param {String} name of called command in the worker 476 | * @param {...*} args 477 | */ 478 | remoteCall(name, ...args) { 479 | if (this.isRunning()) { 480 | this._remoteCall(name, ...args); 481 | } else { 482 | this.emit('error', 483 | LusterWorkerWrapperError.createError( 484 | LusterWorkerWrapperError.CODES.REMOTE_COMMAND_CALL_TO_STOPPED_WORKER, 485 | { 486 | wid: this.wid, 487 | pid: this.process.pid, 488 | command: name 489 | })); 490 | } 491 | } 492 | 493 | // proxy some properties to WorkerWrapper#_worker 494 | 495 | get id() { 496 | return this._worker.id; 497 | } 498 | 499 | get process() { 500 | return this._worker.process; 501 | } 502 | 503 | get suicide() { 504 | return this._worker.suicide; 505 | } 506 | 507 | // proxy some methods to WorkerWrapper#_worker 508 | send(...args) { 509 | this._worker.send(...args); 510 | } 511 | 512 | disconnect() { 513 | this._worker.disconnect(); 514 | } 515 | 516 | /** 517 | * repeat events from WorkerWrapper#_worker on WorkerWrapper 518 | * @private 519 | */ 520 | _proxyEvents() { 521 | WorkerWrapper._PROXY_EVENTS 522 | .forEach(eventName => { 523 | this._worker.on(eventName, this.emit.bind(this, eventName)); 524 | }); 525 | } 526 | 527 | inspect() { 528 | return 'WW{ id:' + this.wid + ', state: ' + this.state + '}'; 529 | } 530 | 531 | broadcastEvent(...args) { 532 | //TODO args here passed as single array, but remoteCall can handle multiple args 533 | this.remoteCall(RPC.fns.worker.broadcastMasterEvent, args); 534 | } 535 | 536 | /** 537 | * Do a remote call to worker, wait for worker to handle it, then execute registered callback 538 | * @method 539 | * @param {String} opts.command 540 | * @param {Function} opts.callback 541 | * @param {Number} [opts.timeout] in milliseconds 542 | * @param {*} [opts.data] 543 | * @public 544 | */ 545 | remoteCallWithCallback(opts) { 546 | const callbackId = RPCCallback.setCallback(this, opts.command, opts.callback, opts.timeout); 547 | 548 | this.remoteCall(opts.command, opts.data, callbackId); 549 | } 550 | 551 | /** 552 | * Schedule a forceful worker stop using signal. 553 | * Only schedules timeout if it was not set yet. 554 | * @private 555 | */ 556 | _scheduleForceStop() { 557 | // We could schedule force stop either when `stop` method is called or when `disconnected` event received from 558 | // worker. In most cases `stop` will be called and then `disconnected` event will fire, therefore we shall 559 | // make sure we do not re-run the force stop timer. 560 | if (this.options.stopTimeout && !this.stopTimeout) { 561 | this.stopTimeout = setTimeout(() => { 562 | this.stopTimeout = null; 563 | 564 | if (this._worker !== null) { 565 | this._worker.process.kill(); 566 | } 567 | }, this.options.stopTimeout); 568 | } 569 | } 570 | 571 | /** 572 | * Clears a forceful worker stop. 573 | * @private 574 | */ 575 | _cancelForceStop() { 576 | if (this.stopTimeout) { 577 | clearTimeout(this.stopTimeout); 578 | this.stopTimeout = null; 579 | } 580 | } 581 | 582 | /** 583 | * Adds this worker to master's restart queue 584 | * @public 585 | */ 586 | softRestart() { 587 | this._master.scheduleWorkerRestart(this); 588 | } 589 | } 590 | 591 | /** 592 | * Possible WorkerWrapper instance states. 593 | * @property {Object} STATES 594 | * @memberOf WorkerWrapper 595 | * @typedef WorkerWrapperState 596 | * @enum 597 | * @readonly 598 | * @public 599 | * @static 600 | */ 601 | Object.defineProperty(WorkerWrapper, 'STATES', { 602 | value: Object.freeze({ 603 | STOPPED: 'stopped', 604 | LAUNCHING: 'launching', 605 | RUNNING: 'running', 606 | STOPPING: 'stopping' 607 | }), 608 | enumerable: true 609 | }); 610 | 611 | /** 612 | * Events to repeat from WorkerWrapper#_worker on WorkerWrapper instance 613 | * @memberOf WorkerWrapper 614 | * @property {String[]} _PROXY_EVENTS 615 | * @static 616 | * @private 617 | */ 618 | Object.defineProperty(WorkerWrapper, '_PROXY_EVENTS', { 619 | value: Object.freeze([ 620 | 'message', 621 | 'online', 622 | 'listening', 623 | 'disconnect', 624 | 'exit' 625 | ]), 626 | enumerable: true 627 | }); 628 | 629 | /** 630 | * Events received from workers via IPC 631 | * @memberOf WorkerWrapper 632 | * @property {String[]} _RPC_EVENTS 633 | * @static 634 | * @private 635 | */ 636 | Object.defineProperty(WorkerWrapper, '_RPC_EVENTS', { 637 | value: Object.freeze([ 638 | 'configured', 639 | 'extension loaded', 640 | 'initialized', 641 | 'loaded', 642 | 'ready' 643 | ]), 644 | enumerable: true 645 | }); 646 | 647 | /** 648 | * All events which can be emitted by WorkerWrapper 649 | * @memberOf WorkerWrapper 650 | * @property {String[]} EVENTS 651 | * @static 652 | */ 653 | Object.defineProperty(WorkerWrapper, 'EVENTS', { 654 | value: Object.freeze( 655 | ['error', 'state', 'fork'] 656 | .concat(WorkerWrapper._PROXY_EVENTS) 657 | .concat(WorkerWrapper._RPC_EVENTS) 658 | ), 659 | enumerable: true 660 | }); 661 | 662 | module.exports = WorkerWrapper; 663 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "luster", 3 | "version": "2.6.0", 4 | "description": "Node.js cluster wrapper", 5 | "main": "./lib/luster.js", 6 | "bin": { 7 | "luster": "./bin/luster.js" 8 | }, 9 | "scripts": { 10 | "lint": "eslint ./lib ./test ./examples ./bin", 11 | "unit": "istanbul test _mocha -- test/unit/test", 12 | "func": "mocha test/func/test $@", 13 | "test": "npm run lint && npm run unit && npm run func -- $@" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git://github.com/nodules/luster.git" 18 | }, 19 | "keywords": [ 20 | "cluster", 21 | "luster", 22 | "ipc" 23 | ], 24 | "author": "Phillip Kovalev (https://github.com/kaero)", 25 | "maintainers": [ 26 | "Phillip Kovalev (https://github.com/kaero)" 27 | ], 28 | "contributors": [ 29 | "Vladimir Varankin (https://github.com/narqo)", 30 | "Alexey Rybakov (https://github.com/flackus)" 31 | ], 32 | "licenses": [ 33 | { 34 | "type": "MIT", 35 | "url": "http://github.com/nodules/luster/raw/master/LICENSE" 36 | } 37 | ], 38 | "dependencies": { 39 | "terror": "^1.0.0" 40 | }, 41 | "devDependencies": { 42 | "chai": "^3.5.0", 43 | "delay": "^4.3.0", 44 | "eslint": "^4.19.1", 45 | "eslint-config-nodules": "^0.4.0", 46 | "istanbul": "^0.4.1", 47 | "mocha": "^3.1.2", 48 | "p-event": "^4.1.0", 49 | "sinon": "^1.17.6", 50 | "sinon-chai": "^2.8.0" 51 | }, 52 | "engines": { 53 | "node": ">=8" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /test/func/fixtures/async_extension/master.js: -------------------------------------------------------------------------------- 1 | const proc = require('luster'); 2 | 3 | proc 4 | .configure({ 5 | app: 'worker.js', 6 | workers: 1, 7 | control: { 8 | exitThreshold: 100, 9 | }, 10 | extensions: { 11 | 'luster-async': { 12 | param1: 2, 13 | param2: 'Hello' 14 | }, 15 | } 16 | }, true, __dirname) 17 | .run(); 18 | 19 | if (proc.isMaster) { 20 | proc.once('running', () => process.send('ready')); 21 | 22 | proc.once('initialized', () => console.log('master is initialized')); 23 | } 24 | -------------------------------------------------------------------------------- /test/func/fixtures/async_extension/node_modules/luster: -------------------------------------------------------------------------------- 1 | ../../../../.. -------------------------------------------------------------------------------- /test/func/fixtures/async_extension/node_modules/luster-async/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | configure: (config, proc, done) => { 3 | if (proc.isMaster) { 4 | setTimeout(() => { 5 | console.log('luster-async extension configured on master process'); 6 | console.log('param1 = %s', config.get('param1')); 7 | console.log('param2 = %s', config.get('param2')); 8 | done(); 9 | }, 100); 10 | } else { 11 | setTimeout(() => { 12 | console.log('luster-async extension configured on worker process #%s', proc.wid); 13 | console.log('param1 = %s', config.get('param1')); 14 | console.log('param2 = %s', config.get('param2')); 15 | done(); 16 | }, 200); 17 | } 18 | } 19 | }; 20 | -------------------------------------------------------------------------------- /test/func/fixtures/async_extension/worker.js: -------------------------------------------------------------------------------- 1 | const worker = require('luster'); 2 | 3 | console.log('worker process #%s has started', worker.wid); 4 | -------------------------------------------------------------------------------- /test/func/fixtures/dead_workers/master.js: -------------------------------------------------------------------------------- 1 | const proc = require('luster'); 2 | 3 | proc 4 | .configure({ 5 | app: 'worker.js', 6 | workers: 1, 7 | control: { 8 | stopTimeout: 100, 9 | exitThreshold: 50, 10 | allowedSequentialDeaths: 0, 11 | } 12 | }, true, __dirname) 13 | .run(); 14 | 15 | if (proc.isMaster) { 16 | proc.once('running', () => { 17 | process.send('ready'); 18 | }); 19 | 20 | proc.on('worker exit', worker => { 21 | console.log(`Worker ${worker.wid} has exited, dead is ${worker.dead}`); 22 | }); 23 | 24 | process.on('message', message => { 25 | switch (message) { 26 | case 'worker quit': 27 | proc.emitToAll('quit'); 28 | break; 29 | case 'worker restart': 30 | proc.forEach(worker => worker.restart()); 31 | break; 32 | case 'worker stop': 33 | proc.forEach(worker => worker.stop()); 34 | break; 35 | default: 36 | throw new Error(`Got unknown command ${message}`); 37 | } 38 | }); 39 | } 40 | -------------------------------------------------------------------------------- /test/func/fixtures/dead_workers/node_modules/luster: -------------------------------------------------------------------------------- 1 | ../../../../.. -------------------------------------------------------------------------------- /test/func/fixtures/dead_workers/worker.js: -------------------------------------------------------------------------------- 1 | const worker = require('luster'); 2 | 3 | worker.on('master quit', () => { 4 | process.disconnect(); 5 | }); 6 | -------------------------------------------------------------------------------- /test/func/fixtures/emit_to_all/master.js: -------------------------------------------------------------------------------- 1 | const proc = require('luster'); 2 | 3 | proc 4 | .configure({ 5 | app: 'worker.js', 6 | workers: 2, 7 | control: { 8 | stopTimeout: 100 9 | } 10 | }, true, __dirname) 11 | .run(); 12 | 13 | if (proc.isMaster) { 14 | proc.once('running', () => { 15 | process.send('ready'); 16 | proc.emitToAll('log', 'test'); 17 | }); 18 | } 19 | -------------------------------------------------------------------------------- /test/func/fixtures/emit_to_all/node_modules/luster: -------------------------------------------------------------------------------- 1 | ../../../../.. -------------------------------------------------------------------------------- /test/func/fixtures/emit_to_all/worker.js: -------------------------------------------------------------------------------- 1 | const worker = require('luster'); 2 | 3 | worker.on('master log', data => console.log(data)); 4 | -------------------------------------------------------------------------------- /test/func/fixtures/force_kill/master.js: -------------------------------------------------------------------------------- 1 | const proc = require('luster'); 2 | 3 | proc 4 | .configure({ 5 | app: 'worker.js', 6 | workers: 1, 7 | control: { 8 | stopTimeout: 100 9 | } 10 | }, true, __dirname) 11 | .run(); 12 | 13 | if (proc.isMaster) { 14 | proc.once('running', () => process.send('ready')); 15 | process.on('message', message => { 16 | switch (message) { 17 | case 'hang': 18 | proc.remoteCallToAll('hang'); 19 | break; 20 | case 'disconnect and hang': 21 | proc.remoteCallToAll('disconnect and hang'); 22 | proc.once('worker disconnect', () => process.send('disconnected')); 23 | break; 24 | case 'wait worker': 25 | proc.once('worker ready', () => process.send('worker ready')); 26 | break; 27 | case 'request': 28 | proc.remoteCallToAllWithCallback({ 29 | command: 'request', 30 | callback: (worker, something, response) => process.send(response) 31 | }); 32 | break; 33 | case 'restart': 34 | proc.restart(); 35 | proc.once('restarted', () => process.send('restarted')); 36 | break; 37 | } 38 | }); 39 | } 40 | -------------------------------------------------------------------------------- /test/func/fixtures/force_kill/node_modules/luster: -------------------------------------------------------------------------------- 1 | ../../../../.. -------------------------------------------------------------------------------- /test/func/fixtures/force_kill/worker.js: -------------------------------------------------------------------------------- 1 | const worker = require('luster'); 2 | 3 | function hang() { 4 | while (true) {} // eslint-disable-line 5 | } 6 | 7 | worker.registerRemoteCommand('hang', hang); 8 | 9 | worker.registerRemoteCommand('disconnect and hang', () => { 10 | // Imitate situation when worker disconnects and cannot quit. 11 | // Master should kill such a worker after `stopTimeout`. 12 | process.removeAllListeners('disconnect'); 13 | process.once('disconnect', hang); 14 | process.disconnect(); 15 | }); 16 | 17 | worker.registerRemoteCommandWithCallback('request', callback => callback('response')); 18 | -------------------------------------------------------------------------------- /test/func/fixtures/manual_ready/master.js: -------------------------------------------------------------------------------- 1 | const proc = require('luster'); 2 | 3 | proc 4 | .configure({ 5 | app: 'worker.js', 6 | workers: 2, 7 | control: { 8 | stopTimeout: 100, 9 | triggerReadyStateManually: true, 10 | } 11 | }, true, __dirname) 12 | .run(); 13 | 14 | if (proc.isMaster) { 15 | proc.once('running', () => process.send('ready')); 16 | } 17 | -------------------------------------------------------------------------------- /test/func/fixtures/manual_ready/node_modules/luster: -------------------------------------------------------------------------------- 1 | ../../../../.. -------------------------------------------------------------------------------- /test/func/fixtures/manual_ready/worker.js: -------------------------------------------------------------------------------- 1 | const worker = require('luster'); 2 | 3 | setTimeout(() => worker.ready(), 500); 4 | -------------------------------------------------------------------------------- /test/func/fixtures/override_config/master.js: -------------------------------------------------------------------------------- 1 | const proc = require('luster'); 2 | 3 | proc 4 | .configure({ 5 | app: 'worker.js', 6 | workers: 1, 7 | test: 'bad', 8 | control: { 9 | stopTimeout: 100, 10 | } 11 | }, true, __dirname) 12 | .run(); 13 | 14 | if (proc.isMaster) { 15 | proc.once('running', () => { 16 | process.send('ready'); 17 | setTimeout(() => { 18 | process.send('master - ' + proc.config.get('test')); 19 | }, 100); 20 | setTimeout(() => { 21 | proc.remoteCallToAllWithCallback({ 22 | command: 'test', 23 | callback: (worker, error, text) => process.send('worker - ' + text), 24 | }); 25 | }, 200); 26 | }); 27 | } 28 | -------------------------------------------------------------------------------- /test/func/fixtures/override_config/node_modules/luster: -------------------------------------------------------------------------------- 1 | ../../../../.. -------------------------------------------------------------------------------- /test/func/fixtures/override_config/worker.js: -------------------------------------------------------------------------------- 1 | const worker = require('luster'); 2 | 3 | worker.registerRemoteCommandWithCallback('test', callback => callback(worker.config.get('test'))); 4 | -------------------------------------------------------------------------------- /test/func/fixtures/remote_call_on_master/master.js: -------------------------------------------------------------------------------- 1 | const proc = require('luster'); 2 | 3 | proc 4 | .configure({ 5 | app: 'worker.js', 6 | workers: 1, 7 | control: { 8 | stopTimeout: 100 9 | } 10 | }, true, __dirname) 11 | .run(); 12 | 13 | if (proc.isMaster) { 14 | proc.once('running', () => { 15 | process.send('ready'); 16 | const worker = proc.getWorkersArray()[0]; 17 | worker.remoteCallWithCallback({ 18 | command: 'test', 19 | callback: (worker, error, response) => { 20 | console.log(response); 21 | worker.remoteCall('test 2', '2'); 22 | }, 23 | data: '1', 24 | }); 25 | }); 26 | } 27 | -------------------------------------------------------------------------------- /test/func/fixtures/remote_call_on_master/node_modules/luster: -------------------------------------------------------------------------------- 1 | ../../../../.. -------------------------------------------------------------------------------- /test/func/fixtures/remote_call_on_master/worker.js: -------------------------------------------------------------------------------- 1 | const worker = require('luster'); 2 | 3 | worker.registerRemoteCommandWithCallback('test', (callback, data) => callback(data)); 4 | 5 | worker.registerRemoteCommand('test 2', (_worker, data) => console.log(data)); 6 | -------------------------------------------------------------------------------- /test/func/fixtures/remote_call_on_worker/master.js: -------------------------------------------------------------------------------- 1 | const proc = require('luster'); 2 | 3 | proc 4 | .configure({ 5 | app: 'worker.js', 6 | workers: 1, 7 | control: { 8 | stopTimeout: 100 9 | } 10 | }, true, __dirname) 11 | .run(); 12 | 13 | if (proc.isMaster) { 14 | proc.registerRemoteCommandWithCallback('test', (callback, data) => callback(data)); 15 | 16 | proc.registerRemoteCommand('test 2', (_worker, data) => console.log(data)); 17 | 18 | proc.once('running', () => process.send('ready')); 19 | } 20 | -------------------------------------------------------------------------------- /test/func/fixtures/remote_call_on_worker/node_modules/luster: -------------------------------------------------------------------------------- 1 | ../../../../.. -------------------------------------------------------------------------------- /test/func/fixtures/remote_call_on_worker/worker.js: -------------------------------------------------------------------------------- 1 | const worker = require('luster'); 2 | 3 | worker.once('ready', () => { 4 | worker.remoteCallWithCallback({ 5 | command: 'test', 6 | callback: (worker, error, response) => { 7 | console.log(response); 8 | worker.remoteCall('test 2', '4'); 9 | }, 10 | data: '3', 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /test/func/fixtures/restart_queue/master.js: -------------------------------------------------------------------------------- 1 | const proc = require('luster'), 2 | WorkerWrapper = require('luster/lib/worker_wrapper'); 3 | 4 | proc 5 | .configure({ 6 | app: 'worker.js', 7 | workers: 3, 8 | control: { 9 | exitThreshold: 10000, 10 | allowedSequentialDeaths: 0, 11 | triggerReadyStateManually: true, 12 | }, 13 | }, true, __dirname) 14 | .run(); 15 | 16 | function restart() { 17 | console.log('restarting'); 18 | proc.softRestart(); 19 | proc.once('restarted', () => process.send('restarted')); 20 | } 21 | 22 | function killFirstWorker() { 23 | const firstWorker = proc.getWorkersArray()[0]; 24 | firstWorker.on('state', state => { 25 | // force dead state 26 | if (state === WorkerWrapper.STATES.LAUNCHING) { 27 | firstWorker.process.kill(9); 28 | } 29 | 30 | if (state === WorkerWrapper.STATES.STOPPED && firstWorker.dead) { 31 | console.log('dead', firstWorker.wid); 32 | } 33 | }); 34 | } 35 | 36 | function killThirdWorker() { 37 | proc.getWorkersArray()[2].restart(); 38 | } 39 | 40 | if (proc.isMaster) { 41 | proc.once('running', () => process.send('ready')); 42 | 43 | proc.on('worker exit', worker => console.log('exit', worker.wid)); 44 | 45 | process.on('message', command => { 46 | switch (command) { 47 | case 'restart': 48 | restart(); 49 | break; 50 | case 'restartKillFirst': 51 | restart(); 52 | killFirstWorker(); 53 | break; 54 | case 'restartKillThird': 55 | restart(); 56 | killThirdWorker(); 57 | break; 58 | default: 59 | throw new Error('Unknown command ' + command); 60 | } 61 | }); 62 | } 63 | -------------------------------------------------------------------------------- /test/func/fixtures/restart_queue/node_modules/luster: -------------------------------------------------------------------------------- 1 | ../../../../.. -------------------------------------------------------------------------------- /test/func/fixtures/restart_queue/worker.js: -------------------------------------------------------------------------------- 1 | const worker = require('luster'); 2 | setTimeout(() => { 3 | console.log('run', worker.wid); 4 | worker.ready(); 5 | }, 10); 6 | 7 | // Do not let worker quit 8 | setTimeout(() => {}, 10000000000); 9 | -------------------------------------------------------------------------------- /test/func/fixtures/simple_extension/master.js: -------------------------------------------------------------------------------- 1 | const proc = require('luster'); 2 | 3 | proc 4 | .configure({ 5 | app: 'worker.js', 6 | workers: 1, 7 | control: { 8 | exitThreshold: 100, 9 | }, 10 | extensions: { 11 | 'luster-simple': { 12 | param1: 1, 13 | param2: 'World' 14 | }, 15 | } 16 | }, true, __dirname) 17 | .run(); 18 | 19 | if (proc.isMaster) { 20 | proc.once('running', () => process.send('ready')); 21 | } 22 | -------------------------------------------------------------------------------- /test/func/fixtures/simple_extension/node_modules/luster: -------------------------------------------------------------------------------- 1 | ../../../../.. -------------------------------------------------------------------------------- /test/func/fixtures/simple_extension/node_modules/luster-simple/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | configure(config, proc) { 3 | if (proc.isMaster) { 4 | console.log('luster-simple extension configured on master process'); 5 | console.log('param1 = %s', config.get('param1')); 6 | console.log('param2 = %s', config.get('param2')); 7 | } else { 8 | console.log('luster-simple extension configured on worker process #%s', proc.wid); 9 | console.log('param1 = %s', config.get('param1')); 10 | console.log('param2 = %s', config.get('param2')); 11 | } 12 | } 13 | }; 14 | -------------------------------------------------------------------------------- /test/func/fixtures/simple_extension/worker.js: -------------------------------------------------------------------------------- 1 | // This file is intentionally left empty. 2 | -------------------------------------------------------------------------------- /test/func/fixtures/suspend/master.js: -------------------------------------------------------------------------------- 1 | const proc = require('luster'); 2 | const pEvent = require('p-event'); 3 | 4 | proc 5 | .configure({ 6 | app: 'worker.js', 7 | workers: 1, 8 | control: { 9 | stopTimeout: 1000 10 | } 11 | }, true, __dirname) 12 | .run(); 13 | 14 | if (proc.isMaster) { 15 | proc.once('running', () => process.send('ready')); 16 | proc.on('shutdown', () => { 17 | if (process.connected) { 18 | process.disconnect(); 19 | } 20 | }); 21 | process.on('message', message => { 22 | switch (message) { 23 | case 'register suspend 100': 24 | proc.remoteCallToAll('register suspend', 100); 25 | break; 26 | case 'register suspend 200': 27 | proc.remoteCallToAll('register suspend', 200); 28 | break; 29 | case 'register suspend 3000': 30 | proc.remoteCallToAll('register suspend', 3000); 31 | break; 32 | case 'soft-restart': 33 | pEvent(proc, 'restarted').then(() => process.send('restarted')); 34 | proc.softRestart(); 35 | break; 36 | case 'shutdown': 37 | proc.shutdown(); 38 | console.log('Shutting down'); 39 | break; 40 | } 41 | }); 42 | } 43 | -------------------------------------------------------------------------------- /test/func/fixtures/suspend/node_modules/luster: -------------------------------------------------------------------------------- 1 | ../../../../.. -------------------------------------------------------------------------------- /test/func/fixtures/suspend/worker.js: -------------------------------------------------------------------------------- 1 | const worker = require('luster'); 2 | 3 | worker.registerRemoteCommand('register suspend', (_, timeout) => { 4 | worker.registerSuspendFunction(() => { 5 | console.log(`Waiting ${timeout}ms in suspend function`); 6 | return new Promise(resolve => { 7 | setTimeout(() => { 8 | console.log(`Finished waiting ${timeout}ms in suspend function`); 9 | resolve(); 10 | }, timeout); 11 | }); 12 | }); 13 | }); 14 | 15 | worker.on('disconnect', () => { 16 | console.log('Got disconnect'); 17 | }); 18 | 19 | worker.on('ready', () => { 20 | console.log('Got ready'); 21 | }); 22 | -------------------------------------------------------------------------------- /test/func/fixtures/twice_ready_throws/master.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const proc = require('luster'); 3 | 4 | proc 5 | .configure({ 6 | app: 'worker.js', 7 | workers: 1, 8 | control: { 9 | stopTimeout: 100, 10 | allowedSequentialDeaths: 0, 11 | exitThreshold: 10000, 12 | triggerReadyStateManually: true, 13 | } 14 | }, true, __dirname) 15 | .run(); 16 | 17 | if (proc.isMaster) { 18 | proc.registerRemoteCommand('already_ready', () => process.send('already_ready')); 19 | } 20 | -------------------------------------------------------------------------------- /test/func/fixtures/twice_ready_throws/node_modules/luster: -------------------------------------------------------------------------------- 1 | ../../../../.. -------------------------------------------------------------------------------- /test/func/fixtures/twice_ready_throws/worker.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const worker = require('luster'), 3 | LusterWorkerError = require('luster/lib/errors').LusterWorkerError; 4 | 5 | worker.once('ready', () => { 6 | try { 7 | worker.ready(); 8 | } catch(e) { 9 | if ((e instanceof LusterWorkerError) && e.code === LusterWorkerError.CODES.ALREADY_READY) { 10 | worker.remoteCall('already_ready'); 11 | } else { 12 | throw e; 13 | } 14 | } 15 | }); 16 | worker.ready(); 17 | -------------------------------------------------------------------------------- /test/func/fixtures/worker_logs/master.js: -------------------------------------------------------------------------------- 1 | const proc = require('luster'); 2 | 3 | proc 4 | .configure({ 5 | app: 'worker.js', 6 | workers: 1, 7 | control: { 8 | stopTimeout: 100 9 | } 10 | }, true, __dirname) 11 | .run(); 12 | 13 | if (proc.isMaster) { 14 | proc.once('running', () => { 15 | setTimeout(() => proc.restart()); 16 | }); 17 | proc.once('restarted', () => process.send('ready')); 18 | } 19 | -------------------------------------------------------------------------------- /test/func/fixtures/worker_logs/node_modules/luster: -------------------------------------------------------------------------------- 1 | ../../../../.. -------------------------------------------------------------------------------- /test/func/fixtures/worker_logs/worker.js: -------------------------------------------------------------------------------- 1 | // This file is intentionally left empty 2 | -------------------------------------------------------------------------------- /test/func/helpers/luster_instance.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @module test/func/runner 3 | * 4 | * A helper module to run test instances with luster. It runs master process which in turn should start luster with 5 | * needed configuration. Used in functional tests. 6 | * @example master process 7 | * var proc = require('luster'); 8 | * 9 | * proc 10 | * .configure({ 11 | * app: 'worker.js', 12 | * workers: 1, 13 | * control: { 14 | * stopTimeout: 500 15 | * }}, true, __dirname) 16 | * .run(); 17 | * 18 | * @example usage in test case 19 | * beforeEach(function() { 20 | * return LusterInstance 21 | * .run('../fixtures/force_kill/master.js') 22 | * .then(function (inst) { 23 | * instance = inst; 24 | * }); 25 | * }); 26 | * afterEach(function() { 27 | * instance.kill(); 28 | * instance = null; 29 | * }); 30 | * 31 | * Master should send 'ready' message once it has started: 32 | * @example 33 | * if (proc.isMaster) { 34 | * proc.once('running', function() { 35 | * process.send('ready'); 36 | * }); 37 | * } 38 | * 39 | * Master can listen to IPC messages and reply to them if necessary. This is completely defined by your test case. 40 | * `LusterInstance` has methods `sendWaitTimeout` and `sendWaitAnswer` sending messages to master process and waiting 41 | * for timeout or reply: 42 | * @example 43 | * if (proc.isMaster) { 44 | * process.on('message', function(message) { 45 | * switch (message) { 46 | * case 'hang': 47 | * // We do not reply to this message, so test will use `sendWaitTimeout` to call this 48 | * proc.remoteCallToAll('hang'); 49 | * break; 50 | * case 'request': 51 | * // We reply with some text, so test will use `sendWaitAnswer` to call this 52 | * proc.remoteCallToAllWithCallback({ 53 | * command: 'request', 54 | * callback: function(worker, something, response) { 55 | * process.send(response); 56 | * }}); 57 | * break; 58 | * } 59 | * }); 60 | * } 61 | */ 62 | 63 | const fork = require('child_process').fork; 64 | const path = require('path'); 65 | const pEvent = require('p-event'); 66 | 67 | /** 68 | * A wrapper for `ChildProcess` 69 | * @class LusterInstance 70 | * @param {ChildProcess} child 71 | * @param {boolean} [pipeStderr] - whether instance's stderr should be piped to current process stderr 72 | * @constructor 73 | */ 74 | class LusterInstance { 75 | constructor(child, pipeStderr) { 76 | if (pipeStderr === undefined) { 77 | pipeStderr = true; 78 | } 79 | 80 | this._process = child; 81 | this._output = ''; 82 | this._process.stdout.setEncoding('utf8'); 83 | this._process.stdout.on('data', chunk => { 84 | this._output += chunk; 85 | }); 86 | if (pipeStderr) { 87 | this._process.stderr.pipe(process.stderr, {end: false}); 88 | } 89 | } 90 | 91 | /** 92 | * Creates new LusterInstance with master at `name` and waits for master 'ready' message. 93 | * @param {String} name - absolute path or path relative to `luster_instance` module. 94 | * @param {Object} [env] - environment key-value pairs 95 | * @param {boolean} [pipeStderr] 96 | * @returns {Promise} 97 | */ 98 | static async run(name, env, pipeStderr) { 99 | if (typeof(env) === 'boolean') { 100 | pipeStderr = env; 101 | } 102 | const instance = fork(path.resolve(__dirname, name), {env, silent: true}); 103 | const res = new LusterInstance(instance, pipeStderr); 104 | 105 | // Promise is resolved when master process replies to ping 106 | // Promise is rejected if master was unable to reply to ping within 1 second 107 | const message = await pEvent(instance, 'message'); 108 | if (message !== 'ready') { 109 | throw new Error(`First message from master should be "ready", got "${message}" instead`); 110 | } 111 | return res; 112 | } 113 | 114 | async send(message) { 115 | this._process.send(message); 116 | } 117 | 118 | /** 119 | * Sends message to master instance, resolves after timeout 120 | * @param {String} message 121 | * @param {Number} timeout 122 | * @returns {Promise} 123 | */ 124 | sendWaitTimeout(message, timeout) { 125 | return new Promise(resolve => { 126 | this._process.send(message); 127 | setTimeout(resolve, timeout); 128 | }); 129 | } 130 | 131 | /** 132 | * Sends message to master instance, waits for first message from master instance. 133 | * Resolves if received message is expected answer and rejects otherwise. 134 | * @param {String} message 135 | * @param {String} expectedAnswer 136 | * @returns {Promise} 137 | */ 138 | async sendWaitAnswer(message, expectedAnswer) { 139 | const reply = this.waitAnswer(expectedAnswer); 140 | this._process.send(message); 141 | await reply; 142 | } 143 | 144 | /** 145 | * Sends message to master instance, waits for first message from master instance. 146 | * Resolves if received message is expected answer and rejects otherwise. 147 | * @param {String} message 148 | * @returns {Promise} 149 | */ 150 | async sendWaitExit(message) { 151 | const exit = pEvent(this._process, 'exit'); 152 | this._process.send(message); 153 | return await exit; 154 | } 155 | 156 | /** 157 | * Waits for message from master instance. 158 | * Resolves if received message is expected answer and rejects otherwise. 159 | * @param {String} expectedAnswer 160 | * @returns {Promise} 161 | */ 162 | async waitAnswer(expectedAnswer) { 163 | const answer = await pEvent(this._process, 'message'); 164 | if (answer !== expectedAnswer) { 165 | throw new Error(`Expected master to send "${expectedAnswer}", got "${answer}" instead`); 166 | } 167 | } 168 | 169 | /** 170 | * Returns all of the spawned processes output (stdout only) from their start 171 | * @returns {String} 172 | */ 173 | output() { 174 | return this._output; 175 | } 176 | 177 | /** 178 | * Kills underlying master process 179 | */ 180 | kill() { 181 | this._process.kill(); 182 | } 183 | } 184 | 185 | module.exports = LusterInstance; 186 | -------------------------------------------------------------------------------- /test/func/test/async_extension.js: -------------------------------------------------------------------------------- 1 | /* globals describe,it,before,after,assert */ 2 | 'use strict'; 3 | 4 | const LusterInstance = require('../helpers/luster_instance'); 5 | 6 | describe('async extension', () => { 7 | let instance; 8 | 9 | beforeEach(async () => { 10 | instance = await LusterInstance 11 | .run('../fixtures/async_extension/master.js'); 12 | }); 13 | 14 | it('should have access to configuration and delay initialized event', done => { 15 | const expected = [ 16 | 'luster-async extension configured on master process', 17 | 'param1 = 2', 18 | 'param2 = Hello', 19 | 'master is initialized', 20 | 'luster-async extension configured on worker process #1', 21 | 'param1 = 2', 22 | 'param2 = Hello', 23 | 'worker process #1 has started\n' 24 | ].join('\n'); 25 | setTimeout(() => { 26 | assert.equal(instance.output(), expected); 27 | done(); 28 | }, 100); 29 | }); 30 | 31 | afterEach(() => { 32 | if (instance) { 33 | instance.kill(); 34 | instance = null; 35 | } 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /test/func/test/dead_workers.js: -------------------------------------------------------------------------------- 1 | /* globals describe,it,before,after,assert */ 2 | 'use strict'; 3 | 4 | const LusterInstance = require('../helpers/luster_instance'); 5 | const delay = require('delay'); 6 | 7 | describe('dead workers', () => { 8 | let instance; 9 | 10 | beforeEach(async () => { 11 | instance = await LusterInstance 12 | .run('../fixtures/dead_workers/master.js'); 13 | }); 14 | 15 | it('worker quitting before exitThreshold should be marked as dead', async () => { 16 | await instance.sendWaitTimeout('worker quit', 50); 17 | 18 | const expectedEvents = 'Worker 1 has exited, dead is true\n'; 19 | assert.equal(instance.output(), expectedEvents); 20 | }); 21 | 22 | it('worker quitting after exitThreshold should not be marked as dead', async () => { 23 | await delay(50); 24 | await instance.sendWaitTimeout('worker quit', 50); 25 | 26 | const expectedEvents = 'Worker 1 has exited, dead is false\n'; 27 | assert.equal(instance.output(), expectedEvents); 28 | }); 29 | 30 | it('worker restarted manually should not be marked as dead', async () => { 31 | await instance.sendWaitTimeout('worker restart', 50); 32 | 33 | const expectedEvents = 'Worker 1 has exited, dead is false\n'; 34 | assert.equal(instance.output(), expectedEvents); 35 | }); 36 | 37 | it('worker stopped manually should not be marked as dead', async () => { 38 | await instance.sendWaitTimeout('worker stop', 50); 39 | 40 | const expectedEvents = 'Worker 1 has exited, dead is false\n'; 41 | assert.equal(instance.output(), expectedEvents); 42 | }); 43 | 44 | afterEach(() => { 45 | if (instance) { 46 | instance.kill(); 47 | instance = null; 48 | } 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /test/func/test/emit_to_all.js: -------------------------------------------------------------------------------- 1 | /* globals describe,it,before,after,assert */ 2 | 'use strict'; 3 | 4 | const LusterInstance = require('../helpers/luster_instance'); 5 | 6 | describe('emitToAll', () => { 7 | let instance; 8 | 9 | beforeEach(async () => { 10 | instance = await LusterInstance 11 | .run('../fixtures/emit_to_all/master.js'); 12 | }); 13 | 14 | it('should deliver message data to all workers', done => { 15 | setTimeout(() => { 16 | assert.equal(instance.output(), 'test\ntest\n'); 17 | done(); 18 | }, 100); 19 | }); 20 | 21 | afterEach(() => { 22 | if (instance) { 23 | instance.kill(); 24 | instance = null; 25 | } 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /test/func/test/force_kill.js: -------------------------------------------------------------------------------- 1 | /* globals describe,it,before,after */ 2 | 'use strict'; 3 | 4 | const LusterInstance = require('../helpers/luster_instance'); 5 | 6 | describe('stopTimeout', () => { 7 | let instance; 8 | 9 | beforeEach(async () => { 10 | instance = await LusterInstance 11 | .run('../fixtures/force_kill/master.js'); 12 | }); 13 | 14 | it('should kill infinite worker', async () => { 15 | await instance.sendWaitTimeout('hang', 10); 16 | await instance.sendWaitAnswer('restart', 'restarted'); 17 | await instance.sendWaitAnswer('request', 'response'); 18 | }); 19 | 20 | it('should kill infinite worker that disconnected itself', async () => { 21 | await instance.sendWaitAnswer('disconnect and hang', 'disconnected'); 22 | await instance.sendWaitAnswer('wait worker', 'worker ready'); 23 | await instance.sendWaitAnswer('request', 'response'); 24 | }); 25 | 26 | afterEach(() => { 27 | if (instance) { 28 | instance.kill(); 29 | instance = null; 30 | } 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /test/func/test/manual_ready.js: -------------------------------------------------------------------------------- 1 | /* globals describe,it,before,after,assert */ 2 | 'use strict'; 3 | 4 | const fork = require('child_process').fork, 5 | path = require('path'); 6 | 7 | describe('manualReady option', () => { 8 | let instance; 9 | 10 | beforeEach(() => { 11 | instance = fork(path.resolve(__dirname, '../fixtures/manual_ready/master.js')); 12 | }); 13 | 14 | it('should fire running when workers are ready', () => { 15 | return new Promise(resolve => { 16 | const start = process.hrtime(); 17 | instance.once('message', message => { 18 | assert.equal(message, 'ready', 'Got unexpected response from server'); 19 | const hrTimeDiff = process.hrtime(start); 20 | const diff = hrTimeDiff[0] + hrTimeDiff[1] / 1e9; 21 | assert.isAtLeast(diff, 0.5, 'Running event is fired too early'); 22 | resolve(); 23 | }); 24 | }); 25 | }); 26 | 27 | afterEach(() => instance.kill()); 28 | }); 29 | -------------------------------------------------------------------------------- /test/func/test/override_config.js: -------------------------------------------------------------------------------- 1 | /* globals describe,it,before,after */ 2 | 'use strict'; 3 | 4 | const LusterInstance = require('../helpers/luster_instance'); 5 | 6 | describe('LUSTER_CONF env variable', () => { 7 | let instance; 8 | 9 | beforeEach(async () => { 10 | instance = await LusterInstance 11 | .run('../fixtures/override_config/master.js', {LUSTER_CONF: 'test=good'}); 12 | }); 13 | 14 | it('should override config', async () => { 15 | await instance.waitAnswer('master - good'); 16 | await instance.waitAnswer('worker - good'); 17 | }); 18 | 19 | afterEach(() => { 20 | if (instance) { 21 | instance.kill(); 22 | instance = null; 23 | } 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /test/func/test/remote_call_on_master.js: -------------------------------------------------------------------------------- 1 | /* globals describe,it,before,after,assert */ 2 | 'use strict'; 3 | 4 | const LusterInstance = require('../helpers/luster_instance'); 5 | 6 | describe('remote calls on master', () => { 7 | let instance; 8 | 9 | beforeEach(async () => { 10 | instance = await LusterInstance 11 | .run('../fixtures/remote_call_on_master/master.js'); 12 | }); 13 | 14 | it('should allow master to call worker', done => { 15 | setTimeout(() => { 16 | assert.equal(instance.output(), '1\n2\n'); 17 | done(); 18 | }, 100); 19 | }); 20 | 21 | afterEach(() => { 22 | if (instance) { 23 | instance.kill(); 24 | instance = null; 25 | } 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /test/func/test/remote_call_on_worker.js: -------------------------------------------------------------------------------- 1 | /* globals describe,it,before,after,assert */ 2 | 'use strict'; 3 | 4 | const LusterInstance = require('../helpers/luster_instance'); 5 | 6 | describe('remote calls on worker', () => { 7 | let instance; 8 | 9 | beforeEach(async () => { 10 | instance = await LusterInstance 11 | .run('../fixtures/remote_call_on_worker/master.js'); 12 | }); 13 | 14 | it('should allow worker to call master', done => { 15 | setTimeout(() => { 16 | assert.equal(instance.output(), '3\n4\n'); 17 | done(); 18 | }, 100); 19 | }); 20 | 21 | afterEach(() => { 22 | if (instance) { 23 | instance.kill(); 24 | instance = null; 25 | } 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /test/func/test/restart_queue.js: -------------------------------------------------------------------------------- 1 | /* globals describe,it,before,after,assert */ 2 | 'use strict'; 3 | 4 | const LusterInstance = require('../helpers/luster_instance'); 5 | 6 | function containInOrder(patterns, test) { 7 | // . in regexp does not match \n, using [^] 8 | const re = new RegExp(patterns.join('[^]*')); 9 | return test.match(re) !== null; 10 | } 11 | 12 | describe('restart queue', () => { 13 | let instance; 14 | 15 | beforeEach(async () => { 16 | instance = await LusterInstance 17 | .run('../fixtures/restart_queue/master.js', false); 18 | }); 19 | 20 | it('should restart workers one by one', async () => { 21 | const expected = [ 22 | 'restarting', 23 | 'exit 1', 24 | 'run 1', 25 | 'exit 2', 26 | 'run 2', 27 | 'exit 3', 28 | 'run 3\n' 29 | ].join('\n'); 30 | 31 | await instance.sendWaitAnswer('restart', 'restarted'); 32 | 33 | assert(instance.output().endsWith(expected), 'Output should end with ' + expected); 34 | }); 35 | 36 | it('should continue if restarted worker became dead', async () => { 37 | const expected = [ 38 | 'restarting', 39 | 'exit 1', 40 | 'dead 1', 41 | 'exit 1', 42 | 'exit 2', 43 | 'run 2', 44 | 'exit 3', 45 | 'run 3' 46 | ]; 47 | 48 | await instance.sendWaitAnswer('restartKillFirst', 'restarted'); 49 | 50 | assert(containInOrder(expected, instance.output()), `Output should contain ${expected} in this order`); 51 | }); 52 | 53 | it('should remove self-restarted worker from queue', async () => { 54 | // Exit/run order of workers is not well-defined, so the only way is to compare sorted log lines 55 | const expected = [ 56 | 'restarting', 57 | 'exit 1', 58 | 'run 1', 59 | 'exit 3', 60 | 'run 3', 61 | 'exit 2', 62 | 'run 2', 63 | '' 64 | ].sort(); 65 | 66 | await instance.sendWaitAnswer('restartKillThird', 'restarted'); 67 | 68 | const output = instance.output().split('\n').slice(-expected.length).sort().join('\n'); 69 | assert.equal(output, expected.join('\n')); 70 | }); 71 | 72 | afterEach(() => { 73 | if (instance) { 74 | instance.kill(); 75 | instance = null; 76 | } 77 | }); 78 | }); 79 | -------------------------------------------------------------------------------- /test/func/test/simple_extension.js: -------------------------------------------------------------------------------- 1 | /* globals describe,it,before,after,assert */ 2 | 'use strict'; 3 | 4 | const LusterInstance = require('../helpers/luster_instance'); 5 | 6 | describe('simple extension', () => { 7 | let instance; 8 | 9 | beforeEach(async () => { 10 | instance = await LusterInstance 11 | .run('../fixtures/simple_extension/master.js'); 12 | }); 13 | 14 | it('should have access to configuration', done => { 15 | const expected = [ 16 | 'luster-simple extension configured on master process', 17 | 'param1 = 1', 18 | 'param2 = World', 19 | 'luster-simple extension configured on worker process #1', 20 | 'param1 = 1', 21 | 'param2 = World\n' 22 | ].join('\n'); 23 | setTimeout(() => { 24 | assert.equal(instance.output(), expected); 25 | done(); 26 | }, 100); 27 | }); 28 | 29 | afterEach(() => { 30 | if (instance) { 31 | instance.kill(); 32 | instance = null; 33 | } 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /test/func/test/suspend.js: -------------------------------------------------------------------------------- 1 | /* globals describe,it,before,after,assert */ 2 | 'use strict'; 3 | 4 | const LusterInstance = require('../helpers/luster_instance'); 5 | 6 | describe('suspend before stop', () => { 7 | let instance; 8 | 9 | beforeEach(async () => { 10 | instance = await LusterInstance 11 | .run('../fixtures/suspend/master.js'); 12 | }); 13 | 14 | it('master calls suspend and waits for it to finish before stop', async () => { 15 | await instance.sendWaitTimeout('register suspend 100', 10); 16 | await instance.sendWaitAnswer('soft-restart', 'restarted'); 17 | const expected = `Got ready 18 | Waiting 100ms in suspend function 19 | Finished waiting 100ms in suspend function 20 | Got disconnect 21 | `; 22 | assert.equal(instance.output(), expected); 23 | }); 24 | 25 | it('master disconnected worker if no suspend function was registered', async () => { 26 | await instance.sendWaitAnswer('soft-restart', 'restarted'); 27 | const expected = 'Got ready\nGot disconnect\n'; 28 | assert.equal(instance.output(), expected); 29 | }); 30 | 31 | it('worker waits for all registered suspend functions', async () => { 32 | await instance.sendWaitTimeout('register suspend 100', 10); 33 | await instance.sendWaitTimeout('register suspend 200', 10); 34 | await instance.sendWaitAnswer('soft-restart', 'restarted'); 35 | const expected = `Got ready 36 | Waiting 100ms in suspend function 37 | Waiting 200ms in suspend function 38 | Finished waiting 100ms in suspend function 39 | Finished waiting 200ms in suspend function 40 | Got disconnect 41 | `; 42 | assert.equal(instance.output(), expected); 43 | }); 44 | 45 | it('master kills worker if suspend did not finish in stopTimeout', async () => { 46 | await instance.sendWaitTimeout('register suspend 3000', 10); 47 | await instance.sendWaitAnswer('soft-restart', 'restarted'); 48 | const expected = 'Got ready\nWaiting 3000ms in suspend function\n'; 49 | assert.equal(instance.output(), expected); 50 | }); 51 | 52 | it('master does not disconnect already killed worker', async function () { 53 | // eslint-disable-next-line no-invalid-this 54 | this.timeout(15000); 55 | 56 | await instance.sendWaitTimeout('register suspend 3000', 10); 57 | await instance.sendWaitAnswer('soft-restart', 'restarted'); 58 | // No "Got disconnect" is expected 59 | const expected = `Got ready 60 | Waiting 3000ms in suspend function 61 | Got ready 62 | `; 63 | 64 | await new Promise(resolve => setTimeout(resolve, 10000)); 65 | 66 | assert.equal(instance.output(), expected); 67 | }); 68 | 69 | it('worker does not call suspend functions more than once', async () => { 70 | await instance.sendWaitTimeout('register suspend 100', 10); 71 | await instance.send('shutdown'); 72 | const exitCode = await instance.sendWaitExit('shutdown'); 73 | 74 | // Keep those two 'shutting down' to make sure master process got our message and called 'shutdown' twice 75 | const expected = `Got ready 76 | Shutting down 77 | Shutting down 78 | Waiting 100ms in suspend function 79 | Finished waiting 100ms in suspend function 80 | Got disconnect 81 | `; 82 | assert.equal(exitCode, 0); 83 | assert.equal(instance.output(), expected); 84 | }); 85 | 86 | afterEach(() => { 87 | if (instance) { 88 | instance.kill(); 89 | instance = null; 90 | } 91 | }); 92 | }); 93 | -------------------------------------------------------------------------------- /test/func/test/twice_ready_throws.js: -------------------------------------------------------------------------------- 1 | /* globals describe,it,before,after,Promise,assert */ 2 | 'use strict'; 3 | 4 | const fork = require('child_process').fork, 5 | path = require('path'); 6 | 7 | describe('Worker#ready()', () => { 8 | let instance; 9 | 10 | beforeEach(() => { 11 | instance = fork(path.resolve(__dirname, '../fixtures/twice_ready_throws/master.js')); 12 | }); 13 | 14 | it('should throw if worker is already in the ready state', () => { 15 | return new Promise((resolve, reject) => { 16 | let done = false; 17 | instance 18 | .once('message', message => { 19 | assert.equal(message, 'already_ready', 'Expected only an "already_ready" message'); 20 | done = true; 21 | resolve(); 22 | }) 23 | .once('exit', () => { 24 | assert(done, 'Second Worker#ready() does not throw ALREADY_READY error'); 25 | reject(); 26 | }); 27 | }); 28 | }); 29 | 30 | afterEach(() => { 31 | instance.kill(); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /test/func/test/worker_logs.js: -------------------------------------------------------------------------------- 1 | /* globals describe,it,before,after,assert */ 2 | 'use strict'; 3 | 4 | const LusterInstance = require('../helpers/luster_instance'); 5 | 6 | describe('worker logs', () => { 7 | let instance; 8 | 9 | beforeEach(async () => { 10 | instance = await LusterInstance 11 | .run('../fixtures/worker_logs/master.js', {NODE_DEBUG: 'luster:eex'}); 12 | }); 13 | 14 | it('should use constant id even after restart', done => { 15 | setTimeout(() => { 16 | const lines = instance.output().split('\n'); 17 | lines.forEach(line => { 18 | const match = /^Worker\((\d+)\)/.exec(line); 19 | if (match) { 20 | const id = parseInt(match[1], 10); 21 | assert.strictEqual(id, 1); 22 | } 23 | }); 24 | done(); 25 | }, 100); 26 | }); 27 | 28 | afterEach(() => { 29 | if (instance) { 30 | instance.kill(); 31 | instance = null; 32 | } 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /test/mocha.opts: -------------------------------------------------------------------------------- 1 | --require ./test/setup 2 | --recursive 3 | -------------------------------------------------------------------------------- /test/setup.js: -------------------------------------------------------------------------------- 1 | /* globals sinon */ 2 | 'use strict'; 3 | const chai = require('chai'); 4 | 5 | global.sinon = require('sinon'); 6 | global.assert = chai.assert; 7 | 8 | chai.use(require('sinon-chai')); 9 | 10 | sinon.assert.expose(chai.assert, { prefix: '' }); 11 | -------------------------------------------------------------------------------- /test/unit/fixtures/luster.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "app": "worker.js", 3 | "workers": 10, 4 | "server": { 5 | "port": 10080 6 | }, 7 | "foo": true, 8 | "baz": { 9 | "foo": "bar" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /test/unit/test/cluster_process.js: -------------------------------------------------------------------------------- 1 | /* globals sinon,assert,describe,it,beforeEach,afterEach */ 2 | 'use strict'; 3 | const ClusterProcess = require('../../../lib/cluster_process'), 4 | Configuration = require('../../../lib/configuration'), 5 | fixturesConf = require('../fixtures/luster.conf'); 6 | 7 | /** 8 | * ClusterProcess is an abstract class, it cannot be instantiated for tests; 9 | * TestClusterProcess is a smalles possible descendant of ClusterProcess; 10 | */ 11 | class TestClusterProcess extends ClusterProcess { 12 | _setupIPCMessagesHandler() {} 13 | } 14 | 15 | describe('ClusterProcess', () => { 16 | let clusterProcess; 17 | const sandbox = sinon.sandbox.create(); 18 | 19 | afterEach(() => sandbox.restore()); 20 | 21 | describe('configure', () => { 22 | let config; 23 | 24 | beforeEach(() => { 25 | clusterProcess = new TestClusterProcess(); 26 | config = Object.assign({}, fixturesConf, true); 27 | clusterProcess.addListener('error', () => {}); 28 | }); 29 | 30 | afterEach(() => clusterProcess.removeAllListeners('error')); 31 | 32 | it('should emit "configured" event on configuration success', () => { 33 | const spy = sandbox.spy(); 34 | 35 | clusterProcess.on('configured', spy); 36 | clusterProcess.configure(config); 37 | 38 | assert.calledOnce(spy); 39 | }); 40 | 41 | it('should emit "error" event for malformed config', () => { 42 | const spy = sandbox.spy(); 43 | 44 | clusterProcess.on('error', spy); 45 | clusterProcess.configure({}); 46 | 47 | assert.calledOnce(spy); 48 | }); 49 | 50 | it('should not apply env config if overriding is explicitly turned off', () => { 51 | process.env.LUSTER_CONF = 'workers=1'; 52 | 53 | clusterProcess.configure(config, false); 54 | 55 | assert.strictEqual(clusterProcess.config.get('workers'), 10); 56 | }); 57 | 58 | it('should run checkConfiguration after applyEnv', () => { 59 | const applyEnv = sandbox.spy(Configuration, 'applyEnvironment'), 60 | check = sandbox.spy(Configuration, 'check'); 61 | clusterProcess.configure(config); 62 | assert(check.calledAfter(applyEnv)); 63 | }); 64 | }); 65 | }); 66 | -------------------------------------------------------------------------------- /test/unit/test/configuration.js: -------------------------------------------------------------------------------- 1 | /* globals sinon,assert,describe,it,beforeEach,afterEach */ 2 | 'use strict'; 3 | const Configuration = require('../../../lib/configuration'), 4 | LusterConfigurationError = require('../../../lib/errors').LusterConfigurationError, 5 | fixturesConf = require('../fixtures/luster.conf'), 6 | helpers = require('../../../lib/configuration/helpers'), 7 | set = helpers.set, 8 | get = helpers.get, 9 | has = helpers.has; 10 | 11 | // suppress stderr from terror 12 | LusterConfigurationError.setLogger(() => {}); 13 | 14 | describe('Configuration', () => { 15 | let configuration; 16 | const sandbox = sinon.sandbox.create(); 17 | 18 | beforeEach(() => configuration = Object.assign({}, fixturesConf, true)); 19 | 20 | afterEach(() => { 21 | sandbox.restore(); 22 | }); 23 | 24 | describe('constructor', () => { 25 | it('should create instance from object', () => { 26 | const instance = new Configuration(configuration); 27 | 28 | assert.strictEqual(instance.get('app'), 'worker.js'); 29 | assert.strictEqual(instance.get('workers'), 10); 30 | assert.strictEqual(instance.get('server.port'), 10080); 31 | assert.strictEqual(instance.get('foo'), true); 32 | assert.strictEqual(instance.get('baz.foo'), 'bar'); 33 | }); 34 | 35 | it('should create instance from Configuration instance', () => { 36 | const instance = new Configuration(new Configuration(configuration)); 37 | 38 | assert.strictEqual(instance.get('app'), 'worker.js'); 39 | assert.strictEqual(instance.get('workers'), 10); 40 | assert.strictEqual(instance.get('server.port'), 10080); 41 | assert.strictEqual(instance.get('foo'), true); 42 | assert.strictEqual(instance.get('baz.foo'), 'bar'); 43 | }); 44 | }); 45 | 46 | describe('applyEnvironment', () => { 47 | afterEach(() => delete process.env.LUSTER_CONF); 48 | 49 | it('should do simple one-level override', () => { 50 | process.env.LUSTER_CONF = 'workers=1'; 51 | 52 | Configuration.applyEnvironment(configuration); 53 | 54 | assert.strictEqual(configuration.workers, 1); 55 | }); 56 | 57 | it('should do simple one-level override with Configuration instance', () => { 58 | process.env.LUSTER_CONF = 'workers=1'; 59 | 60 | const instance = new Configuration(configuration); 61 | Configuration.applyEnvironment(instance); 62 | 63 | assert.strictEqual(instance.get('workers'), 1); 64 | }); 65 | 66 | it('should override to undefined value via empty string', () => { 67 | process.env.LUSTER_CONF = 'foo='; 68 | 69 | Configuration.applyEnvironment(configuration); 70 | 71 | assert.isUndefined(configuration.foo); 72 | }); 73 | 74 | it('should do nothing when only propname is provided', () => { 75 | process.env.LUSTER_CONF = 'foo'; 76 | 77 | Configuration.applyEnvironment(configuration); 78 | 79 | assert.strictEqual(configuration.foo, true); 80 | }); 81 | 82 | it('should respect semicolon in quoted property value', () => { 83 | process.env.LUSTER_CONF = 'foo="baz;"'; 84 | 85 | Configuration.applyEnvironment(configuration); 86 | 87 | assert.strictEqual(configuration.foo, 'baz;'); 88 | }); 89 | 90 | it('should respect equality sign in quoted property value', () => { 91 | process.env.LUSTER_CONF = 'foo="baz=bar"'; 92 | 93 | Configuration.applyEnvironment(configuration); 94 | 95 | assert.strictEqual(configuration.foo, 'baz=bar'); 96 | }); 97 | 98 | it('should parse json from propval', () => { 99 | process.env.LUSTER_CONF = 'properties={"foo":true,"baz":"bar"}'; 100 | 101 | Configuration.applyEnvironment(configuration); 102 | 103 | assert.strictEqual(configuration.properties.foo, true); 104 | assert.strictEqual(configuration.properties.baz, 'bar'); 105 | }); 106 | 107 | it('should throw when trying to set inner property to a scalar property', () => { 108 | process.env.LUSTER_CONF = 'baz.foo.bar=true'; 109 | 110 | assert.throws(() => Configuration.applyEnvironment(configuration), 111 | 'LusterConfigurationError: Property "baz.foo" already exists and is not an object'); 112 | }); 113 | 114 | it('should do second-level nested property override', () => { 115 | process.env.LUSTER_CONF = 'server.port=8080'; 116 | 117 | Configuration.applyEnvironment(configuration); 118 | 119 | assert.strictEqual(configuration.server.port, 8080); 120 | }); 121 | 122 | it('should do deep nested property override', () => { 123 | process.env.LUSTER_CONF = 'properties.foo.bar.baz=true'; 124 | 125 | Configuration.applyEnvironment(configuration); 126 | 127 | assert.strictEqual(configuration.properties.foo.bar.baz, true); 128 | }); 129 | 130 | describe('should override multiple properties at once', () => { 131 | it('should respect semicolon separated values', () => { 132 | process.env.LUSTER_CONF = 'workers=1;foo=false'; 133 | 134 | Configuration.applyEnvironment(configuration); 135 | 136 | assert.strictEqual(configuration.workers, 1); 137 | assert.strictEqual(configuration.foo, false); 138 | }); 139 | 140 | it('whitespaces should not matter', () => { 141 | process.env.LUSTER_CONF = 'workers = 1; foo =false'; 142 | 143 | Configuration.applyEnvironment(configuration); 144 | 145 | assert.strictEqual(configuration.workers, 1); 146 | assert.strictEqual(configuration.foo, false); 147 | }); 148 | }); 149 | }); 150 | 151 | describe('check', () => { 152 | it('should emit error when trying to override non-string property with a string', () => { 153 | configuration.workers = 'some'; 154 | 155 | assert.strictEqual(Configuration.check(configuration), 1); 156 | }); 157 | }); 158 | 159 | describe('set helper', () => { 160 | it('should set first level property', () => { 161 | const ctx = {}; 162 | 163 | set(ctx, 'prop', 123); 164 | 165 | assert.strictEqual(ctx.prop, 123); 166 | }); 167 | 168 | it('should set deeply nested property', () => { 169 | const ctx = { a: { b: { c: 1 } } }; 170 | 171 | set(ctx, 'a.b.c', 2); 172 | 173 | assert.strictEqual(ctx.a.b.c, 2); 174 | }); 175 | 176 | it('should set deeply nested undefined property', () => { 177 | const ctx = {}; 178 | 179 | set(ctx, 'a.b.c', 2); 180 | 181 | assert.strictEqual(ctx.a.b.c, 2); 182 | }); 183 | 184 | it('should fail to set nested property of scalar', () => { 185 | const ctx = {a: 'hello'}; 186 | 187 | assert.throws(() => set(ctx, 'a.b', 2), 188 | 'LusterConfigurationError: Property "a" already exists and is not an object'); 189 | }); 190 | 191 | it('should override complex property with a scalar value', () => { 192 | const ctx = { server: { a: 'b' } }; 193 | 194 | set(ctx, 'server', true); 195 | 196 | assert.strictEqual(ctx.server, true); 197 | }); 198 | 199 | it('should fail to set element of array', () => { 200 | const ctx = {a: [1, 2, 3]}; 201 | 202 | assert.throws(() => set(ctx, 'a.1', 5), 203 | 'LusterConfigurationError: Property "a" already exists and is not an object'); 204 | }); 205 | 206 | it('should override getters', () => { 207 | const ctx = { 208 | get stderr() { 209 | return './error.log'; 210 | }, 211 | }; 212 | 213 | set(ctx, 'stderr', '/dev/null'); 214 | 215 | assert.strictEqual(ctx.stderr, '/dev/null'); 216 | }); 217 | }); 218 | 219 | describe('get helper', () => { 220 | it('should get first level property', () => { 221 | const ctx = { prop: 123 }; 222 | 223 | assert.strictEqual(get(ctx, 'prop'), 123); 224 | }); 225 | 226 | it('should return default for missing property', () => { 227 | assert.strictEqual(get({}, 'prop', 123), 123); 228 | }); 229 | 230 | it('should get deeply nested property', () => { 231 | const ctx = { a: { b: { c: 1 } } }; 232 | 233 | assert.strictEqual(get(ctx, 'a.b.c'), 1); 234 | }); 235 | 236 | it('should return default for missing nested property', () => { 237 | assert.strictEqual(get({}, 'a.b.c', 2), 2); 238 | }); 239 | 240 | it('should return default for nested property of scalar', () => { 241 | const ctx = { a: 'qqq' }; 242 | 243 | assert.strictEqual(get(ctx, 'a.b', 2), 2); 244 | }); 245 | 246 | it('should return complex property', () => { 247 | const ctx = { server: { a: 'b' } }; 248 | 249 | assert.strictEqual(get(ctx, 'server'), ctx.server); 250 | }); 251 | 252 | it('should return value of getters', () => { 253 | const ctx = { 254 | get stderr() { 255 | return './error.log'; 256 | }, 257 | }; 258 | 259 | assert.strictEqual(get(ctx, 'stderr'), './error.log'); 260 | }); 261 | }); 262 | 263 | describe('has helper', () => { 264 | it('should find first level property', () => { 265 | const ctx = { prop: 123 }; 266 | 267 | assert.strictEqual(has(ctx, 'prop'), true); 268 | }); 269 | 270 | it('should find complex property', () => { 271 | const ctx = { server: { a: 'b' } }; 272 | 273 | assert.strictEqual(has(ctx, 'server'), true); 274 | }); 275 | 276 | it('should not find for missing property', () => { 277 | assert.strictEqual(has({}, 'prop'), false); 278 | }); 279 | 280 | it('should get deeply nested property', () => { 281 | const ctx = { a: { b: { c: 1 } } }; 282 | 283 | assert.strictEqual(has(ctx, 'a.b.c'), true); 284 | }); 285 | 286 | it('should not find missing nested property', () => { 287 | const ctx = {a: {}}; 288 | 289 | assert.strictEqual(has(ctx, 'a.b.c'), false); 290 | }); 291 | 292 | it('should not find nested property of scalar', () => { 293 | const ctx = { a: 'qqq' }; 294 | 295 | assert.strictEqual(has(ctx, 'a.b', 2), false); 296 | }); 297 | 298 | it('should find getters', () => { 299 | const ctx = { 300 | get stderr() { 301 | return './error.log'; 302 | }, 303 | }; 304 | 305 | assert.strictEqual(has(ctx, 'stderr'), true); 306 | }); 307 | }); 308 | }); 309 | -------------------------------------------------------------------------------- /test/unit/test/restart_queue.js: -------------------------------------------------------------------------------- 1 | /* globals sinon,describe,it,beforeEach,afterEach */ 2 | 'use strict'; 3 | const RestartQueue = require('../../../lib/restart_queue'); 4 | 5 | describe('RestartQueue', () => { 6 | let queue; 7 | const sandbox = sinon.sandbox.create(); 8 | 9 | beforeEach(() => queue = new RestartQueue()); 10 | 11 | afterEach(() => { 12 | sandbox.restore(); 13 | }); 14 | 15 | describe('push', () => { 16 | it('should do nothing if object is present in queue', () => { 17 | const q = sandbox.mock(queue); 18 | q.expects('_process').once(); 19 | const worker = {on: () => {}}; 20 | queue.push(worker); 21 | queue.push(worker); 22 | q.verify(); 23 | }); 24 | }); 25 | }); 26 | --------------------------------------------------------------------------------