node-resque
Version:
an opinionated implementation of resque in node
329 lines (285 loc) • 8.7 kB
text/typescript
// To read notes about the leader locking scheme, check out:
// https://github.com/resque/resque-scheduler/blob/master/lib/resque/scheduler/locking.rb
import { EventEmitter } from "events";
import * as os from "os";
import { ErrorPayload } from "../types/errorPayload";
import { Job } from "../types/job";
import { Jobs } from "../types/jobs";
import { SchedulerOptions } from "../types/options";
import { Connection } from "./connection";
import { Queue } from "./queue";
export declare interface Scheduler {
options: SchedulerOptions;
jobs: Jobs;
name: string;
leader: boolean;
running: boolean;
processing: boolean;
queue: Queue;
connection: Connection;
timer: NodeJS.Timeout;
on(event: "start" | "end" | "poll" | "leader", cb: () => void): this;
on(
event: "cleanStuckWorker",
cb: (workerName: string, errorPayload: ErrorPayload, delta: number) => void
): this;
on(event: "error", cb: (error: Error, queue: string) => void): this;
on(event: "workingTimestamp", cb: (timestamp: number) => void): this;
on(
event: "transferredJob",
cb: (timestamp: number, job: Job<any>) => void
): this;
once(event: "start" | "end" | "poll" | "leader", cb: () => void): this;
once(
event: "cleanStuckWorker",
cb: (workerName: string, errorPayload: ErrorPayload, delta: number) => void
): this;
once(event: "error", cb: (error: Error, queue: string) => void): this;
once(event: "workingTimestamp", cb: (timestamp: number) => void): this;
once(
event: "transferredJob",
cb: (timestamp: number, job: Job<any>) => void
): this;
removeAllListeners(event: SchedulerEvent): this;
}
export type SchedulerEvent =
| "start"
| "end"
| "poll"
| "leader"
| "cleanStuckWorker"
| "error"
| "workingTimestamp"
| "transferredJob";
export class Scheduler extends EventEmitter {
constructor(options, jobs = {}) {
super();
const defaults = {
timeout: 5000, // in ms
stuckWorkerTimeout: 60 * 60 * 1000, // 60 minutes in ms
leaderLockTimeout: 60 * 3, // in seconds
name: os.hostname() + ":" + process.pid, // assumes only one worker per node process
retryStuckJobs: false,
};
for (const i in defaults) {
if (options[i] === null || options[i] === undefined) {
options[i] = defaults[i];
}
}
this.options = options;
this.name = this.options.name;
this.leader = false;
this.running = false;
this.processing = false;
this.queue = new Queue({ connection: options.connection }, jobs);
this.queue.on("error", (error) => {
this.emit("error", error);
});
}
async connect() {
await this.queue.connect();
this.connection = this.queue.connection;
}
async start() {
this.processing = false;
if (!this.running) {
this.emit("start");
this.running = true;
this.pollAgainLater();
}
}
async end() {
this.running = false;
clearTimeout(this.timer);
if (this.processing === false) {
if (
this.connection &&
(this.connection.connected === true ||
this.connection.connected === undefined ||
this.connection.connected === null)
) {
try {
await this.releaseLeaderLock();
} catch (error) {
this.emit("error", error);
}
}
try {
await this.queue.end();
this.emit("end");
} catch (error) {
this.emit("error", error);
}
} else {
return new Promise((resolve) => {
setTimeout(async () => {
await this.end();
resolve();
}, this.options.timeout / 2);
});
}
}
async poll() {
this.processing = true;
clearTimeout(this.timer);
const isLeader = await this.tryForLeader();
if (!isLeader) {
this.leader = false;
this.processing = false;
return this.pollAgainLater();
}
if (!this.leader) {
this.leader = true;
this.emit("leader");
}
this.emit("poll");
const timestamp = await this.nextDelayedTimestamp();
if (timestamp) {
this.emit("workingTimestamp", timestamp);
await this.enqueueDelayedItemsForTimestamp(parseInt(timestamp));
return this.poll();
} else {
await this.checkStuckWorkers();
this.processing = false;
return this.pollAgainLater();
}
}
private async pollAgainLater() {
if (this.running === true) {
this.timer = setTimeout(() => {
this.poll();
}, this.options.timeout);
}
}
private leaderKey() {
// TODO: Figure out if more work is needed
// return this.connection.key("resque_scheduler_master_lock");
return this.connection.key("resque_scheduler_leader_lock");
}
private async tryForLeader() {
const leaderKey = this.leaderKey();
if (!this.connection || !this.connection.redis) {
return;
}
const lockedByMe = await this.connection.redis.set(
leaderKey,
this.options.name,
"NX",
"EX",
this.options.leaderLockTimeout
);
if (lockedByMe && lockedByMe.toLowerCase() === "ok") {
return true;
}
const currentLeaderName = await this.connection.redis.get(leaderKey);
if (currentLeaderName === this.options.name) {
await this.connection.redis.expire(
leaderKey,
this.options.leaderLockTimeout
);
return true;
}
return false;
}
private async releaseLeaderLock() {
if (!this.connection || !this.connection.redis) {
return;
}
const isLeader = await this.tryForLeader();
if (!isLeader) {
return false;
}
const deleted = await this.connection.redis.del(this.leaderKey());
this.leader = false;
return deleted === 1 || deleted.toString() === "true";
}
private async nextDelayedTimestamp() {
const time = Math.round(new Date().getTime() / 1000);
const items = await this.connection.redis.zrangebyscore(
this.connection.key("delayed_queue_schedule"),
0,
time,
"LIMIT",
0,
1
);
if (items.length === 0) {
return;
}
return items[0];
}
private async enqueueDelayedItemsForTimestamp(timestamp: number) {
const job = await this.nextItemForTimestamp(timestamp);
if (job) {
await this.transfer(timestamp, job);
await this.enqueueDelayedItemsForTimestamp(timestamp);
} else {
await this.cleanupTimestamp(timestamp);
}
}
private async nextItemForTimestamp(timestamp: number) {
const key = this.connection.key("delayed:" + timestamp);
const job = await this.connection.redis.lpop(key);
await this.connection.redis.srem(
this.connection.key("timestamps:" + job),
"delayed:" + timestamp
);
return JSON.parse(job);
}
private async transfer(timestamp: number, job: any) {
await this.queue.enqueue(job.queue, job.class, job.args);
this.emit("transferredJob", timestamp, job);
}
private async cleanupTimestamp(timestamp: number) {
const key = this.connection.key("delayed:" + timestamp);
await this.connection.redis.watch(key);
await this.connection.redis.watch(
this.connection.key("delayed_queue_schedule")
);
const length = await this.connection.redis.llen(key);
if (length === 0) {
await this.connection.redis
.multi()
.del(key)
.zrem(this.connection.key("delayed_queue_schedule"), timestamp)
.exec();
}
await this.connection.redis.unwatch();
}
private async checkStuckWorkers() {
interface Payload {
time: number;
name: string;
}
if (!this.options.stuckWorkerTimeout) {
return;
}
const keys = await this.connection.getKeys(
this.connection.key("worker", "ping", "*")
);
const payloads: Array<Payload> = await Promise.all(
keys.map(async (k) => {
return JSON.parse(await this.connection.redis.get(k));
})
);
const nowInSeconds = Math.round(new Date().getTime() / 1000);
const stuckWorkerTimeoutInSeconds = Math.round(
this.options.stuckWorkerTimeout / 1000
);
for (let i in payloads) {
if (!payloads[i]) continue;
const { name, time } = payloads[i];
const delta = nowInSeconds - time;
if (delta > stuckWorkerTimeoutInSeconds) {
await this.forceCleanWorker(name, delta);
}
}
if (this.options.retryStuckJobs === true) {
await this.queue.retryStuckJobs();
}
}
async forceCleanWorker(workerName, delta) {
const errorPayload = await this.queue.forceCleanWorker(workerName);
this.emit("cleanStuckWorker", workerName, errorPayload, delta);
}
}