@sidequest/engine
Version:
@sidequest/engine is the core engine of SideQuest, a distributed background job processing system for Node.js and TypeScript.
204 lines (201 loc) • 8.9 kB
JavaScript
import { QUEUE_FALLBACK, MISC_FALLBACK, LazyBackend } from '@sidequest/backend';
import { logger, configureLogger } from '@sidequest/core';
import { fork } from 'child_process';
import { cpus } from 'os';
import path from 'path';
import { JOB_BUILDER_FALLBACK } from './job/constants.js';
import { JobBuilder } from './job/job-builder.js';
import { grantQueueConfig } from './queue/grant-queue-config.js';
import { gracefulShutdown, clearGracefulShutdown } from './utils/shutdown.js';
const workerPath = path.resolve(import.meta.dirname, "workers", "main.js");
/**
* The main engine for managing job queues and workers in Sidequest.
*/
class Engine {
/**
* Backend instance used by the engine.
* This is initialized when the engine is configured or started.
*/
backend;
/**
* Current configuration of the engine.
* This is set when the engine is configured or started.
* It contains all the necessary settings for the engine to operate, such as backend, queues, logger options, and job defaults.
*/
config;
/**
* Main worker process that runs the Sidequest engine.
* This is created when the engine is started and handles job processing.
*/
mainWorker;
/**
* Flag indicating whether the engine is currently shutting down.
* This is used to prevent multiple shutdown attempts and ensure graceful shutdown behavior.
*/
shuttingDown = false;
/**
* Configures the Sidequest engine with the provided configuration.
* @param config Optional configuration object.
* @returns The resolved configuration.
*/
async configure(config) {
if (this.config) {
logger("Engine").debug("Sidequest already configured");
return this.config;
}
this.config = {
queues: config?.queues ?? [],
backend: {
driver: config?.backend?.driver ?? "@sidequest/sqlite-backend",
config: config?.backend?.config ?? "./sidequest.sqlite",
},
cleanupFinishedJobsIntervalMin: config?.cleanupFinishedJobsIntervalMin ?? 60,
cleanupFinishedJobsOlderThan: config?.cleanupFinishedJobsOlderThan ?? 30 * 24 * 60 * 60 * 1000,
releaseStaleJobsIntervalMin: config?.releaseStaleJobsIntervalMin ?? 60,
maxConcurrentJobs: config?.maxConcurrentJobs ?? 10,
skipMigration: config?.skipMigration ?? false,
logger: {
level: config?.logger?.level ?? "info",
json: config?.logger?.json ?? false,
},
gracefulShutdown: config?.gracefulShutdown ?? true,
minThreads: config?.minThreads ?? cpus().length,
maxThreads: config?.maxThreads ?? cpus().length * 2,
idleWorkerTimeout: config?.idleWorkerTimeout ?? 10_000,
releaseStaleJobsMaxStaleMs: config?.releaseStaleJobsMaxStaleMs ?? MISC_FALLBACK.maxStaleMs, // 10 minutes
releaseStaleJobsMaxClaimedMs: config?.releaseStaleJobsMaxClaimedMs ?? MISC_FALLBACK.maxClaimedMs, // 1 minute
jobDefaults: {
queue: config?.jobDefaults?.queue ?? JOB_BUILDER_FALLBACK.queue,
maxAttempts: config?.jobDefaults?.maxAttempts ?? JOB_BUILDER_FALLBACK.maxAttempts,
// This here does not use a fallback default because it is a getter.
// It needs to be set at job creation time.
availableAt: config?.jobDefaults?.availableAt,
timeout: config?.jobDefaults?.timeout ?? JOB_BUILDER_FALLBACK.timeout,
uniqueness: config?.jobDefaults?.uniqueness ?? JOB_BUILDER_FALLBACK.uniqueness,
},
queueDefaults: {
concurrency: config?.queueDefaults?.concurrency ?? QUEUE_FALLBACK.concurrency,
priority: config?.queueDefaults?.priority ?? QUEUE_FALLBACK.priority,
state: config?.queueDefaults?.state ?? QUEUE_FALLBACK.state,
},
};
if (this.config.maxConcurrentJobs !== undefined && this.config.maxConcurrentJobs < 1) {
throw new Error(`Invalid "maxConcurrentJobs" value: must be at least 1.`);
}
logger("Engine").debug(`Configuring Sidequest engine: ${JSON.stringify(this.config)}`);
if (this.config.logger) {
configureLogger(this.config.logger);
}
this.backend = new LazyBackend(this.config.backend);
if (!this.config.skipMigration) {
await this.backend.migrate();
}
return this.config;
}
/**
* Starts the Sidequest engine and worker process.
* @param config Optional configuration object.
*/
async start(config) {
if (this.mainWorker) {
logger("Engine").warn("Sidequest engine already started");
return;
}
await this.configure(config);
logger("Engine").info(`Starting Sidequest using backend ${this.config.backend.driver}`);
if (this.config.queues) {
for (const queue of this.config.queues) {
await grantQueueConfig(this.backend, queue, this.config.queueDefaults, true);
}
}
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error("Timeout on starting sidequest fork!"));
}, 5000);
if (!this.mainWorker) {
const runWorker = () => {
logger("Engine").debug("Starting main worker...");
this.mainWorker = fork(workerPath);
logger("Engine").debug(`Worker PID: ${this.mainWorker.pid}`);
this.mainWorker.on("message", (msg) => {
if (msg === "ready") {
logger("Engine").debug("Main worker is ready");
this.mainWorker?.send({ type: "start", sidequestConfig: this.config });
clearTimeout(timeout);
resolve();
}
});
this.mainWorker.on("exit", () => {
if (!this.shuttingDown) {
logger("Engine").error("Sidequest main exited, creating new...");
runWorker();
}
});
};
runWorker();
gracefulShutdown(this.close.bind(this), "Engine", this.config.gracefulShutdown);
}
});
}
/**
* Gets the current engine configuration.
* @returns The current configuration, if set.
*/
getConfig() {
return this.config;
}
/**
* Gets the backend instance in use by the engine.
* @returns The backend instance, if set.
*/
getBackend() {
return this.backend;
}
/**
* Closes the engine and releases resources.
*/
async close() {
if (!this.shuttingDown) {
this.shuttingDown = true;
logger("Engine").debug("Closing Sidequest engine...");
if (this.mainWorker) {
const promise = new Promise((resolve) => {
this.mainWorker.on("exit", resolve);
});
this.mainWorker.send({ type: "shutdown" });
await promise;
}
await this.backend?.close();
this.config = undefined;
this.backend = undefined;
this.mainWorker = undefined;
// Reset the shutting down flag after closing
// This allows the engine to be reconfigured or restarted later
clearGracefulShutdown();
logger("Engine").debug("Sidequest engine closed.");
this.shuttingDown = false;
}
}
/**
* Builds a job using the provided job class.
* @param JobClass The job class constructor.
* @returns A new JobBuilder instance for the job class.
*/
build(JobClass) {
if (!this.config || !this.backend) {
throw new Error("Engine not configured. Call engine.configure() or engine.start() first.");
}
if (this.shuttingDown) {
throw new Error("Engine is shutting down, cannot build job.");
}
logger("Engine").debug(`Building job for class: ${JobClass.name}`);
return new JobBuilder(this.backend, JobClass, {
...this.config.jobDefaults,
// We need to do this check again because available at is a getter. It needs to be set at job creation time.
// If not set, it will use the fallback value which is outdated from config.
availableAt: this.config.jobDefaults.availableAt ?? JOB_BUILDER_FALLBACK.availableAt,
});
}
}
export { Engine };
//# sourceMappingURL=engine.js.map