mirror of https://github.com/requarks/wiki
parent
6e8fe2b558
commit
45b5bd5cdc
@ -0,0 +1,296 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify'
|
||||||
|
import { JOB_STATES, type JobState } from '../models/jobs.ts'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scheduler API Routes
|
||||||
|
*/
|
||||||
|
async function routes(app: FastifyInstance) {
|
||||||
|
/**
|
||||||
|
* LIST SCHEDULED TASKS
|
||||||
|
*/
|
||||||
|
app.get(
|
||||||
|
'/schedule',
|
||||||
|
{
|
||||||
|
config: {
|
||||||
|
permissions: ['manage:system']
|
||||||
|
},
|
||||||
|
schema: {
|
||||||
|
summary: 'List the cron schedule',
|
||||||
|
description:
|
||||||
|
'The tasks the scheduler runs automatically. These are definitions, not executions — the jobs they produce show up under upcoming and then in the history.',
|
||||||
|
tags: ['Scheduler'],
|
||||||
|
response: {
|
||||||
|
200: {
|
||||||
|
description: 'List of scheduled tasks',
|
||||||
|
type: 'array',
|
||||||
|
items: { $ref: 'SchedulerTask#' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
return WIKI.models.jobs.getSchedule()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RUN A SCHEDULED TASK NOW
|
||||||
|
*/
|
||||||
|
app.post<{ Params: { scheduleId: string } }>(
|
||||||
|
'/schedule/:scheduleId/run',
|
||||||
|
{
|
||||||
|
config: {
|
||||||
|
permissions: ['manage:system']
|
||||||
|
},
|
||||||
|
schema: {
|
||||||
|
summary: 'Run a scheduled task now',
|
||||||
|
description:
|
||||||
|
'Queues the task immediately, without waiting for its cron expression and without disturbing the planned iterations. The run is recorded in the history like any other job.',
|
||||||
|
tags: ['Scheduler'],
|
||||||
|
params: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
scheduleId: {
|
||||||
|
type: 'string',
|
||||||
|
format: 'uuid'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: ['scheduleId']
|
||||||
|
},
|
||||||
|
response: {
|
||||||
|
200: {
|
||||||
|
description: 'Task queued successfully',
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
ok: {
|
||||||
|
type: 'boolean'
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
type: 'string'
|
||||||
|
},
|
||||||
|
id: {
|
||||||
|
type: 'string',
|
||||||
|
format: 'uuid',
|
||||||
|
description: 'The ID of the queued job.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async (req, reply) => {
|
||||||
|
const entry = await WIKI.models.jobs.getScheduleEntry(req.params.scheduleId)
|
||||||
|
if (!entry) {
|
||||||
|
return reply.notFound('Scheduled task does not exist.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = await WIKI.models.jobs.runScheduledTask(entry)
|
||||||
|
if (!id) {
|
||||||
|
return reply.internalServerError('The scheduler could not queue the job.')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
message: 'Task queued successfully.',
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LIST UPCOMING JOBS
|
||||||
|
*/
|
||||||
|
app.get(
|
||||||
|
'/upcoming',
|
||||||
|
{
|
||||||
|
config: {
|
||||||
|
permissions: ['manage:system']
|
||||||
|
},
|
||||||
|
schema: {
|
||||||
|
summary: 'List the pending job queue',
|
||||||
|
description:
|
||||||
|
'Jobs waiting to be picked up, soonest first. A job with no `waitUntil` is eligible immediately.',
|
||||||
|
tags: ['Scheduler'],
|
||||||
|
response: {
|
||||||
|
200: {
|
||||||
|
description: 'List of upcoming jobs',
|
||||||
|
type: 'array',
|
||||||
|
items: { $ref: 'SchedulerUpcomingJob#' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
return WIKI.models.jobs.getUpcoming()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CANCEL UPCOMING JOB
|
||||||
|
*/
|
||||||
|
app.delete<{ Params: { jobId: string } }>(
|
||||||
|
'/upcoming/:jobId',
|
||||||
|
{
|
||||||
|
config: {
|
||||||
|
permissions: ['manage:system']
|
||||||
|
},
|
||||||
|
schema: {
|
||||||
|
summary: 'Cancel a pending job',
|
||||||
|
description:
|
||||||
|
'Removes the job from the queue. A job that an instance has already picked up cannot be cancelled and answers 404, as it is no longer pending.',
|
||||||
|
tags: ['Scheduler'],
|
||||||
|
params: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
jobId: {
|
||||||
|
type: 'string',
|
||||||
|
format: 'uuid'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: ['jobId']
|
||||||
|
},
|
||||||
|
response: {
|
||||||
|
204: {
|
||||||
|
description: 'Job cancelled successfully'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async (req, reply) => {
|
||||||
|
const cancelled = await WIKI.models.jobs.cancelUpcoming(req.params.jobId)
|
||||||
|
if (!cancelled) {
|
||||||
|
return reply.notFound('No pending job with this ID.')
|
||||||
|
}
|
||||||
|
return reply.code(204).send()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LIST JOB HISTORY
|
||||||
|
*/
|
||||||
|
app.get<{ Querystring: { states?: JobState[]; limit?: number } }>(
|
||||||
|
'/jobs',
|
||||||
|
{
|
||||||
|
config: {
|
||||||
|
permissions: ['manage:system']
|
||||||
|
},
|
||||||
|
schema: {
|
||||||
|
summary: 'List job execution history',
|
||||||
|
description:
|
||||||
|
'Past and running jobs, most recently started first. Older entries are purged by the `cleanJobHistory` task.',
|
||||||
|
tags: ['Scheduler'],
|
||||||
|
querystring: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
states: {
|
||||||
|
type: 'array',
|
||||||
|
description: 'Keep only jobs in these states. All states when omitted.',
|
||||||
|
items: {
|
||||||
|
type: 'string',
|
||||||
|
enum: JOB_STATES
|
||||||
|
}
|
||||||
|
},
|
||||||
|
limit: { type: 'integer', minimum: 1, maximum: 500, default: 100 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
response: {
|
||||||
|
200: {
|
||||||
|
description: 'List of jobs',
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
total: {
|
||||||
|
type: 'integer',
|
||||||
|
description:
|
||||||
|
'How many jobs match the requested states, which can exceed the number returned.'
|
||||||
|
},
|
||||||
|
limit: {
|
||||||
|
type: 'integer'
|
||||||
|
},
|
||||||
|
jobs: {
|
||||||
|
type: 'array',
|
||||||
|
items: { $ref: 'SchedulerJob#' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async (req) => {
|
||||||
|
const limit = req.query.limit ?? 100
|
||||||
|
const { total, jobs } = await WIKI.models.jobs.getHistory({
|
||||||
|
states: req.query.states ?? [],
|
||||||
|
limit
|
||||||
|
})
|
||||||
|
return { total, limit, jobs }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RETRY JOB
|
||||||
|
*/
|
||||||
|
app.post<{ Params: { jobId: string } }>(
|
||||||
|
'/jobs/:jobId/retry',
|
||||||
|
{
|
||||||
|
config: {
|
||||||
|
permissions: ['manage:system']
|
||||||
|
},
|
||||||
|
schema: {
|
||||||
|
summary: 'Run a past job again',
|
||||||
|
description:
|
||||||
|
'Queues a new job with the same task and payload. The original history entry is left as it is, and the new run is recorded separately with a full retry budget.',
|
||||||
|
tags: ['Scheduler'],
|
||||||
|
params: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
jobId: {
|
||||||
|
type: 'string',
|
||||||
|
format: 'uuid'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: ['jobId']
|
||||||
|
},
|
||||||
|
response: {
|
||||||
|
200: {
|
||||||
|
description: 'Job queued successfully',
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
ok: {
|
||||||
|
type: 'boolean'
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
type: 'string'
|
||||||
|
},
|
||||||
|
id: {
|
||||||
|
type: 'string',
|
||||||
|
format: 'uuid',
|
||||||
|
description: 'The ID of the newly queued job, not the one it was created from.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async (req, reply) => {
|
||||||
|
const entry = await WIKI.models.jobs.getHistoryEntry(req.params.jobId)
|
||||||
|
if (!entry) {
|
||||||
|
return reply.notFound('Job does not exist.')
|
||||||
|
}
|
||||||
|
if (entry.state === 'active') {
|
||||||
|
return reply.conflict('This job is still running.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = await WIKI.models.jobs.retryJob(entry)
|
||||||
|
if (!id) {
|
||||||
|
return reply.internalServerError('The scheduler could not queue the job.')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
message: 'Job queued successfully.',
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default routes
|
||||||
@ -0,0 +1,155 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify'
|
||||||
|
import { JOB_STATES } from '../../models/jobs.ts'
|
||||||
|
|
||||||
|
export async function registerSchemas(app: FastifyInstance): Promise<void> {
|
||||||
|
/**
|
||||||
|
* SCHEDULER TASK - A cron entry, i.e. a task that runs automatically
|
||||||
|
*/
|
||||||
|
app.addSchema({
|
||||||
|
$id: 'SchedulerTask',
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: {
|
||||||
|
type: 'string',
|
||||||
|
format: 'uuid'
|
||||||
|
},
|
||||||
|
task: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Task name, matching a file under `tasks/simple/` or `tasks/workers/`.'
|
||||||
|
},
|
||||||
|
cron: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Cron expression, evaluated in UTC.'
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Where the entry comes from, e.g. `system`.'
|
||||||
|
},
|
||||||
|
createdAt: {
|
||||||
|
type: 'string',
|
||||||
|
format: 'date-time',
|
||||||
|
description: 'RFC 3339 Date Time'
|
||||||
|
},
|
||||||
|
updatedAt: {
|
||||||
|
type: 'string',
|
||||||
|
format: 'date-time',
|
||||||
|
description: 'RFC 3339 Date Time'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SCHEDULER UPCOMING JOB - A job waiting in the queue
|
||||||
|
*/
|
||||||
|
app.addSchema({
|
||||||
|
$id: 'SchedulerUpcomingJob',
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: {
|
||||||
|
type: 'string',
|
||||||
|
format: 'uuid'
|
||||||
|
},
|
||||||
|
task: {
|
||||||
|
type: 'string'
|
||||||
|
},
|
||||||
|
useWorker: {
|
||||||
|
type: 'boolean',
|
||||||
|
description: 'True when the task runs in a worker thread rather than in-process.'
|
||||||
|
},
|
||||||
|
retries: {
|
||||||
|
type: 'integer',
|
||||||
|
description: 'How many attempts have already failed. Zero on a first attempt.'
|
||||||
|
},
|
||||||
|
maxRetries: {
|
||||||
|
type: 'integer'
|
||||||
|
},
|
||||||
|
waitUntil: {
|
||||||
|
// -> Jobs meant to run as soon as a worker is free have no date at all
|
||||||
|
type: 'string',
|
||||||
|
nullable: true,
|
||||||
|
format: 'date-time',
|
||||||
|
description: 'RFC 3339 Date Time, or null to run at the next opportunity'
|
||||||
|
},
|
||||||
|
isScheduled: {
|
||||||
|
type: 'boolean',
|
||||||
|
description: 'True when the job was created from a cron entry rather than on demand.'
|
||||||
|
},
|
||||||
|
createdBy: {
|
||||||
|
type: 'string',
|
||||||
|
nullable: true,
|
||||||
|
description: 'ID of the instance that queued the job.'
|
||||||
|
},
|
||||||
|
createdAt: {
|
||||||
|
type: 'string',
|
||||||
|
format: 'date-time',
|
||||||
|
description: 'RFC 3339 Date Time'
|
||||||
|
},
|
||||||
|
updatedAt: {
|
||||||
|
type: 'string',
|
||||||
|
format: 'date-time',
|
||||||
|
description: 'RFC 3339 Date Time'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SCHEDULER JOB - One execution, as recorded in the job history
|
||||||
|
*/
|
||||||
|
app.addSchema({
|
||||||
|
$id: 'SchedulerJob',
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: {
|
||||||
|
type: 'string',
|
||||||
|
format: 'uuid'
|
||||||
|
},
|
||||||
|
task: {
|
||||||
|
type: 'string'
|
||||||
|
},
|
||||||
|
state: {
|
||||||
|
type: 'string',
|
||||||
|
enum: JOB_STATES,
|
||||||
|
description:
|
||||||
|
'`active` while running, `interrupted` when the run was cut short rather than failing on its own.'
|
||||||
|
},
|
||||||
|
useWorker: {
|
||||||
|
type: 'boolean'
|
||||||
|
},
|
||||||
|
wasScheduled: {
|
||||||
|
type: 'boolean'
|
||||||
|
},
|
||||||
|
attempt: {
|
||||||
|
type: 'integer',
|
||||||
|
description: 'Which attempt this execution was, starting at 1.'
|
||||||
|
},
|
||||||
|
maxRetries: {
|
||||||
|
type: 'integer'
|
||||||
|
},
|
||||||
|
lastErrorMessage: {
|
||||||
|
type: 'string',
|
||||||
|
nullable: true
|
||||||
|
},
|
||||||
|
executedBy: {
|
||||||
|
type: 'string',
|
||||||
|
nullable: true,
|
||||||
|
description: 'ID of the instance that ran the job.'
|
||||||
|
},
|
||||||
|
createdAt: {
|
||||||
|
type: 'string',
|
||||||
|
format: 'date-time',
|
||||||
|
description: 'RFC 3339 Date Time — when the job was queued'
|
||||||
|
},
|
||||||
|
startedAt: {
|
||||||
|
type: 'string',
|
||||||
|
format: 'date-time',
|
||||||
|
description: 'RFC 3339 Date Time'
|
||||||
|
},
|
||||||
|
completedAt: {
|
||||||
|
type: 'string',
|
||||||
|
nullable: true,
|
||||||
|
format: 'date-time',
|
||||||
|
description: 'RFC 3339 Date Time, or null while the job has not finished'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
Loading…
Reference in new issue