@sidequest/core
Version:
@sidequest/core is the core package of SideQuest, a distributed background job queue for Node.js and TypeScript applications.
253 lines (249 loc) • 9.5 kB
JavaScript
;
var promises = require('fs/promises');
var url = require('url');
var logger = require('../logger.cjs');
var parseErrorData = require('../tools/parse-error-data.cjs');
var jobResult = require('../transitions/job-result.cjs');
/**
* Abstract base class for Sidequest jobs.
* Concrete job classes should extend this class and implement the `run` method.
*
* There are a few convenience methods that can be used to return early and trigger a transition:
* - `snooze(delay: number)`: Returns a SnoozeResult to delay the job execution for a specified time.
* - `retry(reason: string | Error, delay?: number)`: Returns a RetryResult to retry the job with an optional delay.
* - `fail(reason: string | Error)`: Returns a FailedResult to mark the job as failed with a reason.
* - `complete(result: unknown)`: Returns a CompletedResult to mark the job as completed with a result.
*
* Calling any of these methods without returning its result will do absolutely nothing. Thus, you need to return
* the result of any of these methods to trigger the job transition.
*
* If there is an uncaught error in the `run` method, it will automatically return a RetryResult with the error data.
*
* @example
* ```typescript
* class MyJob extends Job {
* async run(arg1: string, arg2: number): Promise<string> {
* // Your job logic here
* if (someCondition) {
* return this.snooze(1000); // Delay the job for 1 second
* }
* if (anotherCondition) {
* return this.retry(new Error("Retrying due to some condition"), 500); // Retry after 500ms
* }
* if (yetAnotherCondition) {
* return this.fail("Failed due to some reason"); // Mark the job as failed
* }
* // If everything is fine, return the result
* return this.complete("Job completed successfully"); // Mark the job as completed
* // Alternatively, you can just return a value, which will be treated as the job result:
* return "Job completed successfully";
* }
* }
*/
class Job {
scriptResolver;
// JobData properties
id;
script;
queue;
state;
class;
args;
constructor_args;
attempt;
max_attempts;
inserted_at;
available_at;
timeout;
result;
errors;
attempted_at;
completed_at;
failed_at;
canceled_at;
claimed_at;
claimed_by;
unique_digest;
uniqueness_config;
/**
* Initializes the job and resolves its script path.
*/
constructor() {
/* IMPORTANT: the build path resolution must be called here.
* This is important to ensure the path resolution is returning the Job implementation.
*/
this.scriptResolver = buildPath(this.constructor.name).then((script) => {
Object.assign(this, { script });
logger.logger("Job").debug(`Job script resolved: ${script}`);
return script;
});
}
/**
* Injects JobData properties into the job instance at runtime.
* @param jobData The job data to inject into this instance.
*/
injectJobData(jobData) {
logger.logger("Job").debug(`Injecting job data into ${this.className}:`, jobData);
Object.assign(this, jobData);
}
/**
* The class name of this job.
*/
get className() {
return this.constructor.name;
}
/**
* Waits until the job is ready (script path resolved).
* @returns A promise that resolves when ready.
*/
async ready() {
return await this.scriptResolver;
}
/**
* Returns a snooze result for this job.
* This will delay the job execution for the specified time by setting `available_at` to the current
* time plus the delay.
*
* @param delay The delay in milliseconds.
* @returns A SnoozeResult object.
*/
snooze(delay) {
logger.logger("Job").debug(`Job ${this.className} snoozed for ${delay}ms`);
return { __is_job_transition__: true, type: "snooze", delay: delay };
}
/**
* Returns a retry result for this job. It will increase one attempt and set the `attempted_at`
* to the current time. If the number of attempts is increased to the maximum allowed, the transition
* will mark the job as failed.
*
* @param reason The reason for retrying.
* @param delay Optional delay in milliseconds.
* @returns A RetryResult object.
*/
retry(reason, delay) {
const error = parseErrorData.toErrorData(reason);
logger.logger("Job").debug(`Job ${this.className} retrying due to: ${error.message}${delay ? ` after ${delay}ms` : ""}`);
return { __is_job_transition__: true, type: "retry", error, delay };
}
/**
* Returns a failed result for this job. This method will prevent any retry attempts and will mark the
* job as failed indefinitely.
*
* @param reason The reason for failure.
* @returns A FailedResult object.
*/
fail(reason) {
const error = parseErrorData.toErrorData(reason);
logger.logger("Job").debug(`Job ${this.className} failed: ${error.message}`);
return { __is_job_transition__: true, type: "failed", error };
}
/**
* Returns a completed result for this job.
* This method will mark the job as completed.
*
* @param result The result value.
* @returns A CompletedResult object.
*/
complete(result) {
logger.logger("Job").debug(`Job ${this.className} completed.`);
return { __is_job_transition__: true, type: "completed", result };
}
/**
* Runs the job and returns a JobResult.
* This method is intended to be used internally.
*
* @param args Arguments to pass to the run method.
* @returns A promise resolving to the job result.
*/
async perform(...args) {
try {
const result = await this.run(...args);
if (jobResult.isJobResult(result)) {
return result;
}
return { __is_job_transition__: true, type: "completed", result };
}
catch (error) {
logger.logger("Job").debug(error);
const errorData = parseErrorData.toErrorData(error);
return { __is_job_transition__: true, type: "retry", error: errorData };
}
}
}
// TODO need to test this with unit tests
/**
* Attempts to determine the file path where a given class is exported by analyzing the current call stack.
*
* This function inspects the stack trace of a newly created error to extract file paths,
* then checks each file to see if it exports the specified class. If found, returns the file path
* as a `file://` URI. If not found, returns the first file path in the stack as a fallback.
* Throws an error if no file paths can be determined.
*
* @param className - The name of the class to search for in the stack trace files.
* @returns A promise that resolves to the `file://` URI of the file exporting the class, or the first file in the stack.
* @throws If no file paths can be determined from the stack trace.
*/
async function buildPath(className) {
const err = new Error();
let stackLines = err.stack?.split("\n") ?? [];
stackLines = stackLines.slice(1);
logger.logger("Job").debug(`Resolving script file path. Stack lines: ${stackLines.join("\n")}`);
const filePaths = stackLines
.map((line) => {
const match = /(file:\/\/)?(((\/?)(\w:))?([/\\].+)):\d+:\d+/.exec(line);
if (match) {
return `${match[5] ?? ""}${match[6].replaceAll("\\", "/")}`;
}
return null;
})
.filter(Boolean);
for (const filePath of filePaths) {
const hasExported = await hasClassExported(filePath, className);
if (hasExported) {
logger.logger("Job").debug(`${filePath} exports class ${className}`);
return `file://${filePath}`;
}
}
if (filePaths.length > 0) {
logger.logger("Job").debug(`No class ${className} found in stack, returning first file path: ${filePaths[0]}`);
return `file://${filePaths[0]}`;
}
throw new Error("Could not determine the task path");
}
/**
* Checks if a given file exports a class with the specified name.
*
* This function attempts to import the module at the provided file path and
* determines if it exports a class (either as a named export or as the default export)
* matching the given class name.
*
* @param filePath - The absolute path to the module file to check.
* @param className - The name of the class to look for in the module's exports.
* @returns A promise that resolves to `true` if the class is exported, or `false` otherwise.
*/
async function hasClassExported(filePath, className) {
try {
await promises.access(filePath);
}
catch {
return false;
}
try {
const moduleUrl = url.pathToFileURL(filePath).href;
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const mod = await import(moduleUrl);
if (mod && typeof mod === "object" && className in mod && typeof mod[className] === "function") {
return true;
}
if ("default" in mod && typeof mod.default === "function" && mod.default.name === className) {
return true;
}
return false;
}
catch (e) {
logger.logger("Core").debug(e);
return false;
}
}
exports.Job = Job;
//# sourceMappingURL=job.cjs.map