initial commit
This commit is contained in:
commit
5e7d34458a
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
.idea
|
||||
node_modules
|
44
README.md
Normal file
44
README.md
Normal file
@ -0,0 +1,44 @@
|
||||
# jobd
|
||||
|
||||
**jobd** is a simple job queue daemon written in Node.JS. It uses MySQL
|
||||
table as a storage.
|
||||
|
||||
|
||||
## Installation
|
||||
|
||||
To be written
|
||||
|
||||
|
||||
## Usage
|
||||
|
||||
To be written
|
||||
|
||||
|
||||
## MySQL setup
|
||||
|
||||
Table scheme. You can add additional fields if you need.
|
||||
|
||||
```
|
||||
CREATE TABLE `jobs` (
|
||||
`id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`target` char(16) NOT NULL,
|
||||
`slot` char(16) DEFAULT NULL,
|
||||
`time_created` int(10) UNSIGNED NOT NULL,
|
||||
`time_started` int(10) UNSIGNED NOT NULL DEFAULT 0,
|
||||
`time_finished` int(10) UNSIGNED NOT NULL DEFAULT 0,
|
||||
`status` enum('waiting','manual','accepted','running','done','ignored') NOT NULL DEFAULT 'waiting',
|
||||
`result` enum('ok','fail') DEFAULT NULL,
|
||||
`return_code` tinyint(3) UNSIGNED DEFAULT NULL,
|
||||
`sig` char(10) DEFAULT NULL,
|
||||
`stdout` mediumtext DEFAULT NULL,
|
||||
`stderr` mediumtext DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `status_target_idx` (`status`, `target`, `id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
|
||||
```
|
||||
|
||||
You can turn `target` and `slot` to `ENUM`, for optimization.
|
||||
|
||||
## License
|
||||
|
||||
BSD-2c
|
12
jobd-master.conf.example
Normal file
12
jobd-master.conf.example
Normal file
@ -0,0 +1,12 @@
|
||||
; server settings
|
||||
host = 0.0.0.0
|
||||
port = 13597
|
||||
;password =
|
||||
|
||||
ping_interval = 30 ; seconds
|
||||
poke_throttle_interval = 0.5 ; seconds
|
||||
|
||||
; logging
|
||||
log_file = /tmp/jobd-master.log
|
||||
log_level_file = info
|
||||
log_level_console = debug
|
38
jobd.conf.example
Normal file
38
jobd.conf.example
Normal file
@ -0,0 +1,38 @@
|
||||
; server settings
|
||||
host = 0.0.0.0
|
||||
port = 13596
|
||||
;password =
|
||||
|
||||
master_host = 127.0.0.1
|
||||
master_port = 13597
|
||||
master_reconnect_timeout = 10
|
||||
|
||||
; log
|
||||
log_file = /tmp/jobd.log
|
||||
log_level_file = info
|
||||
log_level_console = debug
|
||||
|
||||
; mysql settings
|
||||
mysql_host = 10.211.55.6
|
||||
mysql_port = 3306
|
||||
mysql_user = jobd
|
||||
mysql_password = password
|
||||
mysql_database = jobd
|
||||
mysql_table = jobs
|
||||
mysql_fetch_limit = 10
|
||||
|
||||
; launcher command template
|
||||
launcher = php /Users/ch1p/jobd-launcher.php --id {id}
|
||||
max_output_buffer = 16777216
|
||||
|
||||
;
|
||||
; targets
|
||||
;
|
||||
|
||||
[server1]
|
||||
low = 5
|
||||
normal = 5
|
||||
high = 5
|
||||
|
||||
[global]
|
||||
normal = 3
|
34
package.json
Normal file
34
package.json
Normal file
@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "jobd",
|
||||
"version": "1.0.0",
|
||||
"description": "job queue daemon",
|
||||
"main": "src/jobd",
|
||||
"homepage": "https://github.com/gch1p/jobd#readme",
|
||||
"bugs": {
|
||||
"url" : "https://github.com/gch1p/jobd/issues",
|
||||
"email": "me@ch1p.io"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"bin": {
|
||||
"jobd": "./src/jobd.js",
|
||||
"jobd-master": "./src/jobd-master.js"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "Evgeny Zinoviev",
|
||||
"license": "BSD-2-Clause",
|
||||
"os": [
|
||||
"darwin",
|
||||
"linux"
|
||||
],
|
||||
"dependencies": {
|
||||
"ini": "^2.0.0",
|
||||
"lodash": "^4.17.21",
|
||||
"log4js": "^6.3.0",
|
||||
"minimist": "^1.2.5",
|
||||
"mysql": "^2.18.1",
|
||||
"promise-mysql": "^5.0.2",
|
||||
"queue": "^6.0.2"
|
||||
}
|
||||
}
|
119
src/config.js
Normal file
119
src/config.js
Normal file
@ -0,0 +1,119 @@
|
||||
const fs = require('fs')
|
||||
const ini = require('ini')
|
||||
const {isNumeric} = require('./util')
|
||||
|
||||
let workerConfig = {
|
||||
targets: {},
|
||||
}
|
||||
let masterConfig = {}
|
||||
|
||||
function readFile(file) {
|
||||
if (!fs.existsSync(file))
|
||||
throw new Error(`file ${file} not found`)
|
||||
|
||||
return ini.parse(fs.readFileSync(file, 'utf-8'))
|
||||
}
|
||||
|
||||
function processScheme(source, scheme) {
|
||||
const result = {}
|
||||
|
||||
for (let key in scheme) {
|
||||
let opts = scheme[key]
|
||||
let ne = !(key in source) || !source[key]
|
||||
if (opts.required === true && ne)
|
||||
throw new Error(`'${key}' is not defined`)
|
||||
|
||||
let value = source[key] ?? opts.default ?? null
|
||||
|
||||
switch (opts.type) {
|
||||
case 'int':
|
||||
if (!isNumeric(value))
|
||||
throw new Error(`'${key}' must be an integer`)
|
||||
value = parseInt(value, 10)
|
||||
break
|
||||
|
||||
case 'float':
|
||||
if (!isNumeric(value))
|
||||
throw new Error(`'${key}' must be a float`)
|
||||
value = parseFloat(value)
|
||||
break
|
||||
}
|
||||
|
||||
result[key] = value
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function parseWorkerConfig(file) {
|
||||
const raw = readFile(file)
|
||||
|
||||
const scheme = {
|
||||
host: {required: true},
|
||||
port: {required: true, type: 'int'},
|
||||
password: {},
|
||||
|
||||
master_host: {},
|
||||
master_port: {type: 'int', default: 0},
|
||||
master_reconnect_timeout: {type: 'int', default: 10},
|
||||
|
||||
log_file: {},
|
||||
log_level_file: {default: 'warn'},
|
||||
log_level_console: {default: 'warn'},
|
||||
|
||||
mysql_host: {required: true},
|
||||
mysql_port: {required: true, type: 'int'},
|
||||
mysql_user: {required: true},
|
||||
mysql_password: {required: true},
|
||||
mysql_database: {required: true},
|
||||
mysql_table: {required: true, default: 'jobs'},
|
||||
mysql_fetch_limit: {default: 100, type: 'int'},
|
||||
|
||||
launcher: {required: true},
|
||||
max_output_buffer: {default: 1024*1024, type: 'int'},
|
||||
}
|
||||
Object.assign(workerConfig, processScheme(raw, scheme))
|
||||
|
||||
// targets
|
||||
for (let target in raw) {
|
||||
if (target === 'null')
|
||||
throw new Error('word \'null\' is reserved, please don\'t use it as a target name')
|
||||
|
||||
if (typeof raw[target] !== 'object')
|
||||
continue
|
||||
|
||||
workerConfig.targets[target] = {slots: {}}
|
||||
for (let slotName in raw[target]) {
|
||||
let slotLimit = parseInt(raw[target][slotName], 10)
|
||||
if (slotLimit < 1)
|
||||
throw new Error(`${target}: slot ${slotName} has invalid limit`)
|
||||
workerConfig.targets[target].slots[slotName] = slotLimit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parseMasterConfig(file) {
|
||||
const raw = readFile(file)
|
||||
|
||||
const scheme = {
|
||||
host: {required: true},
|
||||
port: {required: true, type: 'int'},
|
||||
password: {},
|
||||
|
||||
ping_interval: {default: 30, type: 'int'},
|
||||
poke_throttle_interval: {default: 0.5, type: 'float'},
|
||||
|
||||
log_file: {},
|
||||
log_level_file: {default: 'warn'},
|
||||
log_level_console: {default: 'warn'},
|
||||
}
|
||||
Object.assign(masterConfig, processScheme(raw, scheme))
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
parseWorkerConfig,
|
||||
parseMasterConfig,
|
||||
|
||||
workerConfig,
|
||||
masterConfig
|
||||
}
|
49
src/db.js
Normal file
49
src/db.js
Normal file
@ -0,0 +1,49 @@
|
||||
const {workerConfig} = require('./config')
|
||||
const {getLogger} = require('./logger')
|
||||
const mysql = require('promise-mysql')
|
||||
|
||||
let link
|
||||
const logger = getLogger('db')
|
||||
|
||||
async function init() {
|
||||
link = await mysql.createConnection({
|
||||
host: workerConfig.mysql_host,
|
||||
user: workerConfig.mysql_user,
|
||||
password: workerConfig.mysql_password,
|
||||
database: workerConfig.mysql_database
|
||||
})
|
||||
}
|
||||
|
||||
function wrap(method, isAsync = true, log = true) {
|
||||
return isAsync ? async function(...args) {
|
||||
if (log)
|
||||
logger.trace(`${method}: `, args)
|
||||
|
||||
try {
|
||||
return await link[method](...args)
|
||||
} catch (error) {
|
||||
logger.error(`db.${method}:`, error, link)
|
||||
|
||||
if ( error.code === 'PROTOCOL_ENQUEUE_AFTER_FATAL_ERROR'
|
||||
|| error.code === 'PROTOCOL_CONNECTION_LOST'
|
||||
|| error.fatal === true) {
|
||||
// try to reconnect and call it again, once
|
||||
await init()
|
||||
return await link[method](...args)
|
||||
}
|
||||
}
|
||||
} : function(...args) {
|
||||
if (log)
|
||||
logger.trace(`${method}: `, args)
|
||||
|
||||
return link[method](...args)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
init,
|
||||
query: wrap('query'),
|
||||
beginTransaction: wrap('beginTransaction'),
|
||||
commit: wrap('commit'),
|
||||
escape: wrap('escape', false, false)
|
||||
}
|
153
src/jobd-master.js
Executable file
153
src/jobd-master.js
Executable file
@ -0,0 +1,153 @@
|
||||
#!/usr/bin/env node
|
||||
const minimist = require('minimist')
|
||||
const loggerModule = require('./logger')
|
||||
const configModule = require('./config')
|
||||
const {Server, ResponseMessage, RequestMessage} = require('./server')
|
||||
const WorkersList = require('./workers-list')
|
||||
const {masterConfig} = configModule
|
||||
|
||||
|
||||
/**
|
||||
* @type {Logger}
|
||||
*/
|
||||
let logger
|
||||
|
||||
/**
|
||||
* @type {Server}
|
||||
*/
|
||||
let server
|
||||
|
||||
/**
|
||||
* @type WorkersList
|
||||
*/
|
||||
let workers
|
||||
|
||||
|
||||
main().catch(e => {
|
||||
console.error(e)
|
||||
process.exit(1)
|
||||
})
|
||||
|
||||
|
||||
async function main() {
|
||||
if (process.argv.length < 3) {
|
||||
usage()
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
process.on('SIGINT', term)
|
||||
process.on('SIGTERM', term)
|
||||
|
||||
const argv = minimist(process.argv.slice(2))
|
||||
if (!argv.config)
|
||||
throw new Error('--config option is required')
|
||||
|
||||
// read config
|
||||
try {
|
||||
configModule.parseMasterConfig(argv.config)
|
||||
} catch (e) {
|
||||
console.error(`config parsing error: ${e.message}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
await loggerModule.init({
|
||||
file: masterConfig.log_file,
|
||||
levelFile: masterConfig.log_level_file,
|
||||
levelConsole: masterConfig.log_level_console,
|
||||
})
|
||||
logger = loggerModule.getLogger('jobd-master')
|
||||
|
||||
// console.log(masterConfig)
|
||||
|
||||
workers = new WorkersList()
|
||||
|
||||
// start server
|
||||
server = new Server()
|
||||
server.on('message', onMessage)
|
||||
server.start(masterConfig.port, masterConfig.host)
|
||||
logger.info('server started')
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {RequestMessage|ResponseMessage} message
|
||||
* @param {Connection} connection
|
||||
* @return {Promise<*>}
|
||||
*/
|
||||
async function onMessage({message, connection}) {
|
||||
try {
|
||||
if (!(message instanceof RequestMessage)) {
|
||||
logger.debug('ignoring message', message)
|
||||
return
|
||||
}
|
||||
|
||||
if (message.requestType !== 'ping')
|
||||
logger.info('onMessage:', message)
|
||||
|
||||
if (masterConfig.password && message.password !== masterConfig.password) {
|
||||
connection.send(new ResponseMessage().setError('invalid password'))
|
||||
return connection.close()
|
||||
}
|
||||
|
||||
switch (message.requestType) {
|
||||
case 'ping':
|
||||
connection.send(new ResponseMessage().setError('pong'))
|
||||
break
|
||||
|
||||
case 'register-worker': {
|
||||
const targets = message.requestData?.targets || []
|
||||
if (!targets.length) {
|
||||
connection.send(new ResponseMessage().setError(`targets are empty`))
|
||||
break
|
||||
}
|
||||
|
||||
workers.add(connection, targets)
|
||||
connection.send(new ResponseMessage().setData('ok'))
|
||||
break
|
||||
}
|
||||
|
||||
case 'poke': {
|
||||
const targets = message.requestData?.targets || []
|
||||
if (!targets.length) {
|
||||
connection.send(new ResponseMessage().setError(`targets are empty`))
|
||||
break
|
||||
}
|
||||
|
||||
workers.poke(targets)
|
||||
connection.send(new ResponseMessage().setData('ok'))
|
||||
break
|
||||
}
|
||||
|
||||
case 'status':
|
||||
const info = workers.getInfo()
|
||||
connection.send(new ResponseMessage().setData({
|
||||
workers: info,
|
||||
memoryUsage: process.memoryUsage()
|
||||
}))
|
||||
break
|
||||
|
||||
default:
|
||||
connection.send(new ResponseMessage().setError(`unknown request type: '${message.requestType}'`))
|
||||
break
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`error while handling message:`, message, error)
|
||||
connection.send(new ResponseMessage().setError('server error: ' + error?.message))
|
||||
}
|
||||
}
|
||||
|
||||
function usage() {
|
||||
let s = `${process.argv[1]} OPTIONS
|
||||
|
||||
Options:
|
||||
--config <path>`
|
||||
console.log(s)
|
||||
}
|
||||
|
||||
function term() {
|
||||
if (logger)
|
||||
logger.info('shutdown')
|
||||
|
||||
loggerModule.shutdown(function() {
|
||||
process.exit()
|
||||
})
|
||||
}
|
244
src/jobd.js
Executable file
244
src/jobd.js
Executable file
@ -0,0 +1,244 @@
|
||||
#!/usr/bin/env node
|
||||
const minimist = require('minimist')
|
||||
const loggerModule = require('./logger')
|
||||
const configModule = require('./config')
|
||||
const db = require('./db')
|
||||
const {Server, Connection, RequestMessage, ResponseMessage} = require('./server')
|
||||
const {Worker, STATUS_MANUAL} = require('./worker')
|
||||
|
||||
const {workerConfig} = configModule
|
||||
|
||||
|
||||
/**
|
||||
* @type {Worker}
|
||||
*/
|
||||
let worker
|
||||
|
||||
/**
|
||||
* @type {Logger}
|
||||
*/
|
||||
let logger
|
||||
|
||||
/**
|
||||
* @type {Server}
|
||||
*/
|
||||
let server
|
||||
|
||||
/**
|
||||
* @type {object.<string, Connection>}
|
||||
*/
|
||||
let jobDoneAwaiters = {}
|
||||
|
||||
|
||||
main().catch(e => {
|
||||
console.error(e)
|
||||
process.exit(1)
|
||||
})
|
||||
|
||||
|
||||
async function main() {
|
||||
if (process.argv.length < 3) {
|
||||
usage()
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
process.on('SIGINT', term)
|
||||
process.on('SIGTERM', term)
|
||||
|
||||
const argv = minimist(process.argv.slice(2))
|
||||
if (!argv.config)
|
||||
throw new Error('--config option is required')
|
||||
|
||||
// read config
|
||||
try {
|
||||
configModule.parseWorkerConfig(argv.config)
|
||||
} catch (e) {
|
||||
console.error(`config parsing error: ${e.message}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
await loggerModule.init({
|
||||
file: workerConfig.log_file,
|
||||
levelFile: workerConfig.log_level_file,
|
||||
levelConsole: workerConfig.log_level_console,
|
||||
})
|
||||
logger = loggerModule.getLogger('jobd')
|
||||
|
||||
// console.log(workerConfig)
|
||||
|
||||
// init database
|
||||
try {
|
||||
await db.init()
|
||||
} catch (error) {
|
||||
logger.error('failed to connect to MySQL', error)
|
||||
process.exit(1)
|
||||
}
|
||||
logger.info('db initialized')
|
||||
|
||||
// init queue
|
||||
worker = new Worker()
|
||||
for (let targetName in workerConfig.targets) {
|
||||
let slots = workerConfig.targets[targetName].slots
|
||||
// let target = new Target({name: targetName})
|
||||
// queue.addTarget(target)
|
||||
|
||||
for (let slotName in slots) {
|
||||
let slotLimit = slots[slotName]
|
||||
worker.addSlot(targetName, slotName, slotLimit)
|
||||
}
|
||||
}
|
||||
worker.on('job-done', (data) => {
|
||||
if (jobDoneAwaiters[data.id] !== undefined) {
|
||||
jobDoneAwaiters[data.id].send(new ResponseMessage().setData(data))
|
||||
jobDoneAwaiters[data.id].close()
|
||||
delete jobDoneAwaiters[data.id]
|
||||
}
|
||||
})
|
||||
logger.info('queue initialized')
|
||||
|
||||
// start server
|
||||
server = new Server()
|
||||
server.on('message', onMessage)
|
||||
server.start(workerConfig.port, workerConfig.host)
|
||||
logger.info('server started')
|
||||
|
||||
// connect to master
|
||||
if (workerConfig.master_port && workerConfig.master_host)
|
||||
connectToMaster()
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @param {RequestMessage|ResponseMessage} message
|
||||
* @param {Connection} connection
|
||||
* @return {Promise<*>}
|
||||
*/
|
||||
async function onMessage({message, connection}) {
|
||||
try {
|
||||
if (!(message instanceof RequestMessage)) {
|
||||
logger.debug('ignoring message', message)
|
||||
return
|
||||
}
|
||||
|
||||
if (message.requestType !== 'ping')
|
||||
logger.info('onMessage:', message)
|
||||
|
||||
if (workerConfig.password && message.password !== workerConfig.password) {
|
||||
connection.send(new ResponseMessage().setError('invalid password'))
|
||||
return connection.close()
|
||||
}
|
||||
|
||||
switch (message.requestType) {
|
||||
case 'ping':
|
||||
connection.send(new ResponseMessage().setData('pong'))
|
||||
break
|
||||
|
||||
case 'poll':
|
||||
const targets = message.requestData?.targets || []
|
||||
if (!targets.length) {
|
||||
connection.send(new ResponseMessage().setError('empty targets'))
|
||||
break
|
||||
}
|
||||
|
||||
for (const t of targets) {
|
||||
if (!worker.hasTarget(t)) {
|
||||
connection.send(new ResponseMessage().setError(`invalid target '${t}'`))
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
worker.setPollTargets(targets)
|
||||
worker.poll()
|
||||
|
||||
connection.send(new ResponseMessage().setData('ok'));
|
||||
break
|
||||
|
||||
case 'status':
|
||||
const qs = worker.getStatus()
|
||||
connection.send(
|
||||
new ResponseMessage().setData({
|
||||
queue: qs,
|
||||
jobDoneAwaitersCount: Object.keys(jobDoneAwaiters).length,
|
||||
memoryUsage: process.memoryUsage()
|
||||
})
|
||||
)
|
||||
break
|
||||
|
||||
case 'run-manual':
|
||||
const {id} = message.requestData
|
||||
if (id in jobDoneAwaiters) {
|
||||
connection.send(new ResponseMessage().setError('another client is already waiting this job'))
|
||||
break
|
||||
}
|
||||
|
||||
jobDoneAwaiters[id] = connection
|
||||
|
||||
const {accepted} = await worker.getTasks(null, STATUS_MANUAL, {id})
|
||||
if (!accepted) {
|
||||
delete jobDoneAwaiters[id]
|
||||
connection.send(new ResponseMessage().setError('failed to run task')) // would be nice to provide some error...
|
||||
}
|
||||
|
||||
break
|
||||
|
||||
default:
|
||||
connection.send(new ResponseMessage().setError(`unknown request type: '${message.requestType}'`))
|
||||
break
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`error while handling message:`, message, error)
|
||||
connection.send(new ResponseMessage().setError('server error: ' + error?.message))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function connectToMaster() {
|
||||
const connection = new Connection()
|
||||
connection.connect(workerConfig.master_host, workerConfig.master_port)
|
||||
|
||||
connection.on('connect', function() {
|
||||
connection.send(
|
||||
new RequestMessage('register-worker', {
|
||||
targets: worker.getTargets()
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
connection.on('close', () => {
|
||||
logger.warn(`connectToMaster: connection closed`)
|
||||
setTimeout(() => {
|
||||
connectToMaster()
|
||||
}, workerConfig.master_reconnect_timeout * 1000)
|
||||
})
|
||||
|
||||
connection.on('message', (message) => {
|
||||
if (!(message instanceof RequestMessage)) {
|
||||
logger.debug('message from master is not a request, hmm... skipping', message)
|
||||
return
|
||||
}
|
||||
|
||||
onMessage({message, connection})
|
||||
.catch((error) => {
|
||||
logger.error('connectToMaster: onMessage:', error)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
function usage() {
|
||||
let s = `${process.argv[1]} OPTIONS
|
||||
|
||||
Options:
|
||||
--config <path>`
|
||||
console.log(s)
|
||||
}
|
||||
|
||||
|
||||
function term() {
|
||||
if (logger)
|
||||
logger.info('shutdown')
|
||||
|
||||
loggerModule.shutdown(function() {
|
||||
process.exit()
|
||||
})
|
||||
}
|
97
src/logger.js
Normal file
97
src/logger.js
Normal file
@ -0,0 +1,97 @@
|
||||
const log4js = require('log4js')
|
||||
const fs = require('fs/promises')
|
||||
const fsConstants = require('fs').constants
|
||||
const util = require('util')
|
||||
|
||||
module.exports = {
|
||||
/**
|
||||
* @param {string} file
|
||||
* @param {string} levelFile
|
||||
* @param {string} levelConsole
|
||||
*/
|
||||
async init({file, levelFile, levelConsole}) {
|
||||
const categories = {
|
||||
default: {
|
||||
appenders: ['stdout-filter'],
|
||||
level: 'trace'
|
||||
}
|
||||
}
|
||||
|
||||
const appenders = {
|
||||
stdout: {
|
||||
type: 'stdout',
|
||||
level: 'trace'
|
||||
},
|
||||
'stdout-filter': {
|
||||
type: 'logLevelFilter',
|
||||
appender: 'stdout',
|
||||
level: levelConsole
|
||||
}
|
||||
}
|
||||
|
||||
if (file) {
|
||||
let exists
|
||||
try {
|
||||
await fs.stat(file)
|
||||
exists = true
|
||||
} catch (error) {
|
||||
exists = false
|
||||
}
|
||||
|
||||
// if file exists
|
||||
if (exists) {
|
||||
// see if it's writable
|
||||
try {
|
||||
// this promise fullfills with undefined upon success
|
||||
await fs.access(file, fsConstants.W_OK)
|
||||
} catch (error) {
|
||||
throw new Error(`file '${file}' is not writable:` + error.message)
|
||||
}
|
||||
} else {
|
||||
// try to create an empty file
|
||||
let fd
|
||||
try {
|
||||
fd = await fs.open(file, 'wx')
|
||||
} catch (error) {
|
||||
throw new Error(`failed to create file '${file}': ` + error.message)
|
||||
} finally {
|
||||
await fd?.close()
|
||||
}
|
||||
}
|
||||
|
||||
categories.default.appenders.push('file-filter')
|
||||
appenders.file = {
|
||||
type: 'file',
|
||||
filename: file,
|
||||
maxLogSize: 1024 * 1024 * 50,
|
||||
debug: 'debug'
|
||||
}
|
||||
appenders['file-filter'] = {
|
||||
type: 'logLevelFilter',
|
||||
appender: 'file',
|
||||
level: levelFile
|
||||
}
|
||||
}
|
||||
|
||||
log4js.configure({
|
||||
appenders,
|
||||
categories
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* @return {Logger}
|
||||
*/
|
||||
getLogger(...args) {
|
||||
return log4js.getLogger(...args)
|
||||
},
|
||||
|
||||
/**
|
||||
* @param cb
|
||||
*/
|
||||
shutdown(cb) {
|
||||
log4js.shutdown(cb)
|
||||
},
|
||||
|
||||
Logger: log4js.Logger,
|
||||
}
|
450
src/server.js
Normal file
450
src/server.js
Normal file
@ -0,0 +1,450 @@
|
||||
const net = require('net')
|
||||
const EventEmitter = require('events')
|
||||
const {getLogger} = require('./logger')
|
||||
const isObject = require('lodash/isObject')
|
||||
|
||||
const EOT = 0x04
|
||||
|
||||
class Message {
|
||||
|
||||
static REQUEST = 0
|
||||
static RESPONSE = 1
|
||||
|
||||
/**
|
||||
* @param {number} type
|
||||
*/
|
||||
constructor(type) {
|
||||
/**
|
||||
* @type {number}
|
||||
*/
|
||||
this.type = type
|
||||
}
|
||||
|
||||
getAsObject() {
|
||||
return [this.type]
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class ResponseMessage extends Message {
|
||||
constructor() {
|
||||
super(Message.RESPONSE)
|
||||
|
||||
this.error = null
|
||||
this.data = null
|
||||
}
|
||||
|
||||
setError(error) {
|
||||
this.error = error
|
||||
return this
|
||||
}
|
||||
|
||||
setData(data) {
|
||||
this.data = data
|
||||
return this
|
||||
}
|
||||
|
||||
getAsObject() {
|
||||
return [
|
||||
...super.getAsObject(),
|
||||
[
|
||||
this.error,
|
||||
this.data
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
class RequestMessage extends Message {
|
||||
/**
|
||||
* @param {string} type
|
||||
* @param {any} data
|
||||
*/
|
||||
constructor(type, data = null) {
|
||||
super(Message.REQUEST)
|
||||
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
this.requestType = type
|
||||
|
||||
/**
|
||||
* @type any
|
||||
*/
|
||||
this.requestData = data
|
||||
|
||||
/**
|
||||
* @type {null|string}
|
||||
*/
|
||||
this.password = null
|
||||
}
|
||||
|
||||
getAsObject() {
|
||||
let request = {
|
||||
type: this.requestType
|
||||
}
|
||||
|
||||
if (this.requestData)
|
||||
request.data = this.requestData
|
||||
|
||||
return [
|
||||
...super.getAsObject(),
|
||||
request
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} password
|
||||
*/
|
||||
setPassword(password) {
|
||||
this.password = password
|
||||
}
|
||||
}
|
||||
|
||||
class Server extends EventEmitter {
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
|
||||
/**
|
||||
* @type {null|module:net.Server}
|
||||
*/
|
||||
this.server = null
|
||||
|
||||
/**
|
||||
* @type {Logger}
|
||||
*/
|
||||
this.logger = getLogger('server')
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} port
|
||||
* @param {string} host
|
||||
*/
|
||||
start(port, host) {
|
||||
this.server = net.createServer()
|
||||
|
||||
this.server.on('connection', this.onConnection)
|
||||
this.server.on('error', this.onError)
|
||||
this.server.on('listening', this.onListening)
|
||||
|
||||
this.server.listen(port, host)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {module:net.Socket} socket
|
||||
*/
|
||||
onConnection = (socket) => {
|
||||
let connection = new Connection()
|
||||
connection.setSocket(socket)
|
||||
connection.on('message', (message) => {
|
||||
this.emit('message', {
|
||||
message,
|
||||
connection
|
||||
})
|
||||
})
|
||||
|
||||
this.logger.info(`new connection from ${socket.remoteAddress}:${socket.remotePort}`)
|
||||
}
|
||||
|
||||
onListening = () => {
|
||||
let addr = this.server.address()
|
||||
this.logger.info(`server is listening on ${addr.address}:${addr.port}`)
|
||||
}
|
||||
|
||||
onError = (error) => {
|
||||
this.logger.error('error: ', error)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class Connection extends EventEmitter {
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
|
||||
/**
|
||||
* @type {null|module:net.Socket}
|
||||
*/
|
||||
this.socket = null
|
||||
|
||||
/**
|
||||
* @type {Buffer}
|
||||
*/
|
||||
this.data = Buffer.from([])
|
||||
|
||||
/**
|
||||
* @type {boolean}
|
||||
* @private
|
||||
*/
|
||||
this._closeEmitted = false
|
||||
|
||||
/**
|
||||
* @type {null|string}
|
||||
*/
|
||||
this.remoteAddress = null
|
||||
|
||||
/**
|
||||
* @type {null|number}
|
||||
*/
|
||||
this.remotePort = null
|
||||
|
||||
/**
|
||||
* @type {null|number}
|
||||
*/
|
||||
this.id = null
|
||||
|
||||
this._setLogger()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} host
|
||||
* @param {number} port
|
||||
*/
|
||||
connect(host, port) {
|
||||
if (this.socket !== null)
|
||||
throw new Error(`this Connection already has a socket`)
|
||||
|
||||
this.socket = new net.Socket()
|
||||
this.socket.connect({host, port})
|
||||
|
||||
this.remoteAddress = host
|
||||
this.remotePort = port
|
||||
|
||||
this._setId()
|
||||
this._setLogger()
|
||||
this._setSocketEvents()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {module:net.Socket} socket
|
||||
*/
|
||||
setSocket(socket) {
|
||||
this.socket = socket
|
||||
|
||||
this.remoteAddress = socket.remoteAddress
|
||||
this.remotePort = socket.remotePort
|
||||
|
||||
this._setId()
|
||||
this._setLogger()
|
||||
this._setSocketEvents()
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_setLogger() {
|
||||
let addr = this.socket ? this.remoteAddr() : '?'
|
||||
this.logger = getLogger(`<Connection ${this.id} ${addr}>`)
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_setId() {
|
||||
this.id = Math.floor(Math.random() * 10000)
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_setSocketEvents() {
|
||||
this.socket.on('connect', this.onConnect)
|
||||
this.socket.on('data', this.onData)
|
||||
this.socket.on('end', this.onEnd)
|
||||
this.socket.on('close', this.onClose)
|
||||
this.socket.on('error', this.onError)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Buffer} data
|
||||
* @private
|
||||
*/
|
||||
_appendToBuffer(data) {
|
||||
this.data = Buffer.concat([this.data, data])
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {string}
|
||||
*/
|
||||
remoteAddr() {
|
||||
return this.remoteAddress + ':' + this.remotePort
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_processChunks() {
|
||||
if (!this.data.length)
|
||||
return
|
||||
|
||||
this.logger.trace(`processChunks (start):`, this.data)
|
||||
|
||||
/**
|
||||
* @type {Buffer[]}
|
||||
*/
|
||||
let messages = []
|
||||
let offset = 0
|
||||
let eotPos
|
||||
do {
|
||||
eotPos = this.data.indexOf(EOT, offset)
|
||||
if (eotPos !== -1) {
|
||||
let message = this.data.slice(offset, eotPos)
|
||||
messages.push(message)
|
||||
|
||||
this.logger.debug(`processChunks: found new message (${offset}, ${eotPos})`)
|
||||
offset = eotPos + 1
|
||||
}
|
||||
} while (eotPos !== -1 && offset < this.data.length-1)
|
||||
|
||||
if (offset !== 0) {
|
||||
this.data = this.data.slice(offset)
|
||||
this.logger.trace(`processChunks: slicing data from ${offset}`)
|
||||
}
|
||||
|
||||
this.logger.trace(`processChunks (after parsing):`, this.data)
|
||||
|
||||
for (let message of messages) {
|
||||
try {
|
||||
let buf = message.toString('utf-8')
|
||||
this.logger.debug(buf)
|
||||
|
||||
let json = JSON.parse(buf)
|
||||
this._emitMessage(json)
|
||||
} catch (error) {
|
||||
this.logger.error('failed to parse data as JSON')
|
||||
this.logger.debug(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} json
|
||||
* @private
|
||||
*/
|
||||
_emitMessage(json) {
|
||||
if (!Array.isArray(json)) {
|
||||
this.logger.error('malformed message, JSON array expected', json)
|
||||
return
|
||||
}
|
||||
|
||||
let type = json.shift()
|
||||
let message
|
||||
switch (type) {
|
||||
case Message.REQUEST: {
|
||||
let data = json.shift()
|
||||
if (!data || !isObject(data)) {
|
||||
this.logger.error('malformed REQUEST message')
|
||||
return
|
||||
}
|
||||
|
||||
message = new RequestMessage(data.type, data.data || null)
|
||||
if (data.password)
|
||||
message.setPassword(data.password)
|
||||
break
|
||||
}
|
||||
|
||||
case Message.RESPONSE: {
|
||||
let data = json.shift()
|
||||
if (!data || !Array.isArray(data) || data.length < 2) {
|
||||
this.logger.error('malformed RESPONSE message')
|
||||
return
|
||||
}
|
||||
|
||||
message = new ResponseMessage()
|
||||
message.setError(data[0]).setData(data[1])
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
default:
|
||||
this.logger.error(`malformed message, unexpected type ${type}`)
|
||||
return
|
||||
}
|
||||
|
||||
this.emit('message', message)
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {Message} data
|
||||
* @param message
|
||||
*/
|
||||
send(message) {
|
||||
if (!(message instanceof Message))
|
||||
throw new Error('send expects Message, got', message)
|
||||
|
||||
let json = JSON.stringify(message.getAsObject())
|
||||
let buf = Buffer.concat([
|
||||
Buffer.from(json),
|
||||
Buffer.from([EOT])
|
||||
])
|
||||
|
||||
this.logger.debug('send:', json)
|
||||
this.logger.trace('send:', buf)
|
||||
|
||||
try {
|
||||
this.socket.write(buf)
|
||||
} catch (error) {
|
||||
this.logger.error(`processChunks: failed to write response ${JSON.stringify(message)} to a socket`, error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*/
|
||||
close() {
|
||||
try {
|
||||
this.socket.end()
|
||||
this.socket.destroy()
|
||||
this._emitClose()
|
||||
} catch (error) {
|
||||
this.logger.error('close:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_emitClose() {
|
||||
if (this._closeEmitted)
|
||||
return
|
||||
|
||||
this._closeEmitted = true
|
||||
this.emit('close')
|
||||
}
|
||||
|
||||
onConnect = () => {
|
||||
this.logger.debug('connection established')
|
||||
this.emit('connect')
|
||||
}
|
||||
|
||||
onData = (data) => {
|
||||
this.logger.trace('onData', data)
|
||||
this._appendToBuffer(data)
|
||||
this._processChunks()
|
||||
}
|
||||
|
||||
onEnd = (data) => {
|
||||
if (data)
|
||||
this._appendToBuffer(data)
|
||||
|
||||
this._processChunks()
|
||||
}
|
||||
|
||||
onClose = (hadError) => {
|
||||
this._emitClose()
|
||||
this.logger.debug(`socket closed` + (hadError ? ` with error` : ''))
|
||||
}
|
||||
|
||||
onError = (error) => {
|
||||
this._emitClose()
|
||||
this.logger.warn(`socket error:`, error)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
Server,
|
||||
Connection,
|
||||
RequestMessage,
|
||||
ResponseMessage
|
||||
}
|
9
src/util.js
Normal file
9
src/util.js
Normal file
@ -0,0 +1,9 @@
|
||||
module.exports = {
|
||||
timestamp() {
|
||||
return parseInt(+(new Date())/1000)
|
||||
},
|
||||
|
||||
isNumeric(n) {
|
||||
return !isNaN(parseFloat(n)) && isFinite(n)
|
||||
}
|
||||
}
|
472
src/worker.js
Normal file
472
src/worker.js
Normal file
@ -0,0 +1,472 @@
|
||||
const Queue = require('queue')
|
||||
const child_process = require('child_process')
|
||||
const db = require('./db')
|
||||
const {timestamp} = require('./util')
|
||||
const {getLogger} = require('./logger')
|
||||
const EventEmitter = require('events')
|
||||
const {workerConfig} = require('./config')
|
||||
|
||||
const STATUS_WAITING = 'waiting'
|
||||
const STATUS_MANUAL = 'manual'
|
||||
const STATUS_ACCEPTED = 'accepted'
|
||||
const STATUS_IGNORED = 'ignored'
|
||||
const STATUS_RUNNING = 'running'
|
||||
const STATUS_DONE = 'done'
|
||||
|
||||
const RESULT_OK = 'ok'
|
||||
const RESULT_FAIL = 'fail'
|
||||
|
||||
class Worker extends EventEmitter {
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
|
||||
/**
|
||||
* @type {object.<string, {slots: object.<string, {limit: number, queue: Queue}>}>}
|
||||
*/
|
||||
this.targets = {}
|
||||
|
||||
/**
|
||||
* @type {boolean}
|
||||
*/
|
||||
this.polling = false
|
||||
|
||||
/**
|
||||
* @type {boolean}
|
||||
*/
|
||||
this.nextpoll = {}
|
||||
|
||||
/**
|
||||
* @type {Logger}
|
||||
*/
|
||||
this.logger = getLogger('Worker')
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} target
|
||||
* @param {string} slot
|
||||
* @param {number} limit
|
||||
*/
|
||||
addSlot(target, slot, limit) {
|
||||
this.logger.debug(`addSlot: adding slot '${slot}' for target' ${target}' (limit: ${limit})`)
|
||||
|
||||
if (this.targets[target] === undefined)
|
||||
this.targets[target] = {slots: {}}
|
||||
|
||||
if (this.targets[target].slots[slot] !== undefined)
|
||||
throw new Error(`slot ${slot} for target ${target} has already been added`)
|
||||
|
||||
let queue = Queue({
|
||||
concurrency: limit,
|
||||
autostart: true
|
||||
})
|
||||
queue.on('success', this.onJobFinished.bind(this, target, slot))
|
||||
queue.on('error', this.onJobFinished.bind(this, target, slot))
|
||||
queue.start()
|
||||
|
||||
this.targets[target].slots[slot] = {limit, queue}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} target
|
||||
* @returns {boolean}
|
||||
*/
|
||||
hasTarget(target) {
|
||||
return (target in this.targets)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns status of all queues.
|
||||
*
|
||||
* @return {object}
|
||||
*/
|
||||
getStatus() {
|
||||
let status = {targets: {}}
|
||||
for (const targetName in this.targets) {
|
||||
let target = this.targets[targetName]
|
||||
status.targets[targetName] = {}
|
||||
for (const slotName in target.slots) {
|
||||
const {queue, limit} = target.slots[slotName]
|
||||
status.targets[targetName][slotName] = {
|
||||
concurrency: queue.concurrency,
|
||||
limit,
|
||||
length: queue.length,
|
||||
}
|
||||
}
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {string[]}
|
||||
*/
|
||||
getTargets() {
|
||||
return Object.keys(this.targets)
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
poll() {
|
||||
const LOGPREFIX = `poll():`
|
||||
|
||||
let targets = this.getPollTargets()
|
||||
if (!targets.length) {
|
||||
this.poller.warn(`${LOGPREFIX} no targets`)
|
||||
return
|
||||
}
|
||||
|
||||
// skip and postpone the poll, if we're in the middle on another poll
|
||||
// it will be called again from the last .then() at the end of this method
|
||||
if (this.polling) {
|
||||
this.logger.debug(`${LOGPREFIX} already polling`)
|
||||
return
|
||||
}
|
||||
|
||||
// skip and postpone the poll, if no free slots
|
||||
// it will be called again from onJobFinished()
|
||||
if (!this.hasFreeSlots(targets)) {
|
||||
this.logger.debug(`${LOGPREFIX} no free slots`)
|
||||
return
|
||||
}
|
||||
|
||||
// set polling flag
|
||||
this.polling = true
|
||||
|
||||
// clear postponed polls target list
|
||||
this.setPollTargets()
|
||||
|
||||
this.logger.debug(`${LOGPREFIX} calling getTasks(${JSON.stringify(targets)})`)
|
||||
this.getTasks(targets)
|
||||
.then(({rows}) => {
|
||||
let message = `${LOGPREFIX} ${rows} processed`
|
||||
if (workerConfig.mysql_fetch_limit && rows >= workerConfig.mysql_fetch_limit) {
|
||||
// it seems, there are more, so we'll need to perform another query
|
||||
this.setPollTargets(targets)
|
||||
message += `, scheduling more polls (targets: ${JSON.stringify(this.getPollTargets())})`
|
||||
}
|
||||
this.logger.debug(message)
|
||||
})
|
||||
.catch((error) => {
|
||||
this.logger.error(`${LOGPREFIX}`, error)
|
||||
//this.setPollTargets(targets)
|
||||
})
|
||||
.then(() => {
|
||||
// unset polling flag
|
||||
this.polling = false
|
||||
|
||||
// perform another poll, if needed
|
||||
if (this.getPollTargets().length > 0) {
|
||||
this.logger.debug(`${LOGPREFIX} next poll scheduled, calling poll() again`)
|
||||
this.poll()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string|string[]|null} target
|
||||
*/
|
||||
setPollTargets(target) {
|
||||
// when called without parameter, remove all targets
|
||||
if (target === undefined) {
|
||||
this.nextpoll = {}
|
||||
return
|
||||
}
|
||||
|
||||
// just a fix
|
||||
if (target === 'null')
|
||||
target = null
|
||||
|
||||
if (Array.isArray(target)) {
|
||||
target.forEach(t => {
|
||||
this.nextpoll[t] = true
|
||||
})
|
||||
} else {
|
||||
if (target === null)
|
||||
this.nextpoll = {}
|
||||
this.nextpoll[target] = true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {string[]}
|
||||
*/
|
||||
getPollTargets() {
|
||||
if (null in this.nextpoll)
|
||||
return Object.keys(this.targets)
|
||||
|
||||
return Object.keys(this.nextpoll)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} target
|
||||
* @return {boolean}
|
||||
*/
|
||||
hasPollTarget(target) {
|
||||
return target in this.nextpoll || null in this.nextpoll
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string|null|string[]} target
|
||||
* @param {string} reqstatus
|
||||
* @param {object} data
|
||||
* @returns {Promise<{ignored: number, accepted: number, rows: number}>}
|
||||
*/
|
||||
async getTasks(target = null, reqstatus = STATUS_WAITING, data = {}) {
|
||||
const LOGPREFIX = `getTasks(${JSON.stringify(target)}, '${reqstatus}', ${JSON.stringify(data)}):`
|
||||
|
||||
// get new jobs in transaction
|
||||
await db.beginTransaction()
|
||||
|
||||
let sqlFields = `id, status, target, slot`
|
||||
let sql
|
||||
if (data.id) {
|
||||
sql = `SELECT ${sqlFields} FROM ${workerConfig.mysql_table} WHERE id=${db.escape(data.id)} FOR UPDATE`
|
||||
} else {
|
||||
let targets
|
||||
if (target === null) {
|
||||
targets = Object.keys(this.targets)
|
||||
} else if (!Array.isArray(target)) {
|
||||
targets = [target]
|
||||
} else {
|
||||
targets = target
|
||||
}
|
||||
let sqlLimit = workerConfig.mysql_fetch_limit !== 0 ? ` LIMIT 0, ${workerConfig.mysql_fetch_limit}` : ''
|
||||
let sqlWhere = `status=${db.escape(reqstatus)} AND target IN (`+targets.map(db.escape).join(',')+`)`
|
||||
sql = `SELECT ${sqlFields} FROM ${workerConfig.mysql_table} WHERE ${sqlWhere} ORDER BY id ${sqlLimit} FOR UPDATE`
|
||||
}
|
||||
|
||||
/** @type {object[]} results */
|
||||
let results = await db.query(sql)
|
||||
this.logger.trace(`${LOGPREFIX} query result:`, results)
|
||||
|
||||
/**
|
||||
* @type {{target: string, slot: string, id: number}[]}
|
||||
*/
|
||||
let accepted = []
|
||||
|
||||
/**
|
||||
* @type {number[]}
|
||||
*/
|
||||
let ignored = []
|
||||
|
||||
for (let result of results) {
|
||||
let {id, slot, target, status} = result
|
||||
id = parseInt(id)
|
||||
|
||||
if (status !== reqstatus) {
|
||||
this.logger.warn(`${LOGPREFIX} status = ${status} != ${reqstatus}`)
|
||||
ignored.push(id)
|
||||
continue
|
||||
}
|
||||
|
||||
if (!target || this.targets[target] === undefined) {
|
||||
this.logger.error(`${LOGPREFIX} target '${target}' not found (job id=${id})`)
|
||||
ignored.push(id)
|
||||
continue
|
||||
}
|
||||
|
||||
if (!slot || this.targets[target].slots[slot] === undefined) {
|
||||
this.logger.error(`${LOGPREFIX} slot '${slot}' of target '${target}' not found (job id=${id})`)
|
||||
ignored.push(id)
|
||||
continue
|
||||
}
|
||||
|
||||
this.logger.debug(`${LOGPREFIX} accepted target='${target}', slot='${slot}', id=${id}`)
|
||||
accepted.push({target, slot, id})
|
||||
}
|
||||
|
||||
if (accepted.length)
|
||||
await db.query(`UPDATE ${workerConfig.mysql_table} SET status='accepted' WHERE id IN (`+accepted.map(j => j.id).join(',')+`)`)
|
||||
|
||||
if (ignored.length)
|
||||
await db.query(`UPDATE ${workerConfig.mysql_table} SET status='ignored' WHERE id IN (`+ignored.join(',')+`)`)
|
||||
|
||||
await db.commit()
|
||||
|
||||
accepted.forEach(({id, target, slot}) => {
|
||||
let q = this.targets[target].slots[slot].queue
|
||||
q.push(async (cb) => {
|
||||
let data = {
|
||||
code: null,
|
||||
signal: null,
|
||||
stdout: '',
|
||||
stderr: ''
|
||||
}
|
||||
let result = RESULT_OK
|
||||
|
||||
try {
|
||||
await this.setJobStatus(id, STATUS_RUNNING)
|
||||
|
||||
Object.assign(data, (await this.run(id)))
|
||||
if (data.code !== 0)
|
||||
result = RESULT_FAIL
|
||||
} catch (error) {
|
||||
this.logger.error(`${LOGPREFIX} job ${id}: error while run():`, error)
|
||||
result = RESULT_FAIL
|
||||
data.stderr = (error instanceof Error) ? (error.message + '\n' + error.stack) : (error + '')
|
||||
} finally {
|
||||
this.emit('job-done', {
|
||||
id,
|
||||
result,
|
||||
...data
|
||||
})
|
||||
|
||||
try {
|
||||
await this.setJobStatus(id, STATUS_DONE, result, data)
|
||||
} catch (error) {
|
||||
this.logger.error(`${LOGPREFIX} setJobStatus(${id})`, error)
|
||||
}
|
||||
|
||||
cb()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
rows: results.length,
|
||||
accepted: accepted.length,
|
||||
ignored: ignored.length,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} id
|
||||
*/
|
||||
async run(id) {
|
||||
let command = workerConfig.launcher.replace(/\{id\}/g, id)
|
||||
let args = command.split(/ +/)
|
||||
return new Promise((resolve, reject) => {
|
||||
this.logger.info(`run(${id}): launching`, args)
|
||||
|
||||
let process = child_process.spawn(args[0], args.slice(1), {
|
||||
maxBuffer: workerConfig.max_output_buffer
|
||||
})
|
||||
|
||||
let stdoutChunks = []
|
||||
let stderrChunks = []
|
||||
|
||||
process.on('exit',
|
||||
/**
|
||||
* @param {null|number} code
|
||||
* @param {null|string} signal
|
||||
*/
|
||||
(code, signal) => {
|
||||
let stdout = stdoutChunks.join('')
|
||||
let stderr = stderrChunks.join('')
|
||||
|
||||
stdoutChunks = undefined
|
||||
stderrChunks = undefined
|
||||
|
||||
resolve({
|
||||
code,
|
||||
signal,
|
||||
stdout,
|
||||
stderr
|
||||
})
|
||||
})
|
||||
|
||||
process.on('error', (error) => {
|
||||
reject(error)
|
||||
})
|
||||
|
||||
process.stdout.on('data', (data) => {
|
||||
if (data instanceof Buffer)
|
||||
data = data.toString('utf-8')
|
||||
stdoutChunks.push(data)
|
||||
})
|
||||
|
||||
process.stderr.on('data', (data) => {
|
||||
if (data instanceof Buffer)
|
||||
data = data.toString('utf-8')
|
||||
stderrChunks.push(data)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} id
|
||||
* @param {string} status
|
||||
* @param {string} result
|
||||
* @param {object} data
|
||||
* @return {Promise<void>}
|
||||
*/
|
||||
async setJobStatus(id, status, result = RESULT_OK, data = {}) {
|
||||
let update = {
|
||||
status,
|
||||
result
|
||||
}
|
||||
switch (status) {
|
||||
case STATUS_RUNNING:
|
||||
case STATUS_DONE:
|
||||
update[status === STATUS_RUNNING ? 'time_started' : 'time_finished'] = timestamp()
|
||||
break
|
||||
}
|
||||
if (data.code !== undefined)
|
||||
update.return_code = data.code
|
||||
if (data.signal !== undefined)
|
||||
update.sig = data.signal
|
||||
if (data.stderr !== undefined)
|
||||
update.stderr = data.stderr
|
||||
if (data.stdout !== undefined)
|
||||
update.stdout = data.stdout
|
||||
|
||||
let list = []
|
||||
for (let field in update) {
|
||||
let val = update[field]
|
||||
if (val !== null)
|
||||
val = db.escape(val)
|
||||
list.push(`${field}=${val}`)
|
||||
}
|
||||
|
||||
await db.query(`UPDATE ${workerConfig.mysql_table} SET ${list.join(', ')} WHERE id=?`, [id])
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string[]} inTargets
|
||||
* @returns {boolean}
|
||||
*/
|
||||
hasFreeSlots(inTargets = []) {
|
||||
const LOGPREFIX = `hasFreeSlots(${JSON.stringify(inTargets)}):`
|
||||
|
||||
this.logger.debug(`${LOGPREFIX} entered`)
|
||||
|
||||
for (const target in this.targets) {
|
||||
if (!inTargets.includes(target))
|
||||
continue
|
||||
|
||||
for (const slot in this.targets[target].slots) {
|
||||
const {limit, queue} = this.targets[target].slots[slot]
|
||||
this.logger.debug(LOGPREFIX, limit, queue.length)
|
||||
if (queue.length < limit)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} target
|
||||
* @param {string} slot
|
||||
*/
|
||||
onJobFinished = (target, slot) => {
|
||||
this.logger.debug(`onJobFinished: target=${target}, slot=${slot}`)
|
||||
const {queue, limit} = this.targets[target].slots[slot]
|
||||
if (queue.length < limit && this.hasPollTarget(target)) {
|
||||
this.logger.debug(`onJobFinished: ${queue.length} < ${limit}, calling poll(${target})`)
|
||||
this.poll()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
Worker,
|
||||
STATUS_WAITING,
|
||||
STATUS_MANUAL,
|
||||
STATUS_ACCEPTED,
|
||||
STATUS_IGNORED,
|
||||
STATUS_RUNNING,
|
||||
STATUS_DONE,
|
||||
}
|
145
src/workers-list.js
Normal file
145
src/workers-list.js
Normal file
@ -0,0 +1,145 @@
|
||||
const intersection = require('lodash/intersection')
|
||||
const {masterConfig} = require('./config')
|
||||
const {getLogger} = require('./logger')
|
||||
const {RequestMessage} = require('./server')
|
||||
const throttle = require('lodash/throttle')
|
||||
|
||||
class WorkersList {
|
||||
|
||||
constructor() {
|
||||
/**
|
||||
* @type {{connection: Connection, targets: string[]}[]}
|
||||
*/
|
||||
this.workers = []
|
||||
|
||||
/**
|
||||
* @type {object.<string, boolean>}
|
||||
*/
|
||||
this.targetsToPoke = {}
|
||||
|
||||
/**
|
||||
* @type {object.<string, boolean>}
|
||||
*/
|
||||
this.targetsWaitingToPoke = {}
|
||||
|
||||
/**
|
||||
* @type {NodeJS.Timeout}
|
||||
*/
|
||||
this.pingInterval = setInterval(this.sendPings, masterConfig.ping_interval * 1000)
|
||||
|
||||
/**
|
||||
* @type {Logger}
|
||||
*/
|
||||
this.logger = getLogger('WorkersList')
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Connection} connection
|
||||
* @param {string[]} targets
|
||||
*/
|
||||
add(connection, targets) {
|
||||
this.logger.info(`add: connection from ${connection.remoteAddr()}, targets ${JSON.stringify(targets)}`)
|
||||
|
||||
this.workers.push({connection, targets})
|
||||
connection.on('close', () => {
|
||||
this.logger.info(`connection from ${connection.remoteAddr()} closed, removing worker`)
|
||||
this.workers = this.workers.filter(worker => {
|
||||
return worker.connection !== connection
|
||||
})
|
||||
})
|
||||
|
||||
let waiting = Object.keys(this.targetsWaitingToPoke)
|
||||
if (!waiting.length)
|
||||
return
|
||||
|
||||
let intrs = intersection(waiting, targets)
|
||||
if (intrs.length) {
|
||||
this.logger.info('add: found intersection with waiting targets:', intrs, 'going to poke new worker')
|
||||
this._pokeWorkerConnection(connection, intrs)
|
||||
for (let target of intrs)
|
||||
delete this.targetsWaitingToPoke[target]
|
||||
this.logger.trace(`add: this.targetsWaitingToPoke:`, this.targetsWaitingToPoke)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string[]} targets
|
||||
*/
|
||||
poke(targets) {
|
||||
this.logger.debug('poke:', targets)
|
||||
if (!Array.isArray(targets))
|
||||
throw new Error('targets must be Array')
|
||||
|
||||
for (let t of targets)
|
||||
this.targetsToPoke[t] = true
|
||||
|
||||
this._pokeWorkers()
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_pokeWorkers = throttle(() => {
|
||||
const targets = Object.keys(this.targetsToPoke)
|
||||
this.targetsToPoke = {}
|
||||
|
||||
const found = {}
|
||||
for (const worker of this.workers) {
|
||||
const intrs = intersection(worker.targets, targets)
|
||||
intrs.forEach(t => {
|
||||
found[t] = true
|
||||
})
|
||||
if (intrs.length > 0)
|
||||
this._pokeWorkerConnection(worker.connection, targets)
|
||||
}
|
||||
|
||||
for (let target of targets) {
|
||||
if (!(target in found)) {
|
||||
this.logger.debug(`_pokeWorkers: worker responsible for ${target} not found. we'll remember it`)
|
||||
this.targetsWaitingToPoke[target] = true
|
||||
}
|
||||
this.logger.trace('_pokeWorkers: this.targetsWaitingToPoke:', this.targetsWaitingToPoke)
|
||||
}
|
||||
}, masterConfig.poke_throttle_interval * 1000, {leading: true})
|
||||
|
||||
/**
|
||||
* @param {Connection} connection
|
||||
* @param {string[]} targets
|
||||
* @private
|
||||
*/
|
||||
_pokeWorkerConnection(connection, targets) {
|
||||
this.logger.debug('_pokeWorkerConnection:', connection.remoteAddr(), targets)
|
||||
connection.send(
|
||||
new RequestMessage('poll', {
|
||||
targets
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {{targets: string[], remoteAddr: string, remotePort: number}[]}
|
||||
*/
|
||||
getInfo() {
|
||||
return this.workers.map(worker => {
|
||||
return {
|
||||
remoteAddr: worker.connection.socket?.remoteAddress,
|
||||
remotePort: worker.connection.socket?.remotePort,
|
||||
targets: worker.targets
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
sendPings = () => {
|
||||
this.workers
|
||||
.forEach(w => {
|
||||
this.logger.trace(`sending ping to ${w.connection.remoteAddr()}`)
|
||||
w.connection.send(new RequestMessage('ping'))
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = WorkersList
|
Loading…
x
Reference in New Issue
Block a user