feat: admin scheduler + worker

parent 24ddab73
...@@ -35,6 +35,7 @@ defaults: ...@@ -35,6 +35,7 @@ defaults:
scheduledCheck: 300 scheduledCheck: 300
maxRetries: 5 maxRetries: 5
retryBackoff: 60 retryBackoff: 60
historyExpiration: 90000
# DB defaults # DB defaults
api: api:
isEnabled: false isEnabled: false
......
...@@ -9,7 +9,7 @@ module.exports = { ...@@ -9,7 +9,7 @@ module.exports = {
/** /**
* Load root config from disk * Load root config from disk
*/ */
init() { init(silent = false) {
let confPaths = { let confPaths = {
config: path.join(WIKI.ROOTPATH, 'config.yml'), config: path.join(WIKI.ROOTPATH, 'config.yml'),
data: path.join(WIKI.SERVERPATH, 'app/data.yml'), data: path.join(WIKI.SERVERPATH, 'app/data.yml'),
...@@ -24,7 +24,9 @@ module.exports = { ...@@ -24,7 +24,9 @@ module.exports = {
confPaths.config = path.resolve(WIKI.ROOTPATH, process.env.CONFIG_FILE) confPaths.config = path.resolve(WIKI.ROOTPATH, process.env.CONFIG_FILE)
} }
process.stdout.write(chalk.blue(`Loading configuration from ${confPaths.config}... `)) if (!silent) {
process.stdout.write(chalk.blue(`Loading configuration from ${confPaths.config}... `))
}
let appconfig = {} let appconfig = {}
let appdata = {} let appdata = {}
...@@ -37,7 +39,9 @@ module.exports = { ...@@ -37,7 +39,9 @@ module.exports = {
) )
appdata = yaml.load(fs.readFileSync(confPaths.data, 'utf8')) appdata = yaml.load(fs.readFileSync(confPaths.data, 'utf8'))
appdata.regex = require(confPaths.dataRegex) appdata.regex = require(confPaths.dataRegex)
console.info(chalk.green.bold(`OK`)) if (!silent) {
console.info(chalk.green.bold(`OK`))
}
} catch (err) { } catch (err) {
console.error(chalk.red.bold(`FAILED`)) console.error(chalk.red.bold(`FAILED`))
console.error(err.message) console.error(err.message)
...@@ -66,7 +70,9 @@ module.exports = { ...@@ -66,7 +70,9 @@ module.exports = {
// Load DB Password from Docker Secret File // Load DB Password from Docker Secret File
if (process.env.DB_PASS_FILE) { if (process.env.DB_PASS_FILE) {
console.info(chalk.blue(`DB_PASS_FILE is defined. Will use secret from file.`)) if (!silent) {
console.info(chalk.blue(`DB_PASS_FILE is defined. Will use secret from file.`))
}
try { try {
appconfig.db.pass = fs.readFileSync(process.env.DB_PASS_FILE, 'utf8').trim() appconfig.db.pass = fs.readFileSync(process.env.DB_PASS_FILE, 'utf8').trim()
} catch (err) { } catch (err) {
......
...@@ -4,6 +4,7 @@ const path = require('path') ...@@ -4,6 +4,7 @@ const path = require('path')
const Knex = require('knex') const Knex = require('knex')
const fs = require('fs') const fs = require('fs')
const Objection = require('objection') const Objection = require('objection')
const PGPubSub = require('pg-pubsub')
const migrationSource = require('../db/migrator-source') const migrationSource = require('../db/migrator-source')
const migrateFromLegacy = require('../db/legacy') const migrateFromLegacy = require('../db/legacy')
...@@ -87,7 +88,7 @@ module.exports = { ...@@ -87,7 +88,7 @@ module.exports = {
...WIKI.config.pool, ...WIKI.config.pool,
async afterCreate(conn, done) { async afterCreate(conn, done) {
// -> Set Connection App Name // -> Set Connection App Name
await conn.query(`set application_name = 'Wiki.js'`) await conn.query(`set application_name = 'Wiki.js - ${WIKI.INSTANCE_ID}:MAIN'`)
done() done()
} }
}, },
...@@ -159,9 +160,18 @@ module.exports = { ...@@ -159,9 +160,18 @@ module.exports = {
* Subscribe to database LISTEN / NOTIFY for multi-instances events * Subscribe to database LISTEN / NOTIFY for multi-instances events
*/ */
async subscribeToNotifications () { async subscribeToNotifications () {
const PGPubSub = require('pg-pubsub') let connSettings = this.knex.client.connectionSettings
if (typeof connSettings === 'string') {
this.listener = new PGPubSub(this.knex.client.connectionSettings, { const encodedName = encodeURIComponent(`Wiki.js - ${WIKI.INSTANCE_ID}:PSUB`)
if (connSettings.indexOf('?') > 0) {
connSettings = `${connSettings}&ApplicationName=${encodedName}`
} else {
connSettings = `${connSettings}?ApplicationName=${encodedName}`
}
} else {
connSettings.application_name = `Wiki.js - ${WIKI.INSTANCE_ID}:PSUB`
}
this.listener = new PGPubSub(connSettings, {
log (ev) { log (ev) {
WIKI.logger.debug(ev) WIKI.logger.debug(ev)
} }
......
...@@ -13,7 +13,8 @@ module.exports = { ...@@ -13,7 +13,8 @@ module.exports = {
scheduledRef: null, scheduledRef: null,
tasks: null, tasks: null,
async init () { async init () {
this.maxWorkers = WIKI.config.scheduler.workers === 'auto' ? os.cpus().length : WIKI.config.scheduler.workers this.maxWorkers = WIKI.config.scheduler.workers === 'auto' ? (os.cpus().length - 1) : WIKI.config.scheduler.workers
if (this.maxWorkers < 1) { this.maxWorkers = 1 }
WIKI.logger.info(`Initializing Worker Pool (Limit: ${this.maxWorkers})...`) WIKI.logger.info(`Initializing Worker Pool (Limit: ${this.maxWorkers})...`)
this.workerPool = new DynamicThreadPool(1, this.maxWorkers, './server/worker.js', { this.workerPool = new DynamicThreadPool(1, this.maxWorkers, './server/worker.js', {
errorHandler: (err) => WIKI.logger.warn(err), errorHandler: (err) => WIKI.logger.warn(err),
...@@ -77,80 +78,87 @@ module.exports = { ...@@ -77,80 +78,87 @@ module.exports = {
} }
}, },
async processJob () { async processJob () {
let jobId = null let jobIds = []
try { try {
const availableWorkers = this.maxWorkers - this.activeWorkers
if (availableWorkers < 1) {
WIKI.logger.debug('All workers are busy. Cannot process more jobs at the moment.')
return
}
await WIKI.db.knex.transaction(async trx => { await WIKI.db.knex.transaction(async trx => {
const jobs = await trx('jobs') const jobs = await trx('jobs')
.where('id', WIKI.db.knex.raw('(SELECT id FROM jobs WHERE ("waitUntil" IS NULL OR "waitUntil" <= NOW()) ORDER BY id FOR UPDATE SKIP LOCKED LIMIT 1)')) .whereIn('id', WIKI.db.knex.raw(`(SELECT id FROM jobs WHERE ("waitUntil" IS NULL OR "waitUntil" <= NOW()) ORDER BY id FOR UPDATE SKIP LOCKED LIMIT ${availableWorkers})`))
.returning('*') .returning('*')
.del() .del()
if (jobs && jobs.length === 1) { if (jobs && jobs.length > 0) {
const job = jobs[0] for (const job of jobs) {
WIKI.logger.info(`Processing new job ${job.id}: ${job.task}...`) WIKI.logger.info(`Processing new job ${job.id}: ${job.task}...`)
jobId = job.id // -> Add to Job History
// -> Add to Job History await WIKI.db.knex('jobHistory').insert({
await WIKI.db.knex('jobHistory').insert({ id: job.id,
id: job.id, task: job.task,
task: job.task, state: 'active',
state: 'active', useWorker: job.useWorker,
useWorker: job.useWorker, wasScheduled: job.isScheduled,
wasScheduled: job.isScheduled, payload: job.payload,
payload: job.payload, attempt: job.retries + 1,
attempt: job.retries + 1, maxRetries: job.maxRetries,
maxRetries: job.maxRetries, executedBy: WIKI.INSTANCE_ID,
createdAt: job.createdAt createdAt: job.createdAt
}).onConflict('id').merge({ }).onConflict('id').merge({
startedAt: new Date() executedBy: WIKI.INSTANCE_ID,
}) startedAt: new Date()
// -> Start working on it
try {
if (job.useWorker) {
await this.workerPool.execute({
id: job.id,
name: job.task,
data: job.payload
})
} else {
await this.tasks[job.task](job.payload)
}
// -> Update job history (success)
await WIKI.db.knex('jobHistory').where({
id: job.id
}).update({
state: 'completed',
completedAt: new Date()
})
WIKI.logger.info(`Completed job ${job.id}: ${job.task} [ SUCCESS ]`)
} catch (err) {
WIKI.logger.warn(`Failed to complete job ${job.id}: ${job.task} [ FAILED ]`)
WIKI.logger.warn(err)
// -> Update job history (fail)
await WIKI.db.knex('jobHistory').where({
id: job.id
}).update({
state: 'failed',
lastErrorMessage: err.message
}) })
// -> Reschedule for retry jobIds.push(job.id)
if (job.retries < job.maxRetries) {
const backoffDelay = (2 ** job.retries) * WIKI.config.scheduler.retryBackoff // -> Start working on it
await trx('jobs').insert({ try {
...job, if (job.useWorker) {
retries: job.retries + 1, await this.workerPool.execute({
waitUntil: DateTime.utc().plus({ seconds: backoffDelay }).toJSDate(), ...job,
updatedAt: new Date() INSTANCE_ID: `${WIKI.INSTANCE_ID}:WKR`
})
} else {
await this.tasks[job.task](job.payload)
}
// -> Update job history (success)
await WIKI.db.knex('jobHistory').where({
id: job.id
}).update({
state: 'completed',
completedAt: new Date()
})
WIKI.logger.info(`Completed job ${job.id}: ${job.task}`)
} catch (err) {
WIKI.logger.warn(`Failed to complete job ${job.id}: ${job.task} [ FAILED ]`)
WIKI.logger.warn(err)
// -> Update job history (fail)
await WIKI.db.knex('jobHistory').where({
id: job.id
}).update({
state: 'failed',
lastErrorMessage: err.message
}) })
WIKI.logger.warn(`Rescheduling new attempt for job ${job.id}: ${job.task}...`) // -> Reschedule for retry
if (job.retries < job.maxRetries) {
const backoffDelay = (2 ** job.retries) * WIKI.config.scheduler.retryBackoff
await trx('jobs').insert({
...job,
retries: job.retries + 1,
waitUntil: DateTime.utc().plus({ seconds: backoffDelay }).toJSDate(),
updatedAt: new Date()
})
WIKI.logger.warn(`Rescheduling new attempt for job ${job.id}: ${job.task}...`)
}
} }
} }
} }
}) })
} catch (err) { } catch (err) {
WIKI.logger.warn(err) WIKI.logger.warn(err)
if (jobId) { if (jobIds && jobIds.length > 0) {
WIKI.db.knex('jobHistory').where({ WIKI.db.knex('jobHistory').whereIn('id', jobIds).update({
id: jobId
}).update({
state: 'interrupted', state: 'interrupted',
lastErrorMessage: err.message lastErrorMessage: err.message
}) })
...@@ -181,6 +189,7 @@ module.exports = { ...@@ -181,6 +189,7 @@ module.exports = {
if (scheduledJobs?.length > 0) { if (scheduledJobs?.length > 0) {
// -> Get existing scheduled jobs // -> Get existing scheduled jobs
const existingJobs = await WIKI.db.knex('jobs').where('isScheduled', true) const existingJobs = await WIKI.db.knex('jobs').where('isScheduled', true)
let totalAdded = 0
for (const job of scheduledJobs) { for (const job of scheduledJobs) {
// -> Get next planned iterations // -> Get next planned iterations
const plannedIterations = cronparser.parseExpression(job.cron, { const plannedIterations = cronparser.parseExpression(job.cron, {
...@@ -205,6 +214,7 @@ module.exports = { ...@@ -205,6 +214,7 @@ module.exports = {
notify: false notify: false
}) })
addedFutureJobs++ addedFutureJobs++
totalAdded++
} }
// -> No more iterations for this period or max iterations count reached // -> No more iterations for this period or max iterations count reached
if (next.done || addedFutureJobs >= 10) { break } if (next.done || addedFutureJobs >= 10) { break }
...@@ -213,6 +223,11 @@ module.exports = { ...@@ -213,6 +223,11 @@ module.exports = {
} }
} }
} }
if (totalAdded > 0) {
WIKI.logger.info(`Scheduled ${totalAdded} new future planned jobs: [ OK ]`)
} else {
WIKI.logger.info(`No new future planned jobs to schedule: [ OK ]`)
}
} }
} }
}) })
......
...@@ -132,6 +132,7 @@ exports.up = async knex => { ...@@ -132,6 +132,7 @@ exports.up = async knex => {
table.integer('attempt').notNullable().defaultTo(1) table.integer('attempt').notNullable().defaultTo(1)
table.integer('maxRetries').notNullable().defaultTo(0) table.integer('maxRetries').notNullable().defaultTo(0)
table.text('lastErrorMessage') table.text('lastErrorMessage')
table.string('executedBy')
table.timestamp('createdAt').notNullable() table.timestamp('createdAt').notNullable()
table.timestamp('startedAt').notNullable().defaultTo(knex.fn.now()) table.timestamp('startedAt').notNullable().defaultTo(knex.fn.now())
table.timestamp('completedAt') table.timestamp('completedAt')
...@@ -684,12 +685,17 @@ exports.up = async knex => { ...@@ -684,12 +685,17 @@ exports.up = async knex => {
await knex('jobSchedule').insert([ await knex('jobSchedule').insert([
{ {
task: 'updateLocales', task: 'checkVersion',
cron: '0 0 * * *', cron: '0 0 * * *',
type: 'system' type: 'system'
}, },
{ {
task: 'checkVersion', task: 'cleanJobHistory',
cron: '5 0 * * *',
type: 'system'
},
{
task: 'updateLocales',
cron: '0 0 * * *', cron: '0 0 * * *',
type: 'system' type: 'system'
} }
......
...@@ -27,25 +27,13 @@ module.exports = { ...@@ -27,25 +27,13 @@ module.exports = {
return WIKI.config.security return WIKI.config.security
}, },
async systemJobs (obj, args) { async systemJobs (obj, args) {
switch (args.state) { const results = args.states?.length > 0 ?
case 'ACTIVE': { await WIKI.db.knex('jobHistory').whereIn('state', args.states.map(s => s.toLowerCase())).orderBy('startedAt') :
// const result = await WIKI.scheduler.boss.fetch('*', 25, { includeMeta: true }) await WIKI.db.knex('jobHistory').orderBy('startedAt')
return [] return results.map(r => ({
} ...r,
case 'COMPLETED': { state: r.state.toUpperCase()
return [] }))
}
case 'FAILED': {
return []
}
case 'INTERRUPTED': {
return []
}
default: {
WIKI.logger.warn('Invalid Job State requested.')
return []
}
}
}, },
async systemJobsScheduled (obj, args) { async systemJobsScheduled (obj, args) {
return WIKI.db.knex('jobSchedule').orderBy('task') return WIKI.db.knex('jobSchedule').orderBy('task')
......
...@@ -8,7 +8,7 @@ extend type Query { ...@@ -8,7 +8,7 @@ extend type Query {
systemInfo: SystemInfo systemInfo: SystemInfo
systemSecurity: SystemSecurity systemSecurity: SystemSecurity
systemJobs( systemJobs(
state: SystemJobState states: [SystemJobState]
): [SystemJob] ): [SystemJob]
systemJobsScheduled: [SystemJobScheduled] systemJobsScheduled: [SystemJobScheduled]
systemJobsUpcoming: [SystemJobUpcoming] systemJobsUpcoming: [SystemJobUpcoming]
...@@ -159,6 +159,7 @@ type SystemJob { ...@@ -159,6 +159,7 @@ type SystemJob {
attempt: Int attempt: Int
maxRetries: Int maxRetries: Int
lastErrorMessage: String lastErrorMessage: String
executedBy: String
createdAt: Date createdAt: Date
startedAt: Date startedAt: Date
completedAt: Date completedAt: Date
......
const { DateTime } = require('luxon')
module.exports = async (payload) => {
WIKI.logger.info('Cleaning scheduler job history...')
try {
await WIKI.db.knex('jobHistory')
.whereNot('state', 'active')
.andWhere('startedAt', '<=', DateTime.utc().minus({ seconds: WIKI.config.scheduler.historyExpiration }).toISO())
.del()
WIKI.logger.info('Cleaned scheduler job history: [ COMPLETED ]')
} catch (err) {
WIKI.logger.error('Cleaning scheduler job history: [ FAILED ]')
WIKI.logger.error(err.message)
}
}
...@@ -2,8 +2,8 @@ const path = require('node:path') ...@@ -2,8 +2,8 @@ const path = require('node:path')
const fs = require('fs-extra') const fs = require('fs-extra')
const { DateTime } = require('luxon') const { DateTime } = require('luxon')
module.exports = async (payload, helpers) => { module.exports = async ({ payload }) => {
helpers.logger.info('Purging orphaned upload files...') WIKI.logger.info('Purging orphaned upload files...')
try { try {
const uplTempPath = path.resolve(WIKI.ROOTPATH, WIKI.config.dataPath, 'uploads') const uplTempPath = path.resolve(WIKI.ROOTPATH, WIKI.config.dataPath, 'uploads')
...@@ -18,9 +18,9 @@ module.exports = async (payload, helpers) => { ...@@ -18,9 +18,9 @@ module.exports = async (payload, helpers) => {
} }
} }
helpers.logger.info('Purging orphaned upload files: [ COMPLETED ]') WIKI.logger.info('Purging orphaned upload files: [ COMPLETED ]')
} catch (err) { } catch (err) {
helpers.logger.error('Purging orphaned upload files: [ FAILED ]') WIKI.logger.error('Purging orphaned upload files: [ FAILED ]')
helpers.logger.error(err.message) WIKI.logger.error(err.message)
} }
} }
const { ThreadWorker } = require('poolifier') const { ThreadWorker } = require('poolifier')
const { kebabCase } = require('lodash')
const path = require('node:path')
// ----------------------------------------
// Init Minimal Core
// ----------------------------------------
let WIKI = {
IS_DEBUG: process.env.NODE_ENV === 'development',
ROOTPATH: process.cwd(),
INSTANCE_ID: 'worker',
SERVERPATH: path.join(process.cwd(), 'server'),
Error: require('./helpers/error'),
configSvc: require('./core/config')
}
global.WIKI = WIKI
WIKI.configSvc.init(true)
WIKI.logger = require('./core/logger').init()
// ----------------------------------------
// Execute Task
// ----------------------------------------
module.exports = new ThreadWorker(async (job) => { module.exports = new ThreadWorker(async (job) => {
// TODO: Call external task file WIKI.INSTANCE_ID = job.INSTANCE_ID
return { ok: true } const task = require(`./tasks/workers/${kebabCase(job.task)}.js`)
await task(job)
return true
}, { async: true }) }, { async: true })
...@@ -1523,7 +1523,16 @@ ...@@ -1523,7 +1523,16 @@
"admin.scheduler.updatedAt": "Last Updated", "admin.scheduler.updatedAt": "Last Updated",
"common.field.task": "Task", "common.field.task": "Task",
"admin.scheduler.upcomingNone": "There are no upcoming job for the moment.", "admin.scheduler.upcomingNone": "There are no upcoming job for the moment.",
"admin.scheduler.failedNone": "There are no recently failed job to display.",
"admin.scheduler.waitUntil": "Start", "admin.scheduler.waitUntil": "Start",
"admin.scheduler.attempt": "Attempt", "admin.scheduler.attempt": "Attempt",
"admin.scheduler.useWorker": "Execution Mode" "admin.scheduler.useWorker": "Execution Mode",
"admin.scheduler.schedule": "Schedule",
"admin.scheduler.state": "State",
"admin.scheduler.startedAt": "Started",
"admin.scheduler.result": "Result",
"admin.scheduler.completedIn": "Completed in {duration}",
"admin.scheduler.pending": "Pending",
"admin.scheduler.error": "Error",
"admin.scheduler.interrupted": "Interrupted"
} }
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment