@fedify/fedify
Version:
An ActivityPub server framework
478 lines (477 loc) • 16.6 kB
JavaScript
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 {};