@fedify/redis
Version:
Redis drivers for Fedify
170 lines (166 loc) • 5.76 kB
JavaScript
import { Temporal } from "@js-temporal/polyfill";
import { JsonCodec } from "./codec.js";
import { getLogger } from "@logtape/logtape";
//#region src/mq.ts
const logger = getLogger([
"fedify",
"redis",
"mq"
]);
/**
* A message queue that uses Redis as the underlying storage.
*
* @example
* ```ts ignore
* import { createFederation } from "@fedify/fedify";
* import { RedisMessageQueue } from "@fedify/redis";
* import { Redis, Cluster } from "ioredis";
*
* // Using a standalone Redis instance:
* const federation = createFederation({
* // ...
* queue: new RedisMessageQueue(() => new Redis()),
* });
*
* // Using a Redis Cluster:
* const federation = createFederation({
* // ...
* queue: new RedisMessageQueue(() => new Cluster([
* { host: "127.0.0.1", port: 7000 },
* { host: "127.0.0.1", port: 7001 },
* { host: "127.0.0.1", port: 7002 },
* ])),
* });
* ```
*/
var RedisMessageQueue = class {
#redis;
#subRedis;
#workerId;
#channelKey;
#queueKey;
#lockKey;
#codec;
#pollIntervalMs;
#loopHandle;
/**
* Creates a new Redis message queue.
* @param redis The Redis client factory.
* @param options The options for the message queue.
*/
constructor(redis, options = {}) {
this.#redis = redis();
this.#subRedis = redis();
this.#workerId = options.workerId ?? crypto.randomUUID();
this.#channelKey = options.channelKey ?? "fedify_channel";
this.#queueKey = options.queueKey ?? "fedify_queue";
this.#lockKey = options.lockKey ?? "fedify_lock";
this.#codec = options.codec ?? new JsonCodec();
this.#pollIntervalMs = Temporal.Duration.from(options.pollInterval ?? { seconds: 5 }).total("millisecond");
}
async enqueue(message, options) {
const ts = options?.delay == null ? 0 : Temporal.Now.instant().add(options.delay).epochMilliseconds;
const encodedMessage = this.#codec.encode([crypto.randomUUID(), message]);
await this.#redis.zadd(this.#queueKey, ts, encodedMessage);
if (ts < 1) this.#redis.publish(this.#channelKey, "");
}
async enqueueMany(messages, options) {
if (messages.length === 0) return;
const ts = options?.delay == null ? 0 : Temporal.Now.instant().add(options.delay).epochMilliseconds;
const multi = this.#redis.multi();
for (const message of messages) {
const encodedMessage = this.#codec.encode([crypto.randomUUID(), message]);
multi.zadd(this.#queueKey, ts, encodedMessage);
}
await multi.exec();
if (ts < 1) this.#redis.publish(this.#channelKey, "");
}
async #poll() {
logger.debug("Polling for messages...");
const result = await this.#redis.set(this.#lockKey, this.#workerId, "EX", Math.floor(this.#pollIntervalMs / 1e3 * 2), "NX");
if (result == null) {
logger.debug("Another worker is already processing messages; skipping...");
return;
}
logger.debug("Acquired lock; processing messages...");
const messages = await this.#redis.zrangebyscoreBuffer(this.#queueKey, 0, Temporal.Now.instant().epochMilliseconds);
logger.debug("Found {messages} messages to process.", { messages: messages.length });
try {
if (messages.length < 1) return;
const encodedMessage = messages[0];
await this.#redis.zrem(this.#queueKey, encodedMessage);
const [_, message] = this.#codec.decode(encodedMessage);
return message;
} finally {
await this.#redis.del(this.#lockKey);
}
}
async listen(handler, options = {}) {
if (this.#loopHandle != null) throw new Error("Already listening");
const signal = options.signal;
const poll = async () => {
while (!signal?.aborted) {
let message;
try {
message = await this.#poll();
} catch (error) {
logger.error("Error polling for messages: {error}", { error });
return;
}
if (message === void 0) return;
await handler(message);
}
};
const promise = this.#subRedis.subscribe(this.#channelKey, () => {
/**
* Cast to Redis for event methods. Both Redis and Cluster extend EventEmitter
* and get the same methods via applyMixin at runtime, but their TypeScript
* interfaces are incompatible:
* - Redis declares specific overloads: on(event: "message", cb: (channel, message) => void)
* - Cluster only has generic: on(event: string | symbol, listener: Function)
*
* This makes the union type Redis | Cluster incompatible for these method calls.
* The cast is safe because both classes use applyMixin(Class, EventEmitter) which
* copies all EventEmitter prototype methods, giving them identical pub/sub functionality.
*
* @see https://github.com/redis/ioredis/blob/main/lib/Redis.ts#L863 (has specific overloads)
* @see https://github.com/redis/ioredis/blob/main/lib/cluster/index.ts#L1110 (empty interface)
*/
const subRedis = this.#subRedis;
subRedis.on("message", poll);
signal?.addEventListener("abort", () => {
subRedis.off("message", poll);
});
});
signal?.addEventListener("abort", () => {
for (const timeout of timeouts) clearTimeout(timeout);
});
const timeouts = /* @__PURE__ */ new Set();
while (!signal?.aborted) {
let timeout;
await new Promise((resolve) => {
signal?.addEventListener("abort", resolve);
timeout = setTimeout(() => {
signal?.removeEventListener("abort", resolve);
resolve(0);
}, this.#pollIntervalMs);
timeouts.add(timeout);
});
if (timeout != null) timeouts.delete(timeout);
await poll();
}
return await new Promise((resolve) => {
signal?.addEventListener("abort", () => {
promise.catch(() => resolve()).then(() => resolve());
});
promise.catch(() => resolve()).then(() => resolve());
});
}
[Symbol.dispose]() {
clearInterval(this.#loopHandle);
this.#redis.disconnect();
this.#subRedis.disconnect();
}
};
//#endregion
export { RedisMessageQueue };