433 lines
14 KiB
JavaScript
433 lines
14 KiB
JavaScript
const path = require("path");
|
|
const Graceful = require("@ladjs/graceful");
|
|
const Bree = require("@mintplex-labs/bree");
|
|
const later = require("@breejs/later");
|
|
const PQueue = require("p-queue").default;
|
|
const setLogger = require("../logger");
|
|
|
|
// Use UTC time for cron interpretation. This ensures consistent behavior
|
|
// regardless of server timezone (e.g., when running in containers).
|
|
later.date.UTC();
|
|
|
|
class BackgroundService {
|
|
name = "BackgroundWorkerService";
|
|
static _instance = null;
|
|
documentSyncEnabled = false;
|
|
memoryExtractionEnabled = false;
|
|
#root = path.resolve(__dirname, "../../jobs");
|
|
#scheduledJobTimers = new Map();
|
|
#scheduledJobQueue = new PQueue({
|
|
concurrency: Number(process.env.SCHEDULED_JOB_MAX_CONCURRENT) || 1,
|
|
});
|
|
// Tracks in-flight worker processes per scheduled jobId so we can kill any
|
|
// active runs when the job is deleted. Without this, a running worker
|
|
// outlives the cascade-delete of its scheduled_job_runs row and throws when
|
|
// it tries to write the result back (prisma.update on a missing row).
|
|
#scheduledJobWorkers = new Map();
|
|
|
|
#alwaysRunJobs = [
|
|
{
|
|
name: "cleanup-orphan-documents",
|
|
timeout: "1m",
|
|
interval: "12hr",
|
|
},
|
|
{
|
|
name: "cleanup-generated-files",
|
|
timeout: "5m",
|
|
interval: "8hr",
|
|
},
|
|
];
|
|
|
|
#memoryJobs = [
|
|
{
|
|
name: "extract-memories",
|
|
interval: process.env.MEMORY_EXTRACTION_INTERVAL || "3hr",
|
|
},
|
|
];
|
|
|
|
#documentSyncJobs = [
|
|
// Job for auto-sync of documents
|
|
// https://github.com/breejs/bree
|
|
{
|
|
name: "sync-watched-documents",
|
|
interval: "1hr",
|
|
},
|
|
];
|
|
|
|
constructor() {
|
|
if (BackgroundService._instance) {
|
|
this.#log("SINGLETON LOCK: Using existing BackgroundService.");
|
|
return BackgroundService._instance;
|
|
}
|
|
|
|
this.logger = setLogger();
|
|
BackgroundService._instance = this;
|
|
}
|
|
|
|
#log(text, ...args) {
|
|
console.log(`\x1b[36m[${this.name}]\x1b[0m ${text}`, ...args);
|
|
}
|
|
|
|
/**
|
|
* Returns the root path where job files are located.
|
|
* Handles the difference between development and production (bundled) environments.
|
|
* @returns {string}
|
|
*/
|
|
get jobsRoot() {
|
|
return this.#root;
|
|
}
|
|
|
|
/**
|
|
* Wraps the logger so that IPC messages carrying `silent: true` are
|
|
* suppressed. Bree unconditionally calls `logger.info(message)` for
|
|
* every IPC message from forked processes, so this is the only
|
|
* interception point.
|
|
*/
|
|
#makeBreeLogger() {
|
|
const base = this.logger;
|
|
const isSilent = (args) => args.length === 1 && args[0]?.silent === true;
|
|
|
|
const wrapped = Object.create(base);
|
|
wrapped.info = (...args) => {
|
|
if (!isSilent(args)) base.info(...args);
|
|
};
|
|
wrapped.log = (...args) => {
|
|
if (!isSilent(args)) base.log(...args);
|
|
};
|
|
return wrapped;
|
|
}
|
|
|
|
async boot() {
|
|
const { DocumentSyncQueue } = require("../../models/documentSyncQueue");
|
|
const { SystemSettings } = require("../../models/systemSettings");
|
|
const { ScheduledJobRun } = require("../../models/scheduledJobRun");
|
|
|
|
this.documentSyncEnabled = await DocumentSyncQueue.enabled();
|
|
this.memoryExtractionEnabled = await SystemSettings.autoMemoriesEnabled();
|
|
|
|
// Mark any orphaned scheduled job runs as failed (server crashed mid-execution)
|
|
const orphanedCount = await ScheduledJobRun.failOrphanedRuns();
|
|
if (orphanedCount > 0) {
|
|
this.#log(
|
|
`Marked ${orphanedCount} orphaned scheduled job run(s) as failed`
|
|
);
|
|
}
|
|
|
|
const jobsToRun = this.jobs();
|
|
|
|
this.#log("Starting...");
|
|
this.bree = new Bree({
|
|
logger: this.#makeBreeLogger(),
|
|
root: this.#root,
|
|
jobs: jobsToRun,
|
|
errorHandler: this.onError,
|
|
workerMessageHandler: this.onWorkerMessageHandler,
|
|
runJobsAs: "process",
|
|
});
|
|
this.graceful = new Graceful({ brees: [this.bree], logger: this.logger });
|
|
this.graceful.listen();
|
|
|
|
this.bree.start();
|
|
this.#log(
|
|
`Service started with ${jobsToRun.length} jobs`,
|
|
jobsToRun.map((j) => j.name)
|
|
);
|
|
|
|
await this.#bootScheduledJobs();
|
|
}
|
|
|
|
/**
|
|
* Cleanup scheduled jobs (in-process cron timers + p-queue)
|
|
*/
|
|
#cleanupScheduledJobs() {
|
|
for (const [id, timer] of this.#scheduledJobTimers) {
|
|
timer.clear();
|
|
this.#scheduledJobTimers.delete(id);
|
|
}
|
|
this.#scheduledJobQueue.clear();
|
|
}
|
|
|
|
async stop() {
|
|
this.#log("Stopping...");
|
|
this.#cleanupScheduledJobs();
|
|
if (!!this.graceful && !!this.bree) this.graceful.stopBree(this.bree, 0);
|
|
this.bree = null;
|
|
this.graceful = null;
|
|
this.#log("Service stopped");
|
|
}
|
|
|
|
/** @returns {import("@mintplex-labs/bree").Job[]} */
|
|
jobs() {
|
|
const activeJobs = [...this.#alwaysRunJobs];
|
|
if (this.memoryExtractionEnabled) activeJobs.push(...this.#memoryJobs);
|
|
if (this.documentSyncEnabled) activeJobs.push(...this.#documentSyncJobs);
|
|
return activeJobs;
|
|
}
|
|
|
|
/**
|
|
* Sync the memory extraction job based on current settings.
|
|
* Called when memory_enabled or memory_auto_extraction changes.
|
|
* @param {boolean} enabled - The desired state (should the extraction job run?)
|
|
*/
|
|
async syncMemoryJob(enabled) {
|
|
if (!this.bree) return;
|
|
|
|
const jobName = this.#memoryJobs[0].name;
|
|
const isCurrentlyRunning = this.bree.config.jobs.some(
|
|
(j) => j.name === jobName
|
|
);
|
|
|
|
if (enabled && !isCurrentlyRunning) {
|
|
this.memoryExtractionEnabled = true;
|
|
await this.bree.add(this.#memoryJobs[0]);
|
|
await this.bree.start(jobName);
|
|
this.#log(`Added and started ${jobName} job`);
|
|
} else if (!enabled && isCurrentlyRunning) {
|
|
this.memoryExtractionEnabled = false;
|
|
await this.bree.stop(jobName);
|
|
await this.bree.remove(jobName);
|
|
this.#log(`Stopped and removed ${jobName} job`);
|
|
}
|
|
}
|
|
|
|
onError(error, _workerMetadata) {
|
|
this.logger.error(`${error.message}`, {
|
|
service: "bg-worker",
|
|
origin: error.name,
|
|
});
|
|
}
|
|
|
|
onWorkerMessageHandler(message, _workerMetadata) {
|
|
if (message?.silent || message?.message?.silent) return;
|
|
this.logger.info(`${message.message}`, {
|
|
service: "bg-worker",
|
|
origin: message.name,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Spawn a one-off Bree worker process for the given script.
|
|
* @param {string} scriptPath - Absolute path to the worker JS file
|
|
* @returns {Promise<{ worker: ChildProcess, jobId: string }>}
|
|
*/
|
|
async spawnWorker(scriptPath) {
|
|
if (!this.bree)
|
|
throw new Error("BackgroundService has not been booted yet");
|
|
|
|
const jobId = `${path.basename(scriptPath, ".js")}-${Date.now()}`;
|
|
|
|
await this.bree.add({
|
|
name: jobId,
|
|
path: scriptPath,
|
|
});
|
|
|
|
await this.bree.run(jobId);
|
|
const worker = this.bree.workers.get(jobId);
|
|
|
|
if (!worker) throw new Error("Failed to get worker reference from Bree");
|
|
|
|
return { worker, jobId };
|
|
}
|
|
|
|
/**
|
|
* Remove a one-off Bree job registration so stale entries don't accumulate.
|
|
* @param {string} jobId
|
|
*/
|
|
async removeJob(jobId) {
|
|
if (!jobId) return;
|
|
try {
|
|
if (this.bree) await this.bree.remove(jobId);
|
|
} catch {
|
|
/* Job may already be removed */
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Scheduled Jobs — in-process cron timers + p-queue
|
|
//
|
|
// Bree tightly couples scheduling with worker spawning — when a
|
|
// Bree cron fires, it directly calls run() which immediately
|
|
// spawns a child process with no way to intercept it. We manage
|
|
// our own cron timers (via later.setInterval) to decouple
|
|
// scheduling from execution so we can route jobs through p-queue
|
|
// for global concurrency control before spawning workers.
|
|
//
|
|
// Per-job dedup lives in the database, not in process memory: any
|
|
// non-terminal row (`queued` or `running`) in scheduled_job_runs means
|
|
// the job has a run in flight. ScheduledJobRun.start() does the check +
|
|
// insert atomically and creates the row in `queued` status. The worker
|
|
// transitions it to `running` once it actually begins executing, so
|
|
// `startedAt` reflects execution start rather than queue-claim time.
|
|
// Cron-fired and manually-triggered enqueues use the same rule —
|
|
// at most one in-flight run per job, regardless of source.
|
|
// ---------------------------------------------------------------
|
|
|
|
/**
|
|
* Register cron timers for all enabled scheduled jobs on startup.
|
|
*/
|
|
async #bootScheduledJobs() {
|
|
const { ScheduledJob } = require("../../models/scheduledJob");
|
|
const enabledJobs = await ScheduledJob.allEnabled();
|
|
|
|
for (const job of enabledJobs) {
|
|
await ScheduledJob.recomputeNextRunAt(job.id);
|
|
this.addScheduledJob(job);
|
|
}
|
|
|
|
if (enabledJobs.length > 0) {
|
|
this.#log(
|
|
`Registered ${enabledJobs.length} scheduled job(s) (max concurrent: ${this.#scheduledJobQueue.concurrency})`,
|
|
enabledJobs.map((j) => `${j.name} (${j.schedule})`)
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Register an in-process cron timer for a scheduled job.
|
|
* When the cron fires, the jobId is enqueued for execution.
|
|
* @param {object} job - scheduled_jobs DB record
|
|
*/
|
|
addScheduledJob(job) {
|
|
this.removeScheduledJob(job.id);
|
|
const sched = later.parse.cron(job.schedule);
|
|
const timer = later.setInterval(() => {
|
|
this.enqueueScheduledJob(job.id);
|
|
}, sched);
|
|
this.#scheduledJobTimers.set(job.id, timer);
|
|
}
|
|
|
|
/**
|
|
* Remove an in-process cron timer for a scheduled job and kill any in-flight
|
|
* worker processes for it. Killing in-flight workers prevents them from
|
|
* writing results back to a scheduled_job_runs row that the FK cascade (from
|
|
* a subsequent ScheduledJob.delete) is about to remove.
|
|
* @param {number} jobId - scheduled_jobs.id
|
|
*/
|
|
removeScheduledJob(jobId) {
|
|
const timer = this.#scheduledJobTimers.get(jobId);
|
|
if (timer) timer.clear();
|
|
this.#scheduledJobTimers.delete(jobId);
|
|
|
|
const workers = this.#scheduledJobWorkers.get(jobId);
|
|
if (workers) {
|
|
for (const worker of workers) {
|
|
try {
|
|
worker.kill("SIGTERM");
|
|
} catch {
|
|
/* worker may have already exited */
|
|
}
|
|
}
|
|
this.#scheduledJobWorkers.delete(jobId);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Re-sync a scheduled job's cron timer after an update.
|
|
* Removes the old timer and re-adds if still enabled.
|
|
* @param {number} jobId - scheduled_jobs.id
|
|
*/
|
|
async syncScheduledJob(jobId) {
|
|
const { ScheduledJob } = require("../../models/scheduledJob");
|
|
this.removeScheduledJob(jobId);
|
|
const job = await ScheduledJob.get({ id: Number(jobId) });
|
|
if (job && job.enabled) {
|
|
this.addScheduledJob(job);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Kill a specific run's worker process. This terminates the worker but does
|
|
* not update the database — the caller should use ScheduledJobRun.kill()
|
|
* before or after calling this to mark the run as failed.
|
|
*
|
|
* @param {number} jobId - scheduled_jobs.id (parent job)
|
|
* @param {number} runId - scheduled_job_runs.id (not directly used, but for
|
|
* future multi-run support; currently we kill all workers for the jobId)
|
|
* @returns {boolean} true if a worker was found and killed, false otherwise
|
|
*/
|
|
killRun(jobId, _runId) {
|
|
const workers = this.#scheduledJobWorkers.get(Number(jobId));
|
|
if (!workers || workers.size === 0) return false;
|
|
|
|
let killed = false;
|
|
for (const worker of workers) {
|
|
try {
|
|
worker.kill("SIGTERM");
|
|
killed = true;
|
|
} catch {
|
|
/* worker may have already exited */
|
|
}
|
|
}
|
|
return killed;
|
|
}
|
|
|
|
/**
|
|
* Enqueue a scheduled job for execution. Called by both the cron timer
|
|
* (in addScheduledJob) and the manual trigger endpoint. ScheduledJobRun.start()
|
|
* atomically rejects the call if the job already has a run in flight.
|
|
*
|
|
* @param {number} jobId - scheduled_jobs.id
|
|
* @returns {Promise<object|null>} the created run row, or null if skipped
|
|
* because a run is already in flight for this job.
|
|
*/
|
|
async enqueueScheduledJob(jobId) {
|
|
const { ScheduledJobRun } = require("../../models/scheduledJobRun");
|
|
|
|
const run = await ScheduledJobRun.start(jobId);
|
|
// if start returns null, skip enqueuing, schueduled job already has a run in flight
|
|
if (!run) return null;
|
|
|
|
this.#scheduledJobQueue.add(() =>
|
|
this.#runScheduledJobWorker(jobId, run.id).catch(async (err) => {
|
|
this.#log(`Scheduled job ${jobId} failed: ${err.message}`);
|
|
await ScheduledJobRun.failIfNotTerminal(run.id, err.message);
|
|
})
|
|
);
|
|
return run;
|
|
}
|
|
|
|
/**
|
|
* Spawn the run-scheduled-job worker for a given run and resolve when it
|
|
* exits so p-queue can advance. The worker reads its payload from an IPC
|
|
* `process.on("message", ...)` handler.
|
|
*
|
|
* @param {number} jobId
|
|
* @param {number} runId
|
|
* @returns {Promise<void>}
|
|
*/
|
|
async #runScheduledJobWorker(jobId, runId) {
|
|
const scriptPath = path.resolve(this.jobsRoot, "run-scheduled-job.js");
|
|
const { worker, jobId: workerId } = await this.spawnWorker(scriptPath);
|
|
|
|
if (!this.#scheduledJobWorkers.has(jobId)) {
|
|
this.#scheduledJobWorkers.set(jobId, new Set());
|
|
}
|
|
this.#scheduledJobWorkers.get(jobId).add(worker);
|
|
|
|
try {
|
|
worker.send({ jobId, runId });
|
|
await new Promise((resolve, reject) => {
|
|
worker.on("exit", (code, signal) => {
|
|
// SIGTERM is sent by removeScheduledJob when the job is deleted
|
|
// mid-run; treat that as a normal exit rather than a worker failure.
|
|
if (code === 0 || code == null || signal === "SIGTERM") {
|
|
resolve();
|
|
} else {
|
|
reject(new Error(`Worker exited with code ${code}`));
|
|
}
|
|
});
|
|
worker.on("error", reject);
|
|
});
|
|
} finally {
|
|
const workers = this.#scheduledJobWorkers.get(jobId);
|
|
if (workers) {
|
|
workers.delete(worker);
|
|
if (workers.size === 0) this.#scheduledJobWorkers.delete(jobId);
|
|
}
|
|
await this.removeJob(workerId).catch(() => {});
|
|
}
|
|
}
|
|
}
|
|
|
|
module.exports.BackgroundService = BackgroundService;
|