node-resque
Version:
an opinionated implementation of resque in node
592 lines (530 loc) • 15.4 kB
text/typescript
import { EventEmitter } from "events";
import { Cluster } from "ioredis";
import * as os from "os";
import { Jobs } from "..";
import { WorkerOptions } from "../types/options";
import { Connection } from "./connection";
import { RunPlugins } from "./pluginRunner";
import { ParsedJob, Queue } from "./queue";
function prepareJobs(jobs: Jobs) {
return Object.keys(jobs).reduce((h: { [key: string]: any }, k) => {
const job = jobs[k];
h[k] = typeof job === "function" ? { perform: job } : job;
return h;
}, {});
}
export declare interface Worker {
options: WorkerOptions;
jobs: Jobs;
started: boolean;
name: string;
queues: Array<string> | string;
queue: string;
originalQueue: string | null;
error: Error | null;
result: any;
ready: boolean;
running: boolean;
working: boolean;
pollTimer: NodeJS.Timeout;
endTimer: NodeJS.Timeout;
pingTimer: NodeJS.Timeout;
job: ParsedJob;
connection: Connection;
queueObject: Queue;
id: number;
on(event: "start" | "end" | "pause", cb: () => void): this;
on(event: "cleaning_worker", cb: (worker: Worker, pid: string) => void): this;
on(event: "poll", cb: (queue: string) => void): this;
on(event: "ping", cb: (time: number) => void): this;
on(event: "job", cb: (queue: string, job: ParsedJob) => void): this;
on(
event: "reEnqueue",
cb: (queue: string, job: ParsedJob, plugin: string) => void,
): this;
on(
event: "success",
cb: (queue: string, job: ParsedJob, result: any, duration: number) => void,
): this;
on(
event: "failure",
cb: (
queue: string,
job: ParsedJob,
failure: Error,
duration: number,
) => void,
): this;
on(
event: "error",
cb: (error: Error, queue: string, job: ParsedJob) => void,
): this;
once(event: "start" | "end" | "pause", cb: () => void): this;
once(
event: "cleaning_worker",
cb: (worker: Worker, pid: string) => void,
): this;
once(event: "poll", cb: (queue: string) => void): this;
once(event: "ping", cb: (time: number) => void): this;
once(event: "job", cb: (queue: string, job: ParsedJob) => void): this;
once(
event: "reEnqueue",
cb: (queue: string, job: ParsedJob, plugin: string) => void,
): this;
once(
event: "success",
cb: (queue: string, job: ParsedJob, result: any) => void,
): this;
once(
event: "failure",
cb: (queue: string, job: ParsedJob, failure: any) => void,
): this;
once(
event: "error",
cb: (error: Error, queue: string, job: ParsedJob) => void,
): this;
removeAllListeners(event: string): this;
}
export type WorkerEvent =
| "start"
| "end"
| "cleaning_worker"
| "poll"
| "ping"
| "job"
| "reEnqueue"
| "success"
| "failure"
| "error"
| "pause";
export class Worker extends EventEmitter {
constructor(options: WorkerOptions, jobs: Jobs = {}) {
super();
options.name = options.name ?? os.hostname() + ":" + process.pid; // assumes only one worker per node process
options.id = options.id ?? 1;
options.queues = options.queues ?? "*";
options.timeout = options.timeout ?? 5000;
options.looping = options.looping ?? true;
this.options = options;
this.jobs = prepareJobs(jobs);
this.name = this.options.name;
this.queues = this.options.queues;
this.queue = null;
this.originalQueue = null;
this.error = null;
this.result = null;
this.ready = true;
this.running = false;
this.working = false;
this.job = null;
this.pollTimer = null;
this.endTimer = null;
this.pingTimer = null;
this.started = false;
this.queueObject = new Queue({ connection: options.connection }, this.jobs);
this.queueObject.on("error", (error) => {
this.emit("error", error);
});
}
async connect() {
await this.queueObject.connect();
this.connection = this.queueObject.connection;
await this.checkQueues();
}
async start() {
if (this.ready) {
this.started = true;
this.emit("start", new Date());
await this.init();
this.poll();
}
}
async init() {
await this.track();
await this.connection.redis.set(
this.connection.key("worker", this.name, this.stringQueues(), "started"),
Math.round(new Date().getTime() / 1000),
);
await this.ping();
this.pingTimer = setInterval(this.ping.bind(this), this.options.timeout);
}
async end(): Promise<void> {
this.running = false;
if (this.working === true) {
await new Promise((resolve) => {
this.endTimer = setTimeout(() => {
resolve(null);
}, this.options.timeout);
});
return this.end();
}
clearTimeout(this.pollTimer);
clearTimeout(this.endTimer);
clearInterval(this.pingTimer);
if (
this.connection &&
(this.connection.connected === true ||
this.connection.connected === undefined ||
this.connection.connected === null)
) {
await this.untrack();
}
await this.queueObject.end();
this.emit("end", new Date());
}
private async poll(nQueue = 0): Promise<ParsedJob> {
if (!this.running) return;
this.queue = this.queues[nQueue];
this.emit("poll", this.queue);
if (this.queue === null || this.queue === undefined) {
await this.checkQueues();
await this.pause();
return null;
}
if (this.working === true) {
const error = new Error("refusing to get new job, already working");
this.emit("error", error, this.queue);
return null;
}
this.working = true;
try {
const currentJob = await this.getJob();
if (currentJob) {
if (this.options.looping) {
this.result = null;
await this.perform(currentJob);
} else {
return currentJob;
}
} else {
this.working = false;
if (nQueue === this.queues.length - 1) {
if (this.originalQueue === "*") await this.checkQueues();
await this.pause();
return null;
} else {
return this.poll(nQueue + 1);
}
}
} catch (error) {
this.emit("error", error, this.queue);
this.working = false;
await this.pause();
return null;
}
}
private async perform(job: ParsedJob) {
this.job = job;
this.error = null;
let toRun;
const startedAt = new Date().getTime();
if (!this.jobs[job.class]) {
this.error = new Error(`No job defined for class "${job.class}"`);
return this.completeJob(false, startedAt);
}
const perform = this.jobs[job.class].perform;
if (!perform || typeof perform !== "function") {
this.error = new Error(`Missing Job: "${job.class}"`);
return this.completeJob(false, startedAt);
}
this.emit("job", this.queue, this.job);
let triedAfterPerform = false;
try {
toRun = await RunPlugins(
this,
"beforePerform",
job.class,
this.queue,
this.jobs[job.class],
job.args,
);
if (toRun === false) {
return this.completeJob(false, startedAt);
}
let callableArgs = [job.args];
if (job.args === undefined || job.args instanceof Array) {
callableArgs = job.args;
}
for (const i in callableArgs) {
if (typeof callableArgs[i] === "object" && callableArgs[i] !== null) {
Object.freeze(callableArgs[i]);
}
}
this.result = await perform.apply(this, callableArgs);
triedAfterPerform = true;
toRun = await RunPlugins(
this,
"afterPerform",
job.class,
this.queue,
this.jobs[job.class],
job.args,
);
return this.completeJob(true, startedAt);
} catch (error) {
this.error = error;
if (!triedAfterPerform) {
try {
await RunPlugins(
this,
"afterPerform",
job.class,
this.queue,
this.jobs[job.class],
job.args,
);
} catch (error) {
if (error && !this.error) {
this.error = error;
}
}
}
return this.completeJob(!this.error, startedAt);
}
}
// #performInline is used to run a job payload directly.
// If you are planning on running a job via #performInline, this worker should also not be started, nor should be using event emitters to monitor this worker.
// This method will also not write to redis at all, including logging errors, modify resque's stats, etc.
async performInline(func: string, args: any[] = []) {
const q = "_direct-queue-" + this.name;
let toRun;
if (!(args instanceof Array)) {
args = [args];
}
if (this.started) {
throw new Error(
"Worker#performInline can not be used on a started worker",
);
}
if (!this.jobs[func]) {
throw new Error(`No job defined for class "${func}"`);
}
if (!this.jobs[func].perform) {
throw new Error(`Missing Job: "${func}"`);
}
let triedAfterPerform = false;
try {
toRun = await RunPlugins(
this,
"beforePerform",
func,
q,
this.jobs[func],
args,
);
if (toRun === false) {
return;
}
this.result = await this.jobs[func].perform.apply(this, args);
triedAfterPerform = true;
toRun = await RunPlugins(
this,
"afterPerform",
func,
q,
this.jobs[func],
args,
);
return this.result;
} catch (error) {
this.error = error;
if (!triedAfterPerform) {
try {
await RunPlugins(
this,
"afterPerform",
func,
this.queue,
this.jobs[func],
args,
);
} catch (error) {
if (error && !this.error) {
this.error = error;
}
}
}
// Allow afterPerform to clear the error
if (this.error) throw this.error;
}
}
private async completeJob(toRespond: boolean, startedAt: number) {
const duration = new Date().getTime() - startedAt;
if (this.error) {
await this.fail(this.error, duration);
} else if (toRespond) {
await this.succeed(this.job, duration);
}
this.working = false;
await this.connection.redis.del(
this.connection.key("worker", this.name, this.stringQueues()),
);
this.job = null;
if (this.options.looping) {
this.poll();
}
}
private async succeed(job: ParsedJob, duration: number) {
const response = await this.connection.redis
.multi()
.incr(this.connection.key("stat", "processed"))
.incr(this.connection.key("stat", "processed", this.name))
.exec();
response.forEach((res) => {
if (res[0] !== null) {
throw res[0];
}
});
this.emit("success", this.queue, job, this.result, duration);
}
private async fail(err: Error, duration: number) {
const response = await this.connection.redis
.multi()
.incr(this.connection.key("stat", "failed"))
.incr(this.connection.key("stat", "failed", this.name))
.rpush(
this.connection.key("failed"),
JSON.stringify(this.failurePayload(err, this.job)),
)
.exec();
response.forEach((res) => {
if (res[0] !== null) {
throw res[0];
}
});
this.emit("failure", this.queue, this.job, err, duration);
}
private async pause() {
this.emit("pause");
await new Promise((resolve) => {
this.pollTimer = setTimeout(() => {
this.poll();
resolve(null);
}, this.options.timeout);
});
}
private async getJob() {
let currentJob: ParsedJob;
const queueKey = this.connection.key("queue", this.queue);
const workerKey = this.connection.key(
"worker",
this.name,
this.stringQueues(),
);
let encodedJob: string;
if (
// We cannot use the atomic Lua script if we are using redis cluster - the shard storing the queue and worker may not be the same
!(this.connection.redis instanceof Cluster) &&
//@ts-ignore
this.connection.redis["popAndStoreJob"]
) {
//@ts-ignore
encodedJob = await this.connection.redis["popAndStoreJob"](
queueKey,
workerKey,
new Date().toString(),
this.queue,
this.name,
);
} else {
encodedJob = await this.connection.redis.lpop(queueKey);
if (encodedJob) {
await this.connection.redis.set(
workerKey,
JSON.stringify({
run_at: new Date().toString(),
queue: this.queue,
worker: this.name,
payload: JSON.parse(encodedJob),
}),
);
}
}
if (encodedJob) currentJob = JSON.parse(encodedJob);
return currentJob;
}
private async track() {
this.running = true;
return this.connection.redis.sadd(
this.connection.key("workers"),
this.name + ":" + this.stringQueues(),
);
}
private async ping() {
if (!this.running) return;
const name = this.name;
const nowSeconds = Math.round(new Date().getTime() / 1000);
this.emit("ping", nowSeconds);
const payload = JSON.stringify({
time: nowSeconds,
name: name,
queues: this.stringQueues(),
});
await this.connection.redis.set(
this.connection.key("worker", "ping", name),
payload,
);
}
private async untrack() {
const name = this.name;
const queues = this.stringQueues();
if (!this.connection || !this.connection.redis) {
return;
}
const response = await this.connection.redis
.multi()
.srem(this.connection.key("workers"), name + ":" + queues)
.del(this.connection.key("worker", "ping", name))
.del(this.connection.key("worker", name, queues))
.del(this.connection.key("worker", name, queues, "started"))
.del(this.connection.key("stat", "failed", name))
.del(this.connection.key("stat", "processed", name))
.exec();
response.forEach((res) => {
if (res[0] !== null) {
throw res[0];
}
});
}
async checkQueues() {
if (Array.isArray(this.queues) && this.queues.length > 0) {
this.ready = true;
}
if (
(this.queues[0] === "*" && this.queues.length === 1) ||
this.queues.length === 0 ||
this.originalQueue === "*"
) {
this.originalQueue = "*";
await this.untrack();
const response = await this.connection.redis.smembers(
this.connection.key("queues"),
);
this.queues = response ? response.sort() : [];
await this.track();
}
}
private failurePayload(err: Error, job: ParsedJob) {
return {
worker: this.name,
queue: this.queue,
payload: job,
exception: err.name,
error: err.message,
backtrace: err.stack ? err.stack.split("\n").slice(1) : null,
failed_at: new Date().toString(),
};
}
private stringQueues() {
if (this.queues.length === 0) {
return ["*"].join(",");
} else {
try {
return Array.isArray(this.queues) ? this.queues.join(",") : this.queues;
} catch (e) {
return "";
}
}
}
}
exports.Worker = Worker;