chore(xo-server): pluggable job executors (#2707)
This commit is contained in:
parent
dccddd78a6
commit
488eed046e
@ -1,190 +0,0 @@
|
|||||||
import { BaseError } from 'make-error'
|
|
||||||
import { createPredicate } from 'value-matcher'
|
|
||||||
import { timeout } from 'promise-toolbox'
|
|
||||||
import { assign, filter, find, isEmpty, map, mapValues } from 'lodash'
|
|
||||||
|
|
||||||
import { crossProduct } from './math'
|
|
||||||
import { asyncMap, serializeError, thunkToArray } from './utils'
|
|
||||||
|
|
||||||
export class JobExecutorError extends BaseError {}
|
|
||||||
export class UnsupportedJobType extends JobExecutorError {
|
|
||||||
constructor (job) {
|
|
||||||
super('Unknown job type: ' + job.type)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
export class UnsupportedVectorType extends JobExecutorError {
|
|
||||||
constructor (vector) {
|
|
||||||
super('Unknown vector type: ' + vector.type)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===================================================================
|
|
||||||
|
|
||||||
const paramsVectorActionsMap = {
|
|
||||||
extractProperties ({ mapping, value }) {
|
|
||||||
return mapValues(mapping, key => value[key])
|
|
||||||
},
|
|
||||||
crossProduct ({ items }) {
|
|
||||||
return thunkToArray(
|
|
||||||
crossProduct(map(items, value => resolveParamsVector.call(this, value)))
|
|
||||||
)
|
|
||||||
},
|
|
||||||
fetchObjects ({ pattern }) {
|
|
||||||
const objects = filter(this.xo.getObjects(), createPredicate(pattern))
|
|
||||||
if (isEmpty(objects)) {
|
|
||||||
throw new Error('no objects match this pattern')
|
|
||||||
}
|
|
||||||
return objects
|
|
||||||
},
|
|
||||||
map ({ collection, iteratee, paramName = 'value' }) {
|
|
||||||
return map(resolveParamsVector.call(this, collection), value => {
|
|
||||||
return resolveParamsVector.call(this, {
|
|
||||||
...iteratee,
|
|
||||||
[paramName]: value,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
},
|
|
||||||
set: ({ values }) => values,
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveParamsVector (paramsVector) {
|
|
||||||
const visitor = paramsVectorActionsMap[paramsVector.type]
|
|
||||||
if (!visitor) {
|
|
||||||
throw new Error(`Unsupported function '${paramsVector.type}'.`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return visitor.call(this, paramsVector)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===================================================================
|
|
||||||
|
|
||||||
export default class JobExecutor {
|
|
||||||
constructor (xo) {
|
|
||||||
this.xo = xo
|
|
||||||
|
|
||||||
// The logger is not available until Xo has started.
|
|
||||||
xo.on('start', () =>
|
|
||||||
xo.getLogger('jobs').then(logger => {
|
|
||||||
this._logger = logger
|
|
||||||
})
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
async exec (job, onStart, extraParams) {
|
|
||||||
const runJobId = this._logger.notice(`Starting execution of ${job.id}.`, {
|
|
||||||
event: 'job.start',
|
|
||||||
userId: job.userId,
|
|
||||||
jobId: job.id,
|
|
||||||
key: job.key,
|
|
||||||
})
|
|
||||||
if (onStart !== undefined) {
|
|
||||||
onStart(runJobId)
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (job.type === 'call') {
|
|
||||||
const execStatus = await this._execCall(job, runJobId, extraParams)
|
|
||||||
|
|
||||||
this.xo.emit('job:terminated', execStatus)
|
|
||||||
} else {
|
|
||||||
throw new UnsupportedJobType(job)
|
|
||||||
}
|
|
||||||
|
|
||||||
this._logger.notice(`Execution terminated for ${job.id}.`, {
|
|
||||||
event: 'job.end',
|
|
||||||
runJobId,
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
this._logger.error(`The execution of ${job.id} has failed.`, {
|
|
||||||
event: 'job.end',
|
|
||||||
runJobId,
|
|
||||||
error: serializeError(error),
|
|
||||||
})
|
|
||||||
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async _execCall (job, runJobId, extraParams) {
|
|
||||||
const { paramsVector } = job
|
|
||||||
const paramsFlatVector = paramsVector
|
|
||||||
? resolveParamsVector.call(this, paramsVector)
|
|
||||||
: [{}] // One call with no parameters
|
|
||||||
|
|
||||||
const connection = this.xo.createUserConnection()
|
|
||||||
|
|
||||||
connection.set('user_id', job.userId)
|
|
||||||
|
|
||||||
const schedule = find(await this.xo.getAllSchedules(), { jobId: job.id })
|
|
||||||
|
|
||||||
const execStatus = {
|
|
||||||
calls: {},
|
|
||||||
runJobId,
|
|
||||||
start: Date.now(),
|
|
||||||
timezone: schedule !== undefined ? schedule.timezone : undefined,
|
|
||||||
}
|
|
||||||
|
|
||||||
await asyncMap(paramsFlatVector, params => {
|
|
||||||
Object.assign(params, extraParams)
|
|
||||||
const runCallId = this._logger.notice(
|
|
||||||
`Starting ${job.method} call. (${job.id})`,
|
|
||||||
{
|
|
||||||
event: 'jobCall.start',
|
|
||||||
runJobId,
|
|
||||||
method: job.method,
|
|
||||||
params,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
const call = (execStatus.calls[runCallId] = {
|
|
||||||
method: job.method,
|
|
||||||
params,
|
|
||||||
start: Date.now(),
|
|
||||||
})
|
|
||||||
let promise = this.xo.callApiMethod(
|
|
||||||
connection,
|
|
||||||
job.method,
|
|
||||||
assign({}, params)
|
|
||||||
)
|
|
||||||
if (job.timeout) {
|
|
||||||
promise = promise::timeout(job.timeout)
|
|
||||||
}
|
|
||||||
|
|
||||||
return promise.then(
|
|
||||||
value => {
|
|
||||||
this._logger.notice(
|
|
||||||
`Call ${job.method} (${runCallId}) is a success. (${job.id})`,
|
|
||||||
{
|
|
||||||
event: 'jobCall.end',
|
|
||||||
runJobId,
|
|
||||||
runCallId,
|
|
||||||
returnedValue: value,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
call.returnedValue = value
|
|
||||||
call.end = Date.now()
|
|
||||||
},
|
|
||||||
reason => {
|
|
||||||
this._logger.notice(
|
|
||||||
`Call ${job.method} (${runCallId}) has failed. (${job.id})`,
|
|
||||||
{
|
|
||||||
event: 'jobCall.end',
|
|
||||||
runJobId,
|
|
||||||
runCallId,
|
|
||||||
error: serializeError(reason),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
call.error = reason
|
|
||||||
call.end = Date.now()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
connection.close()
|
|
||||||
execStatus.end = Date.now()
|
|
||||||
|
|
||||||
return execStatus
|
|
||||||
}
|
|
||||||
}
|
|
127
packages/xo-server/src/xo-mixins/jobs/execute-call.js
Normal file
127
packages/xo-server/src/xo-mixins/jobs/execute-call.js
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
import { createPredicate } from 'value-matcher'
|
||||||
|
import { timeout } from 'promise-toolbox'
|
||||||
|
import { assign, filter, find, isEmpty, map, mapValues } from 'lodash'
|
||||||
|
|
||||||
|
import { crossProduct } from '../../math'
|
||||||
|
import { asyncMap, serializeError, thunkToArray } from '../../utils'
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
const paramsVectorActionsMap = {
|
||||||
|
extractProperties ({ mapping, value }) {
|
||||||
|
return mapValues(mapping, key => value[key])
|
||||||
|
},
|
||||||
|
crossProduct ({ items }) {
|
||||||
|
return thunkToArray(
|
||||||
|
crossProduct(map(items, value => resolveParamsVector.call(this, value)))
|
||||||
|
)
|
||||||
|
},
|
||||||
|
fetchObjects ({ pattern }) {
|
||||||
|
const objects = filter(this.getObjects(), createPredicate(pattern))
|
||||||
|
if (isEmpty(objects)) {
|
||||||
|
throw new Error('no objects match this pattern')
|
||||||
|
}
|
||||||
|
return objects
|
||||||
|
},
|
||||||
|
map ({ collection, iteratee, paramName = 'value' }) {
|
||||||
|
return map(resolveParamsVector.call(this, collection), value => {
|
||||||
|
return resolveParamsVector.call(this, {
|
||||||
|
...iteratee,
|
||||||
|
[paramName]: value,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
set: ({ values }) => values,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveParamsVector (paramsVector) {
|
||||||
|
const visitor = paramsVectorActionsMap[paramsVector.type]
|
||||||
|
if (!visitor) {
|
||||||
|
throw new Error(`Unsupported function '${paramsVector.type}'.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return visitor.call(this, paramsVector)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
export default async function executeJobCall ({
|
||||||
|
app,
|
||||||
|
data,
|
||||||
|
job,
|
||||||
|
logger,
|
||||||
|
runJobId,
|
||||||
|
session,
|
||||||
|
}) {
|
||||||
|
const { paramsVector } = job
|
||||||
|
const paramsFlatVector = paramsVector
|
||||||
|
? resolveParamsVector.call(app, paramsVector)
|
||||||
|
: [{}] // One call with no parameters
|
||||||
|
|
||||||
|
const schedule = find(await app.getAllSchedules(), { jobId: job.id })
|
||||||
|
|
||||||
|
const execStatus = {
|
||||||
|
calls: {},
|
||||||
|
runJobId,
|
||||||
|
start: Date.now(),
|
||||||
|
timezone: schedule !== undefined ? schedule.timezone : undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
await asyncMap(paramsFlatVector, params => {
|
||||||
|
Object.assign(params, data)
|
||||||
|
const runCallId = logger.notice(
|
||||||
|
`Starting ${job.method} call. (${job.id})`,
|
||||||
|
{
|
||||||
|
event: 'jobCall.start',
|
||||||
|
runJobId,
|
||||||
|
method: job.method,
|
||||||
|
params,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const call = (execStatus.calls[runCallId] = {
|
||||||
|
method: job.method,
|
||||||
|
params,
|
||||||
|
start: Date.now(),
|
||||||
|
})
|
||||||
|
let promise = app.callApiMethod(session, job.method, assign({}, params))
|
||||||
|
if (job.timeout) {
|
||||||
|
promise = promise::timeout(job.timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
return promise.then(
|
||||||
|
value => {
|
||||||
|
logger.notice(
|
||||||
|
`Call ${job.method} (${runCallId}) is a success. (${job.id})`,
|
||||||
|
{
|
||||||
|
event: 'jobCall.end',
|
||||||
|
runJobId,
|
||||||
|
runCallId,
|
||||||
|
returnedValue: value,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
call.returnedValue = value
|
||||||
|
call.end = Date.now()
|
||||||
|
},
|
||||||
|
reason => {
|
||||||
|
logger.notice(
|
||||||
|
`Call ${job.method} (${runCallId}) has failed. (${job.id})`,
|
||||||
|
{
|
||||||
|
event: 'jobCall.end',
|
||||||
|
runJobId,
|
||||||
|
runCallId,
|
||||||
|
error: serializeError(reason),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
call.error = reason
|
||||||
|
call.end = Date.now()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
execStatus.end = Date.now()
|
||||||
|
|
||||||
|
return execStatus
|
||||||
|
}
|
@ -1,7 +1,7 @@
|
|||||||
/* eslint-env jest */
|
/* eslint-env jest */
|
||||||
|
|
||||||
import { forEach } from 'lodash'
|
import { forEach } from 'lodash'
|
||||||
import { resolveParamsVector } from './job-executor'
|
import { resolveParamsVector } from './execute-call'
|
||||||
|
|
||||||
describe('resolveParamsVector', function () {
|
describe('resolveParamsVector', function () {
|
||||||
forEach(
|
forEach(
|
||||||
@ -68,7 +68,6 @@ describe('resolveParamsVector', function () {
|
|||||||
|
|
||||||
// Context.
|
// Context.
|
||||||
{
|
{
|
||||||
xo: {
|
|
||||||
getObjects: function () {
|
getObjects: function () {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@ -100,7 +99,6 @@ describe('resolveParamsVector', function () {
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
([expectedResult, entry, context], name) => {
|
([expectedResult, entry, context], name) => {
|
@ -5,12 +5,15 @@ import type { Pattern } from 'value-matcher'
|
|||||||
// $FlowFixMe
|
// $FlowFixMe
|
||||||
import { assign } from 'lodash'
|
import { assign } from 'lodash'
|
||||||
// $FlowFixMe
|
// $FlowFixMe
|
||||||
import { finally as pFinally } from 'promise-toolbox'
|
import { cancelable } from 'promise-toolbox'
|
||||||
import { noSuchObject } from 'xo-common/api-errors'
|
import { noSuchObject } from 'xo-common/api-errors'
|
||||||
|
|
||||||
import JobExecutor from '../job-executor'
|
import { Jobs as JobsDb } from '../../models/job'
|
||||||
import { Jobs as JobsDb } from '../models/job'
|
import { mapToArray, serializeError } from '../../utils'
|
||||||
import { mapToArray } from '../utils'
|
|
||||||
|
import type Logger from '../logs/loggers/abstract'
|
||||||
|
|
||||||
|
import executeCall from './execute-call'
|
||||||
|
|
||||||
// ===================================================================
|
// ===================================================================
|
||||||
|
|
||||||
@ -42,30 +45,48 @@ type ParamsVector =
|
|||||||
export type Job = {
|
export type Job = {
|
||||||
id: string,
|
id: string,
|
||||||
name: string,
|
name: string,
|
||||||
|
type: string,
|
||||||
userId: string
|
userId: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CallJob = Job & {|
|
export type CallJob = {|
|
||||||
|
...$Exact<Job>,
|
||||||
method: string,
|
method: string,
|
||||||
paramsVector: ParamsVector,
|
paramsVector: ParamsVector,
|
||||||
timeout?: number,
|
timeout?: number,
|
||||||
type: 'call'
|
type: 'call'
|
||||||
|}
|
|}
|
||||||
|
|
||||||
|
type Executor = ({|
|
||||||
|
app: Object,
|
||||||
|
cancelToken: any,
|
||||||
|
data: Object,
|
||||||
|
job: Job,
|
||||||
|
logger: Logger,
|
||||||
|
runJobId: string,
|
||||||
|
session: Object
|
||||||
|
|}) => Promise<void>
|
||||||
|
|
||||||
export default class Jobs {
|
export default class Jobs {
|
||||||
_executor: JobExecutor
|
_app: any
|
||||||
|
_executors: { __proto__: null, [string]: Executor }
|
||||||
_jobs: JobsDb
|
_jobs: JobsDb
|
||||||
|
_logger: Logger
|
||||||
_runningJobs: { __proto__: null, [string]: boolean }
|
_runningJobs: { __proto__: null, [string]: boolean }
|
||||||
|
|
||||||
constructor (xo: any) {
|
constructor (xo: any) {
|
||||||
this._executor = new JobExecutor(xo)
|
this._app = xo
|
||||||
|
const executors = (this._executors = Object.create(null))
|
||||||
const jobsDb = (this._jobs = new JobsDb({
|
const jobsDb = (this._jobs = new JobsDb({
|
||||||
connection: xo._redis,
|
connection: xo._redis,
|
||||||
prefix: 'xo:job',
|
prefix: 'xo:job',
|
||||||
indexes: ['user_id', 'key'],
|
indexes: ['user_id', 'key'],
|
||||||
}))
|
}))
|
||||||
|
this._logger = undefined
|
||||||
this._runningJobs = Object.create(null)
|
this._runningJobs = Object.create(null)
|
||||||
|
|
||||||
|
executors.call = executeCall
|
||||||
|
|
||||||
xo.on('clean', () => jobsDb.rebuildIndexes())
|
xo.on('clean', () => jobsDb.rebuildIndexes())
|
||||||
xo.on('start', () => {
|
xo.on('start', () => {
|
||||||
xo.addConfigManager(
|
xo.addConfigManager(
|
||||||
@ -74,6 +95,10 @@ export default class Jobs {
|
|||||||
jobs => Promise.all(mapToArray(jobs, job => jobsDb.save(job))),
|
jobs => Promise.all(mapToArray(jobs, job => jobsDb.save(job))),
|
||||||
['users']
|
['users']
|
||||||
)
|
)
|
||||||
|
|
||||||
|
xo.getLogger('jobs').then(logger => {
|
||||||
|
this._logger = logger
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -116,38 +141,86 @@ export default class Jobs {
|
|||||||
return /* await */ this._jobs.save(job)
|
return /* await */ this._jobs.save(job)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
registerJobExecutor (type: string, executor: Executor): void {
|
||||||
|
const executors = this._executors
|
||||||
|
if (type in executor) {
|
||||||
|
throw new Error(`there is already a job executor for type ${type}`)
|
||||||
|
}
|
||||||
|
executors[type] = executor
|
||||||
|
}
|
||||||
|
|
||||||
async removeJob (id: string) {
|
async removeJob (id: string) {
|
||||||
return /* await */ this._jobs.remove(id)
|
return /* await */ this._jobs.remove(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
_runJob (job: Job, extraParams: {}) {
|
async _runJob (cancelToken: any, job: Job, data: {}) {
|
||||||
const { id } = job
|
const { id } = job
|
||||||
|
|
||||||
const runningJobs = this._runningJobs
|
const runningJobs = this._runningJobs
|
||||||
if (id in runningJobs) {
|
if (id in runningJobs) {
|
||||||
throw new Error(`job ${id} is already running`)
|
throw new Error(`job ${id} is already running`)
|
||||||
}
|
}
|
||||||
runningJobs[id] = true
|
|
||||||
return pFinally.call(
|
const executor = this._executors[job.type]
|
||||||
this._executor.exec(
|
if (executor === undefined) {
|
||||||
job,
|
throw new Error(`cannot run job ${id}: no executor for type ${job.type}`)
|
||||||
runJobId => {
|
|
||||||
runningJobs[id] = runJobId
|
|
||||||
},
|
|
||||||
extraParams
|
|
||||||
),
|
|
||||||
() => {
|
|
||||||
delete runningJobs[id]
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async runJobSequence (idSequence: Array<string>, extraParams: {}) {
|
const logger = this._logger
|
||||||
|
const runJobId = logger.notice(`Starting execution of ${id}.`, {
|
||||||
|
event: 'job.start',
|
||||||
|
userId: job.userId,
|
||||||
|
jobId: id,
|
||||||
|
// $FlowFixMe only defined for CallJob
|
||||||
|
key: job.key,
|
||||||
|
})
|
||||||
|
|
||||||
|
runningJobs[id] = runJobId
|
||||||
|
|
||||||
|
try {
|
||||||
|
const app = this._app
|
||||||
|
const session = app.createUserConnection()
|
||||||
|
session.set('user_id', job.userId)
|
||||||
|
|
||||||
|
const status = await executor({
|
||||||
|
app,
|
||||||
|
cancelToken,
|
||||||
|
data,
|
||||||
|
job,
|
||||||
|
logger,
|
||||||
|
runJobId,
|
||||||
|
session,
|
||||||
|
})
|
||||||
|
logger.notice(`Execution terminated for ${job.id}.`, {
|
||||||
|
event: 'job.end',
|
||||||
|
runJobId,
|
||||||
|
})
|
||||||
|
|
||||||
|
session.close()
|
||||||
|
app.emit('job:terminated', status)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`The execution of ${id} has failed.`, {
|
||||||
|
event: 'job.end',
|
||||||
|
runJobId,
|
||||||
|
error: serializeError(error),
|
||||||
|
})
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
delete runningJobs[id]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@cancelable
|
||||||
|
async runJobSequence ($cancelToken: any, idSequence: Array<string>, data: {}) {
|
||||||
const jobs = await Promise.all(
|
const jobs = await Promise.all(
|
||||||
mapToArray(idSequence, id => this.getJob(id))
|
mapToArray(idSequence, id => this.getJob(id))
|
||||||
)
|
)
|
||||||
|
|
||||||
for (const job of jobs) {
|
for (const job of jobs) {
|
||||||
await this._runJob(job, extraParams)
|
if ($cancelToken.requested) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
await this._runJob($cancelToken, job, data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
Loading…
Reference in New Issue
Block a user