Skip to content
This repository was archived by the owner on Jul 4, 2025. It is now read-only.

Commit c7c9dbc

Browse files
committed
RedisMessageQueue
1 parent be0fe5d commit c7c9dbc

File tree

6 files changed

+236
-2
lines changed

6 files changed

+236
-2
lines changed

README.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,10 @@
88
[![GitHub Actions][GitHub Actions badge]][GitHub Actions]
99

1010
This package provides [Fedify]'s [`KvStore`] and [`MessageQueue`]
11-
implementations for Redis.
11+
implementations for Redis:
12+
13+
- [`RedisKvStore`]
14+
- [`RedisMessageQueue`]
1215

1316
[JSR]: https://jsr.io/@fedify/redis
1417
[JSR badge]: https://jsr.io/badges/@fedify/redis
@@ -19,6 +22,8 @@ implementations for Redis.
1922
[Fedify]: https://fedify.dev/
2023
[`KvStore`]: https://jsr.io/@fedify/fedify/doc/federation/~/KvStore
2124
[`MessageQueue`]: https://jsr.io/@fedify/fedify/doc/federation/~/MessageQueue
25+
[`RedisKvStore`]: https://jsr.io/@fedify/redis/doc/kv/~/RedisKvStore
26+
[`RedisMessageQueue`]: https://jsr.io/@fedify/redis/doc/kv/~/RedisMessageQueue
2227

2328

2429
Changelog

deno.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,12 @@
1010
"@deno/dnt": "jsr:@deno/dnt@^0.41.2",
1111
"@fedify/fedify": "jsr:@fedify/fedify@^0.10.0",
1212
"@std/assert": "jsr:@std/assert@^0.226.0",
13+
"@std/async": "jsr:@std/async@^0.224.2",
1314
"ioredis": "npm:ioredis@^5.4.0"
1415
},
16+
"unstable": [
17+
"temporal"
18+
],
1519
"exclude": [
1620
"npm"
1721
],

deno.lock

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

dnt.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,23 @@ await build({
4040
outDir: "./npm",
4141
entryPoints: ["./mod.ts"],
4242
importMap,
43-
shims: { deno: true },
43+
shims: {
44+
deno: true,
45+
custom: [
46+
{
47+
package: {
48+
name: "@js-temporal/polyfill",
49+
version: "^0.4.4",
50+
},
51+
globalNames: [
52+
{
53+
name: "Temporal",
54+
exportName: "Temporal",
55+
},
56+
],
57+
},
58+
],
59+
},
4460
typeCheck: "both",
4561
declaration: "separate",
4662
declarationMap: true,

src/mq.test.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { assertEquals, assertGreater } from "@std/assert";
2+
import { delay } from "@std/async/delay";
3+
import { Redis } from "ioredis";
4+
import { RedisMessageQueue } from "./mq.ts";
5+
6+
Deno.test("RedisMessageQueue", async (t) => {
7+
const mq = new RedisMessageQueue(() => new Redis(), {
8+
loopInterval: { seconds: 1 },
9+
});
10+
const mq2 = new RedisMessageQueue(() => new Redis(), {
11+
loopInterval: { seconds: 1 },
12+
});
13+
14+
const messages: string[] = [];
15+
mq.listen((message: string) => {
16+
messages.push(message);
17+
});
18+
mq2.listen((message: string) => {
19+
messages.push(message);
20+
});
21+
22+
await t.step("enqueue()", async () => {
23+
await mq.enqueue("Hello, world!");
24+
});
25+
26+
await waitFor(() => messages.length > 0, 15_000);
27+
28+
await t.step("listen()", () => {
29+
assertEquals(messages, ["Hello, world!"]);
30+
});
31+
32+
let started = 0;
33+
await t.step("enqueue() with delay", async () => {
34+
started = Date.now();
35+
await mq.enqueue(
36+
"Delayed message",
37+
{ delay: Temporal.Duration.from({ seconds: 3 }) },
38+
);
39+
});
40+
41+
await waitFor(() => messages.length > 1, 15_000);
42+
43+
await t.step("listen() with delay", () => {
44+
assertEquals(messages, ["Hello, world!", "Delayed message"]);
45+
assertGreater(Date.now() - started, 3_000);
46+
});
47+
48+
mq[Symbol.dispose]();
49+
mq2[Symbol.dispose]();
50+
});
51+
52+
async function waitFor(
53+
predicate: () => boolean,
54+
timeoutMs: number,
55+
): Promise<void> {
56+
const started = Date.now();
57+
while (!predicate()) {
58+
await delay(500);
59+
if (Date.now() - started > timeoutMs) {
60+
throw new Error("Timeout");
61+
}
62+
}
63+
}

src/mq.ts

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
// deno-lint-ignore-file no-explicit-any
2+
import type { MessageQueue, MessageQueueEnqueueOptions } from "@fedify/fedify";
3+
import type { Redis, RedisKey } from "ioredis";
4+
import { type Codec, JsonCodec } from "./codec.ts";
5+
6+
/**
7+
* Options for {@link RedisMessageQueue} class.
8+
*/
9+
export interface RedisMessageQueueOptions {
10+
/**
11+
* The unique identifier for the worker that is processing messages from the
12+
* queue. If this is not specified, a random identifier will be generated.
13+
* This is used to prevent multiple workers from processing the same message,
14+
* so it should be unique for each worker.
15+
*/
16+
workerId?: string;
17+
18+
/**
19+
* The Pub/Sub channel key to use for the message queue. `"fedify_channel"`
20+
* by default.
21+
*/
22+
channelKey?: RedisKey;
23+
24+
/**
25+
* The Sorted Set key to use for the delayed message queue. `"fedify_queue"`
26+
* by default.
27+
*/
28+
queueKey?: RedisKey;
29+
30+
/**
31+
* The key to use for locking the message queue. `"fedify_lock"` by default.
32+
*/
33+
lockKey?: RedisKey;
34+
35+
/**
36+
* The codec to use for encoding and decoding messages in the key-value store.
37+
* Defaults to {@link JsonCodec}.
38+
*/
39+
codec?: Codec;
40+
41+
/**
42+
* The interval at which to poll the message queue for delayed messages.
43+
* If this interval is too short, it may cause excessive load on the Redis
44+
* server. If it is too long, it may cause messages to be delayed longer
45+
* than expected.
46+
*
47+
* 5 seconds by default.
48+
*/
49+
loopInterval?: Temporal.DurationLike;
50+
}
51+
52+
/**
53+
* A message queue that uses Redis as the underlying storage.
54+
*/
55+
export class RedisMessageQueue implements MessageQueue, Disposable {
56+
#redis: Redis;
57+
#subRedis: Redis;
58+
#workerId: string;
59+
#channelKey: RedisKey;
60+
#queueKey: RedisKey;
61+
#lockKey: RedisKey;
62+
#codec: Codec;
63+
#loopInterval: Temporal.Duration;
64+
#loopHandle?: ReturnType<typeof setInterval>;
65+
66+
/**
67+
* Creates a new Redis message queue.
68+
* @param redis The Redis client factory.
69+
* @param options The options for the message queue.
70+
*/
71+
constructor(redis: () => Redis, options: RedisMessageQueueOptions = {}) {
72+
this.#redis = redis();
73+
this.#subRedis = redis();
74+
this.#workerId = options.workerId ?? crypto.randomUUID();
75+
this.#channelKey = options.channelKey ?? "fedify_channel";
76+
this.#queueKey = options.queueKey ?? "fedify_queue";
77+
this.#lockKey = options.lockKey ?? "fedify_lock";
78+
this.#codec = options.codec ?? new JsonCodec();
79+
this.#loopInterval = Temporal.Duration.from(
80+
options.loopInterval ?? { seconds: 5 },
81+
);
82+
}
83+
84+
async enqueue(
85+
message: any,
86+
options?: MessageQueueEnqueueOptions,
87+
): Promise<void> {
88+
const ts = options?.delay == null
89+
? 0
90+
: Temporal.Now.instant().add(options.delay).epochMilliseconds;
91+
const encodedMessage = this.#codec.encode(message);
92+
await this.#redis.zadd(this.#queueKey, ts, encodedMessage);
93+
if (ts < 1) this.#redis.publish(this.#channelKey, "");
94+
}
95+
96+
async #poll(): Promise<any | undefined> {
97+
const result = await this.#redis.setnx(this.#lockKey, this.#workerId);
98+
if (result < 1) return;
99+
await this.#redis.expire(
100+
this.#lockKey,
101+
this.#loopInterval.total({ unit: "seconds" }) * 2,
102+
);
103+
const messages = await this.#redis.zrangebyscoreBuffer(
104+
this.#queueKey,
105+
0,
106+
Temporal.Now.instant().epochMilliseconds,
107+
);
108+
try {
109+
if (messages.length < 1) return;
110+
const message = messages[0];
111+
await this.#redis.zrem(this.#queueKey, message);
112+
return this.#codec.decode(message);
113+
} finally {
114+
await this.#redis.del(this.#lockKey);
115+
}
116+
}
117+
118+
listen(handler: (message: any) => void | Promise<void>): void {
119+
if (this.#loopHandle != null) {
120+
throw new Error("Already listening");
121+
}
122+
this.#loopHandle = setInterval(async () => {
123+
const message = await this.#poll();
124+
if (message === undefined) return;
125+
await handler(message);
126+
}, this.#loopInterval.total({ unit: "milliseconds" }));
127+
this.#subRedis.subscribe(this.#channelKey, () => {
128+
this.#subRedis.on("message", async () => {
129+
const message = await this.#poll();
130+
if (message === undefined) return;
131+
await handler(message);
132+
});
133+
});
134+
}
135+
136+
[Symbol.dispose](): void {
137+
clearInterval(this.#loopHandle);
138+
this.#redis.disconnect();
139+
this.#subRedis.disconnect();
140+
}
141+
}

0 commit comments

Comments
 (0)