|
|
@ -5,19 +5,218 @@ import { debug } from './logger'
|
|
|
|
import { createJsonDatabase } from './lib/file-db'
|
|
|
|
import { createJsonDatabase } from './lib/file-db'
|
|
|
|
|
|
|
|
|
|
|
|
import { randomUUID } from 'crypto'
|
|
|
|
import { randomUUID } from 'crypto'
|
|
|
|
|
|
|
|
import { createSimpleRunner, type Runner } from './runners'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
import { createWriteStream } from 'fs'
|
|
|
|
|
|
|
|
import { readFile, mkdir } from 'fs/promises'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
import { dirname } from 'path'
|
|
|
|
|
|
|
|
import type { random } from 'lodash'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
type JobStatus = 'queued' | 'running' | 'completed'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export type Job = {
|
|
|
|
|
|
|
|
uuid: string
|
|
|
|
|
|
|
|
name: string
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
status: JobStatus
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
submitter: any
|
|
|
|
|
|
|
|
submittedAt: string
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
successful?: boolean
|
|
|
|
|
|
|
|
error?: any
|
|
|
|
|
|
|
|
startedAt?: string
|
|
|
|
|
|
|
|
completedAt?: string
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export type DeployFunction = (runner: Runner) => Promise<void>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
type QueuedJob = {
|
|
|
|
|
|
|
|
uuid: string
|
|
|
|
|
|
|
|
deployFn: DeployFunction
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const emitter = new EventEmitter()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const jobsDB = createJsonDatabase<Job[]>(`${import.meta.env.DATA_PATH}/jobs.json`, [])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export function getJobLogFile(job: Job): string {
|
|
|
|
|
|
|
|
const timeHash = new Date(job.submittedAt).getTime()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// TODO: job.name is not safe
|
|
|
|
|
|
|
|
return `${import.meta.env.DATA_PATH}/logs/${job.name}_${timeHash}_${job.uuid.slice(0, 6)}.log`
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// To ensure that the while loop inside "processQueue" is getting executed from
|
|
|
|
|
|
|
|
// only one call at a time.
|
|
|
|
|
|
|
|
let working = false
|
|
|
|
|
|
|
|
const queue: QueuedJob[] = []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async function processQueue() {
|
|
|
|
|
|
|
|
if (working) return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
working = true
|
|
|
|
|
|
|
|
{
|
|
|
|
|
|
|
|
while (queue.length > 0) {
|
|
|
|
|
|
|
|
const { uuid, deployFn } = queue.shift()!
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const job = await jobsDB.update(async jobs => {
|
|
|
|
|
|
|
|
const job = jobs.find(job => job.uuid === uuid)!
|
|
|
|
|
|
|
|
job.status = 'running'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return job
|
|
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
emitter.emit('job:started', job)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const startedAt = new Date().toISOString()
|
|
|
|
|
|
|
|
let error: string | undefined
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const logsFilename = getJobLogFile(job)
|
|
|
|
|
|
|
|
await mkdir(dirname(logsFilename), { recursive: true })
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const logsFile = createWriteStream(logsFilename, { flags: 'w' })
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const runner = createSimpleRunner(line => {
|
|
|
|
|
|
|
|
logsFile.write(line + '\n')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// to not block log file creation
|
|
|
|
|
|
|
|
setImmediate(() => emitter.emit(`job:log:${uuid}`, line))
|
|
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
debug(`[Jobs] Starting job "${job.name}"`)
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
|
|
await deployFn(runner)
|
|
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
|
|
error = e!.toString()
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
debug(`[Jobs] Finished job`)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const completedAt = new Date().toISOString()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const completedJob = await jobsDB.update(async jobs => {
|
|
|
|
|
|
|
|
const job = jobs.find(job => job.uuid === uuid)!
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
job.status = 'completed'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
job.successful = error === undefined
|
|
|
|
|
|
|
|
job.error = error
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
job.startedAt = startedAt
|
|
|
|
|
|
|
|
job.completedAt = completedAt
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return job
|
|
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
emitter.emit('job:completed', completedJob)
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
working = false
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export type JobBase = {
|
|
|
|
|
|
|
|
name: string
|
|
|
|
|
|
|
|
submitter: any
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Use this function to add new jobs to the work queue
|
|
|
|
|
|
|
|
export async function enqueueJob(jobBase: JobBase, deployFn: DeployFunction) {
|
|
|
|
|
|
|
|
const uuid = randomUUID()
|
|
|
|
|
|
|
|
const job: Job = {
|
|
|
|
|
|
|
|
...jobBase,
|
|
|
|
|
|
|
|
uuid,
|
|
|
|
|
|
|
|
status: 'queued',
|
|
|
|
|
|
|
|
submittedAt: new Date().toISOString(),
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
await jobsDB.update(async jobs => {
|
|
|
|
|
|
|
|
jobs.push(job)
|
|
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
queue.push({ uuid, deployFn })
|
|
|
|
|
|
|
|
emitter.emit('job:add', job)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// starts concurrently a function to process jobs
|
|
|
|
|
|
|
|
processQueue()
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export function getJobs(): Promise<Job[]> {
|
|
|
|
|
|
|
|
return jobsDB.load()
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export async function getJob(uuid: string): Promise<Job> {
|
|
|
|
|
|
|
|
const jobs = await jobsDB.load()
|
|
|
|
|
|
|
|
const job = jobs.find(job => job.uuid === uuid)
|
|
|
|
|
|
|
|
if (!job) throw new Error(`no job with uuid "${uuid}"`)
|
|
|
|
|
|
|
|
return job
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export async function getJobLogs(uuid: string): Promise<string> {
|
|
|
|
|
|
|
|
const job = await getJob(uuid)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
|
|
return await readFile(getJobLogFile(job), 'utf8')
|
|
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
|
|
return ''
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export const OnJobAdded = {
|
|
|
|
|
|
|
|
addListener(cb: (job: Job) => void) {
|
|
|
|
|
|
|
|
emitter.on('job:add', cb)
|
|
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
removeListener(cb: (job: Job) => void) {
|
|
|
|
|
|
|
|
emitter.off('job:add', cb)
|
|
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export const OnJobCompleted = {
|
|
|
|
|
|
|
|
addListener(cb: (job: Job) => void) {
|
|
|
|
|
|
|
|
emitter.on('job:completed', cb)
|
|
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
removeListener(cb: (job: Job) => void) {
|
|
|
|
|
|
|
|
emitter.off('job:completed', cb)
|
|
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export const OnJobStarted = {
|
|
|
|
|
|
|
|
addListener(cb: (job: Job) => void) {
|
|
|
|
|
|
|
|
emitter.on('job:started', cb)
|
|
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
removeListener(cb: (job: Job) => void) {
|
|
|
|
|
|
|
|
emitter.off('job:started', cb)
|
|
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export const OnJobLog = {
|
|
|
|
|
|
|
|
addListener(uuid: string, cb: (line: string) => void) {
|
|
|
|
|
|
|
|
emitter.on(`job:log:${uuid}`, cb)
|
|
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
removeListener(uuid: string, cb: (line: string) => void) {
|
|
|
|
|
|
|
|
emitter.off(`job:log:${uuid}`, cb)
|
|
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// ===================[ Old Version ]===================
|
|
|
|
|
|
|
|
/*
|
|
|
|
|
|
|
|
|
|
|
|
export type QueuedJob = {
|
|
|
|
export type QueuedJob = {
|
|
|
|
uuid: string
|
|
|
|
uuid: string
|
|
|
|
name: string
|
|
|
|
name: string
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
status: JobStatus
|
|
|
|
|
|
|
|
|
|
|
|
submitter: any
|
|
|
|
submitter: any
|
|
|
|
submittedAt: Date
|
|
|
|
submittedAt: string
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export type CompletedJob = {
|
|
|
|
export type CompletedJob = {
|
|
|
|
uuid: string
|
|
|
|
uuid: string
|
|
|
|
name: string
|
|
|
|
name: string
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
status: JobStatus
|
|
|
|
|
|
|
|
|
|
|
|
successful: boolean
|
|
|
|
successful: boolean
|
|
|
|
error?: any
|
|
|
|
error?: any
|
|
|
|
|
|
|
|
|
|
|
@ -30,22 +229,54 @@ export type CompletedJob = {
|
|
|
|
export type Job = {
|
|
|
|
export type Job = {
|
|
|
|
name: string
|
|
|
|
name: string
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
status: JobStatus
|
|
|
|
|
|
|
|
|
|
|
|
submitter: any
|
|
|
|
submitter: any
|
|
|
|
submittedAt: Date
|
|
|
|
submittedAt: string
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export type SubmittedJob = {
|
|
|
|
|
|
|
|
uuid: string
|
|
|
|
|
|
|
|
name: string
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
status: JobStatus
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
submitter: any
|
|
|
|
|
|
|
|
submittedAt: string
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
successful?: boolean
|
|
|
|
|
|
|
|
error?: any
|
|
|
|
|
|
|
|
startedAt?: string
|
|
|
|
|
|
|
|
completedAt?: string
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
type JobBare = {
|
|
|
|
|
|
|
|
name: string
|
|
|
|
|
|
|
|
uuid: string
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
status: JobStatus
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export function getJobLogFile(job: JobBare): string {
|
|
|
|
|
|
|
|
// TODO: job.name is not safe
|
|
|
|
|
|
|
|
return `${import.meta.env.DATA_PATH}/logs/${job.name}.${job.uuid}.log`
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export type Worker = {
|
|
|
|
export type Worker = {
|
|
|
|
work: () => Promise<void>
|
|
|
|
work: (runner: Runner) => Promise<void>
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Event emitter & Queue (runtime only)
|
|
|
|
// Event emitter & Queue (runtime only)
|
|
|
|
const emitter = new EventEmitter<{
|
|
|
|
const emitter = new EventEmitter<{
|
|
|
|
'job:add': [QueuedJob]
|
|
|
|
'job:add': [QueuedJob]
|
|
|
|
'job:completed': [CompletedJob]
|
|
|
|
'job:completed': [CompletedJob]
|
|
|
|
|
|
|
|
'job:log': [string]
|
|
|
|
}>()
|
|
|
|
}>()
|
|
|
|
|
|
|
|
|
|
|
|
const queue: (QueuedJob & Worker)[] = []
|
|
|
|
const queue: (QueuedJob & Worker)[] = []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const runningJobs: Record<string, SubmittedJob> = {}
|
|
|
|
|
|
|
|
|
|
|
|
// Job db for logging purposes
|
|
|
|
// Job db for logging purposes
|
|
|
|
const jobsDB = createJsonDatabase<CompletedJob[]>(`${import.meta.env.DATA_PATH}/jobs.json`, [])
|
|
|
|
const jobsDB = createJsonDatabase<CompletedJob[]>(`${import.meta.env.DATA_PATH}/jobs.json`, [])
|
|
|
|
|
|
|
|
|
|
|
@ -64,9 +295,22 @@ async function processQueue() {
|
|
|
|
const startedAt = new Date().toISOString()
|
|
|
|
const startedAt = new Date().toISOString()
|
|
|
|
let error: string | undefined
|
|
|
|
let error: string | undefined
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const logsFilename = getJobLogFile(job)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
await mkdir(dirname(logsFilename), { recursive: true })
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const logsFile = createWriteStream(logsFilename, { flags: 'w' })
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const runner = createSimpleRunner(line => {
|
|
|
|
|
|
|
|
logsFile.write(line + '\n')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// to not block log file creation
|
|
|
|
|
|
|
|
setImmediate(() => emitter.emit('job:log', line))
|
|
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
debug(`[Jobs] Starting job "${job.name}"`)
|
|
|
|
debug(`[Jobs] Starting job "${job.name}"`)
|
|
|
|
try {
|
|
|
|
try {
|
|
|
|
await job.work()
|
|
|
|
await job.work(runner)
|
|
|
|
} catch (e) {
|
|
|
|
} catch (e) {
|
|
|
|
error = e!.toString()
|
|
|
|
error = e!.toString()
|
|
|
|
}
|
|
|
|
}
|
|
|
@ -74,15 +318,17 @@ async function processQueue() {
|
|
|
|
|
|
|
|
|
|
|
|
const completedAt = new Date().toISOString()
|
|
|
|
const completedAt = new Date().toISOString()
|
|
|
|
|
|
|
|
|
|
|
|
const completedJob = {
|
|
|
|
const completedJob: CompletedJob = {
|
|
|
|
uuid: job.uuid,
|
|
|
|
uuid: job.uuid,
|
|
|
|
name: job.name,
|
|
|
|
name: job.name,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
status: 'completed',
|
|
|
|
|
|
|
|
|
|
|
|
successful: error === undefined,
|
|
|
|
successful: error === undefined,
|
|
|
|
error,
|
|
|
|
error,
|
|
|
|
|
|
|
|
|
|
|
|
submitter: job.submitter,
|
|
|
|
submitter: job.submitter,
|
|
|
|
submittedAt: job.submittedAt.toISOString(),
|
|
|
|
submittedAt: job.submittedAt,
|
|
|
|
startedAt,
|
|
|
|
startedAt,
|
|
|
|
completedAt,
|
|
|
|
completedAt,
|
|
|
|
}
|
|
|
|
}
|
|
|
@ -97,9 +343,7 @@ async function processQueue() {
|
|
|
|
working = false
|
|
|
|
working = false
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
// Use this function to add new jobs to the work queue
|
|
|
|
* Use this function to add new jobs to the work queue
|
|
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
export function enqueueJob(job: Job & Worker) {
|
|
|
|
export function enqueueJob(job: Job & Worker) {
|
|
|
|
const queueJob = { ...job, uuid: randomUUID() }
|
|
|
|
const queueJob = { ...job, uuid: randomUUID() }
|
|
|
|
|
|
|
|
|
|
|
@ -108,6 +352,7 @@ export function enqueueJob(job: Job & Worker) {
|
|
|
|
emitter.emit('job:add', {
|
|
|
|
emitter.emit('job:add', {
|
|
|
|
uuid: queueJob.uuid,
|
|
|
|
uuid: queueJob.uuid,
|
|
|
|
name: queueJob.name,
|
|
|
|
name: queueJob.name,
|
|
|
|
|
|
|
|
status: 'queued',
|
|
|
|
submitter: queueJob.submitter,
|
|
|
|
submitter: queueJob.submitter,
|
|
|
|
submittedAt: queueJob.submittedAt,
|
|
|
|
submittedAt: queueJob.submittedAt,
|
|
|
|
})
|
|
|
|
})
|
|
|
@ -117,9 +362,10 @@ export function enqueueJob(job: Job & Worker) {
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export async function getQueuedJobs(): Promise<QueuedJob[]> {
|
|
|
|
export async function getQueuedJobs(): Promise<QueuedJob[]> {
|
|
|
|
return queue.map(({ uuid, name, submitter, submittedAt }) => ({
|
|
|
|
return queue.map(({ uuid, name, status, submitter, submittedAt }) => ({
|
|
|
|
uuid,
|
|
|
|
uuid,
|
|
|
|
name,
|
|
|
|
name,
|
|
|
|
|
|
|
|
status,
|
|
|
|
submitter,
|
|
|
|
submitter,
|
|
|
|
submittedAt,
|
|
|
|
submittedAt,
|
|
|
|
}))
|
|
|
|
}))
|
|
|
@ -129,6 +375,20 @@ export function getCompletedJobs(): Promise<CompletedJob[]> {
|
|
|
|
return jobsDB.load()
|
|
|
|
return jobsDB.load()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export async function getJob(uuid: string): Promise<SubmittedJob | undefined> {
|
|
|
|
|
|
|
|
const jobs = await jobsDB.load()
|
|
|
|
|
|
|
|
const job = jobs.find(job => job.uuid === uuid)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (job) return job
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const queuedJob = queue.find(job => job.uuid === uuid)
|
|
|
|
|
|
|
|
return queuedJob
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export async function getJobLogs(job: JobBare): Promise<string> {
|
|
|
|
|
|
|
|
return await readFile(getJobLogFile(job), 'utf8')
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export const OnJobAdded = {
|
|
|
|
export const OnJobAdded = {
|
|
|
|
addListener(cb: (job: QueuedJob) => void) {
|
|
|
|
addListener(cb: (job: QueuedJob) => void) {
|
|
|
|
emitter.on('job:add', cb)
|
|
|
|
emitter.on('job:add', cb)
|
|
|
@ -146,3 +406,14 @@ export const OnJobCompleted = {
|
|
|
|
emitter.off('job:completed', cb)
|
|
|
|
emitter.off('job:completed', cb)
|
|
|
|
},
|
|
|
|
},
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export const OnJobLog = {
|
|
|
|
|
|
|
|
addListener(cb: (line: string) => void) {
|
|
|
|
|
|
|
|
emitter.on('job:log', cb)
|
|
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
removeListener(cb: (line: string) => void) {
|
|
|
|
|
|
|
|
emitter.off('job:log', cb)
|
|
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
*/
|
|
|
|