UNPKG

@fedify/redis

Version:

Redis drivers for Fedify

170 lines (166 loc) 5.76 kB
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 };