UNPKG

@fedify/fedify

Version:

An ActivityPub server framework

478 lines (477 loc) • 16.6 kB
import { Temporal } from "@js-temporal/polyfill"; import "urlpattern-polyfill"; globalThis.addEventListener = () => {}; import { t as assertEquals } from "../assert_equals-Ew3jOFa3.mjs"; import { n as assertGreater, r as assertGreaterOrEqual } from "../std__assert-CRDpx_HF.mjs"; import { n as assertFalse } from "../assert_rejects-B-qJtC9Z.mjs"; import { t as assert } from "../assert-DikXweDx.mjs"; import { test } from "@fedify/fixture"; import { delay } from "es-toolkit"; //#region src/federation/mq.ts /** * A message queue that processes messages in the same process. * Do not use this in production as it does neither persist messages nor * distribute them across multiple processes. * * @since 0.5.0 */ var InProcessMessageQueue = class { #messages; #monitors; #pollIntervalMs; /** * Tracks which ordering keys are currently being processed to ensure * sequential processing for messages with the same key. */ #processingKeys; /** * In-process message queue does not provide native retry mechanisms. * @since 1.7.0 */ nativeRetrial = false; /** * Constructs a new {@link InProcessMessageQueue} with the given options. * @param options Additional options for the in-process message queue. */ constructor(options = {}) { this.#messages = []; this.#monitors = {}; this.#pollIntervalMs = Temporal.Duration.from(options.pollInterval ?? { seconds: 5 }).total("millisecond"); this.#processingKeys = /* @__PURE__ */ new Set(); } enqueue(message, options) { const delay = options?.delay == null ? 0 : Math.max(options.delay.total("millisecond"), 0); if (delay > 0) { setTimeout(() => this.enqueue(message, { ...options, delay: void 0 }), delay); return Promise.resolve(); } const orderingKey = options?.orderingKey ?? null; this.#messages.push({ message, orderingKey }); for (const monitorId in this.#monitors) this.#monitors[monitorId](); return Promise.resolve(); } enqueueMany(messages, options) { if (messages.length === 0) return Promise.resolve(); const delay = options?.delay == null ? 0 : Math.max(options.delay.total("millisecond"), 0); if (delay > 0) { setTimeout(() => this.enqueueMany(messages, { ...options, delay: void 0 }), delay); return Promise.resolve(); } const orderingKey = options?.orderingKey ?? null; for (const message of messages) this.#messages.push({ message, orderingKey }); for (const monitorId in this.#monitors) this.#monitors[monitorId](); return Promise.resolve(); } async listen(handler, options = {}) { const signal = options.signal; while (signal == null || !signal.aborted) { const idx = this.#messages.findIndex((m) => m.orderingKey == null || !this.#processingKeys.has(m.orderingKey)); if (idx >= 0) { const { message, orderingKey } = this.#messages.splice(idx, 1)[0]; if (orderingKey != null) this.#processingKeys.add(orderingKey); try { await handler(message); } finally { if (orderingKey != null) this.#processingKeys.delete(orderingKey); } } else if (this.#messages.length === 0) await this.#wait(this.#pollIntervalMs, signal); else await this.#wait(10, signal); } } #wait(ms, signal) { let timer = null; return Promise.any([new Promise((resolve) => { signal?.addEventListener("abort", () => { if (timer != null) clearTimeout(timer); resolve(); }, { once: true }); const monitorId = crypto.randomUUID(); this.#monitors[monitorId] = () => { delete this.#monitors[monitorId]; if (timer != null) clearTimeout(timer); resolve(); }; }), new Promise((resolve) => timer = setTimeout(resolve, ms))]); } }; /** * A message queue that processes messages in parallel. It takes another * {@link MessageQueue}, and processes messages in parallel up to a certain * number of workers. * * Actually, it's rather a decorator than a queue itself. * * Note that the workers do not run in truly parallel, in the sense that they * are not running in separate threads or processes. They are running in the * same process, but are scheduled to run in parallel. Hence, this is useful * for I/O-bound tasks, but not for CPU-bound tasks, which is okay for Fedify's * workloads. * * When using `ParallelMessageQueue`, the ordering guarantee is preserved * *only if* the underlying queue implementation delivers messages in a wrapper * format that includes the `__fedify_ordering_key__` property. Currently, * only `DenoKvMessageQueue` and `WorkersMessageQueue` use this format. * For other queue implementations (e.g., `InProcessMessageQueue`, * `RedisMessageQueue`, `PostgresMessageQueue`, `SqliteMessageQueue`, * `AmqpMessageQueue`), the ordering key cannot be detected by * `ParallelMessageQueue`, so ordering guarantees are handled by those * implementations directly rather than at the `ParallelMessageQueue` level. * * Messages with the same ordering key will never be processed concurrently * by different workers, ensuring sequential processing within each key. * Messages with different ordering keys (or no ordering key) can still be * processed in parallel. * * @since 1.0.0 */ var ParallelMessageQueue = class ParallelMessageQueue { queue; workers; /** * Inherits the native retry capability from the wrapped queue. * @since 1.7.0 */ nativeRetrial; /** * Tracks which ordering keys are currently being processed to ensure * sequential processing for messages with the same key. */ #processingKeys = /* @__PURE__ */ new Set(); /** * Pending messages waiting for their ordering key to become available. */ #pendingMessages = []; /** * Constructs a new {@link ParallelMessageQueue} with the given queue and * number of workers. * @param queue The message queue to use under the hood. Note that * {@link ParallelMessageQueue} cannot be nested. * @param workers The number of workers to process messages in parallel. * @throws {TypeError} If the given queue is an instance of * {@link ParallelMessageQueue}. */ constructor(queue, workers) { if (queue instanceof ParallelMessageQueue) throw new TypeError("Cannot nest ParallelMessageQueue."); this.queue = queue; this.workers = workers; this.nativeRetrial = queue.nativeRetrial; } enqueue(message, options) { return this.queue.enqueue(message, options); } async enqueueMany(messages, options) { if (this.queue.enqueueMany == null) { const errors = (await Promise.allSettled(messages.map((message) => this.queue.enqueue(message, options)))).filter((r) => r.status === "rejected").map((r) => r.reason); if (errors.length > 1) throw new AggregateError(errors, "Failed to enqueue messages."); else if (errors.length === 1) throw errors[0]; return; } await this.queue.enqueueMany(messages, options); } /** * Extracts ordering key from a message if present. * * This method only works for queue implementations that deliver messages * in the wrapper format with `__fedify_ordering_key__` property. Currently, * only `DenoKvMessageQueue` and `WorkersMessageQueue` use this format. * * For other queue implementations (`InProcessMessageQueue`, * `RedisMessageQueue`, `PostgresMessageQueue`, `SqliteMessageQueue`, * `AmqpMessageQueue`), messages are delivered as raw payloads without the * wrapper, so the ordering key cannot be detected here. Those * implementations handle ordering guarantees internally. */ #extractOrderingKey(message) { if (message != null && typeof message === "object") { if ("__fedify_ordering_key__" in message) return message.__fedify_ordering_key__; } } listen(handler, options = {}) { const workers = /* @__PURE__ */ new Map(); return this.queue.listen(async (message) => { while (workers.size >= this.workers) { const consumedId = await Promise.any(workers.values()); workers.delete(consumedId); } const workerId = crypto.randomUUID(); const orderingKey = this.#extractOrderingKey(message); if (orderingKey != null && this.#processingKeys.has(orderingKey)) await new Promise((resolve) => { this.#pendingMessages.push({ message, orderingKey, resolve }); }); if (orderingKey != null) this.#processingKeys.add(orderingKey); const promise = this.#work(workerId, handler, message, orderingKey); workers.set(workerId, promise); }, options); } async #work(workerId, handler, message, orderingKey) { await this.#sleep(0); try { await handler(message); } finally { if (orderingKey != null) { this.#processingKeys.delete(orderingKey); const pendingIdx = this.#pendingMessages.findIndex((p) => p.orderingKey === orderingKey); if (pendingIdx >= 0) this.#pendingMessages.splice(pendingIdx, 1)[0].resolve(); } } return workerId; } #sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } }; //#endregion //#region src/federation/mq.test.ts async function disposeMessageQueue(mq) { if (Symbol.asyncDispose in mq) { const dispose = mq[Symbol.asyncDispose]; if (typeof dispose === "function") { await dispose.call(mq); return; } } if (Symbol.dispose in mq) { const dispose = mq[Symbol.dispose]; if (typeof dispose === "function") dispose.call(mq); } } test("InProcessMessageQueue", async (t) => { const mq = new InProcessMessageQueue(); await t.step("nativeRetrial property", () => { assertFalse(mq.nativeRetrial); }); const messages = []; const controller = new AbortController(); const listening = mq.listen((message) => { messages.push(message); }, controller); await t.step("enqueue()", async () => { await mq.enqueue("Hello, world!"); }); await waitFor(() => messages.length > 0, 15e3); await t.step("listen()", () => { assertEquals(messages, ["Hello, world!"]); }); let started = 0; await t.step("enqueue() with delay", async () => { started = Date.now(); await mq.enqueue("Delayed message", { delay: Temporal.Duration.from({ seconds: 3 }) }); assertEquals(messages, ["Hello, world!"]); }); await waitFor(() => messages.length > 1, 15e3); await t.step("listen() with delay", () => { assertEquals(messages, ["Hello, world!", "Delayed message"]); assertGreater(Date.now() - started, 3e3); }); while (messages.length > 0) messages.pop(); await t.step("enqueueMany()", async () => { const testMessages = Array.from({ length: 5 }, (_, i) => `Batch message ${i}!`); await mq.enqueueMany(testMessages); }); await waitFor(() => messages.length >= 5, 15e3); await t.step("listen() [multiple]", () => { assertEquals(messages.length, 5); for (let i = 0; i < 5; i++) assertEquals(messages[i], `Batch message ${i}!`); }); while (messages.length > 0) messages.pop(); started = 0; await t.step("enqueueMany() with delay", async () => { started = Date.now(); const testMessages = Array.from({ length: 3 }, (_, i) => `Delayed batch ${i}!`); await mq.enqueueMany(testMessages, { delay: Temporal.Duration.from({ seconds: 2 }) }); assertEquals(messages.length, 0); }); await waitFor(() => messages.length >= 3, 15e3); await t.step("listen() [delayed multiple]", () => { assertEquals(messages.length, 3); assertGreater(Date.now() - started, 2e3); for (let i = 0; i < 3; i++) assertEquals(messages[i], `Delayed batch ${i}!`); }); controller.abort(); await listening; }); test("InProcessMessageQueue orderingKey", async (t) => { const mq = new InProcessMessageQueue(); const orderTracker = { keyA: [], keyB: [], noKey: [] }; const allMessages = []; const controller = new AbortController(); const listening = mq.listen((message) => { allMessages.push(message); const trackKey = message.key ?? "noKey"; if (trackKey in orderTracker) orderTracker[trackKey].push(message.value); }, controller); await t.step("enqueue with ordering key", async () => { await mq.enqueue({ key: "keyA", value: 1 }, { orderingKey: "keyA" }); await mq.enqueue({ key: "keyB", value: 1 }, { orderingKey: "keyB" }); await mq.enqueue({ key: "keyA", value: 2 }, { orderingKey: "keyA" }); await mq.enqueue({ key: "keyB", value: 2 }, { orderingKey: "keyB" }); await mq.enqueue({ key: "keyA", value: 3 }, { orderingKey: "keyA" }); await mq.enqueue({ key: "keyB", value: 3 }, { orderingKey: "keyB" }); await mq.enqueue({ key: null, value: 1 }); await mq.enqueue({ key: null, value: 2 }); }); await waitFor(() => allMessages.length >= 8, 3e4); await t.step("verify ordering key order", () => { assertEquals(orderTracker.keyA, [ 1, 2, 3 ], "Messages with orderingKey 'keyA' should be processed in order"); assertEquals(orderTracker.keyB, [ 1, 2, 3 ], "Messages with orderingKey 'keyB' should be processed in order"); }); await t.step("verify messages without ordering key", () => { assertEquals(orderTracker.noKey.length, 2, "Messages without ordering key should all be received"); assert(orderTracker.noKey.includes(1) && orderTracker.noKey.includes(2), "Messages without ordering key should contain values 1 and 2"); }); controller.abort(); await listening; }); test("MessageQueue.nativeRetrial", async (t) => { if ("Deno" in globalThis && "openKv" in globalThis.Deno && typeof globalThis.Deno.openKv === "function") await t.step("DenoKvMessageQueue", async () => { const packageName = () => "@fedify/denokv"; const { DenoKvMessageQueue } = await import(packageName()); const mq = new DenoKvMessageQueue(await globalThis.Deno.openKv(":memory:")); assert(mq.nativeRetrial); await disposeMessageQueue(mq); }); await t.step("WorkersMessageQueue mock", () => { class MockQueue { send(_message, _options) { return Promise.resolve(); } sendBatch(_messages, _options) { return Promise.resolve(); } } class TestWorkersMessageQueue { nativeRetrial = true; #queue; constructor(queue) { this.#queue = queue; } enqueue(message) { return this.#queue.send(message); } enqueueMany(messages) { return this.#queue.sendBatch(messages); } listen() { throw new TypeError("WorkersMessageQueue does not support listen()"); } } assert(new TestWorkersMessageQueue(new MockQueue()).nativeRetrial); }); }); const queues = { InProcessMessageQueue: () => Promise.resolve(new InProcessMessageQueue()) }; if ("Deno" in globalThis && "openKv" in globalThis.Deno && typeof globalThis.Deno.openKv === "function") { const packageName = () => "@fedify/denokv"; const { DenoKvMessageQueue } = await import(packageName()); queues.DenoKvMessageQueue = async () => new DenoKvMessageQueue(await globalThis.Deno.openKv(":memory:")); } for (const mqName in queues) test({ name: `ParallelMessageQueue [${mqName}]`, ignore: "Bun" in globalThis, async fn(t) { const mq = await queues[mqName](); const workers = new ParallelMessageQueue(mq, 5); await t.step("nativeRetrial property inheritance", () => { assertEquals(workers.nativeRetrial, mq.nativeRetrial); }); const messages = []; const controller = new AbortController(); const listening = workers.listen(async (message) => { for (let i = 0, cnt = 5 + Math.random() * 5; i < cnt; i++) await delay(250); messages.push(message); }, controller); await t.step("enqueue() [single]", async () => { await workers.enqueue("Hello, world!"); }); await waitFor(() => messages.length > 0, 15e3); await t.step("listen() [single]", () => { assertEquals(messages, ["Hello, world!"]); }); messages.pop(); await t.step("enqueue() [multiple]", async () => { for (let i = 0; i < 20; i++) await workers.enqueue(`Hello, ${i}!`); }); await t.step("listen() [multiple]", async () => { await delay(3e3); assertGreaterOrEqual(messages.length, 5); await waitFor(() => messages.length >= 20, 15e3); assertEquals(messages.length, 20); }); await waitFor(() => messages.length >= 20, 15e3); while (messages.length > 0) messages.pop(); await t.step("enqueueMany()", async () => { const messages = Array.from({ length: 20 }, (_, i) => `Hello, ${i}!`); await workers.enqueueMany(messages); }); await t.step("listen() [multiple]", async () => { await delay(3e3); assertGreaterOrEqual(messages.length, 5); await waitFor(() => messages.length >= 20, 15e3); assertEquals(messages.length, 20); }); await waitFor(() => messages.length >= 20, 15e3); controller.abort(); await listening; await disposeMessageQueue(mq); } }); async function waitFor(predicate, timeoutMs) { const started = Date.now(); while (!predicate()) { await delay(500); if (Date.now() - started > timeoutMs) throw new Error("Timeout"); } } //#endregion export {};