@fedify/redis 2.0.0-dev.241 → 2.0.0-dev.279

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/dist/mq.cjs +67 -14
  2. package/dist/mq.js +67 -14
  3. package/package.json +8 -6
package/dist/mq.cjs CHANGED
@@ -47,6 +47,8 @@ var RedisMessageQueue = class {
47
47
  #codec;
48
48
  #pollIntervalMs;
49
49
  #loopHandle;
50
+ #lastTimestamp = 0;
51
+ #sequenceInMs = 0;
50
52
  /**
51
53
  * Creates a new Redis message queue.
52
54
  * @param redis The Redis client factory.
@@ -62,22 +64,53 @@ var RedisMessageQueue = class {
62
64
  this.#codec = options.codec ?? new require_codec.JsonCodec();
63
65
  this.#pollIntervalMs = Temporal.Duration.from(options.pollInterval ?? { seconds: 5 }).total("millisecond");
64
66
  }
67
+ /**
68
+ * Returns a monotonically increasing timestamp to ensure message ordering.
69
+ * When multiple messages are enqueued in the same millisecond, a fractional
70
+ * sequence number is added to preserve insertion order.
71
+ */
72
+ #getMonotonicTimestamp(baseTimestamp) {
73
+ if (baseTimestamp === this.#lastTimestamp) this.#sequenceInMs++;
74
+ else {
75
+ this.#lastTimestamp = baseTimestamp;
76
+ this.#sequenceInMs = 0;
77
+ }
78
+ return baseTimestamp + this.#sequenceInMs * .001;
79
+ }
65
80
  async enqueue(message, options) {
66
- const ts = options?.delay == null ? 0 : Temporal.Now.instant().add(options.delay).epochMilliseconds;
67
- const encodedMessage = this.#codec.encode([crypto.randomUUID(), message]);
81
+ const now = Temporal.Now.instant().epochMilliseconds;
82
+ const baseTs = options?.delay == null ? now : now + options.delay.total("millisecond");
83
+ const ts = this.#getMonotonicTimestamp(baseTs);
84
+ const encodedMessage = this.#codec.encode([
85
+ crypto.randomUUID(),
86
+ message,
87
+ options?.orderingKey
88
+ ]);
68
89
  await this.#redis.zadd(this.#queueKey, ts, encodedMessage);
69
- if (ts < 1) this.#redis.publish(this.#channelKey, "");
90
+ if (baseTs <= now) this.#redis.publish(this.#channelKey, "");
70
91
  }
71
92
  async enqueueMany(messages, options) {
72
93
  if (messages.length === 0) return;
73
- const ts = options?.delay == null ? 0 : Temporal.Now.instant().add(options.delay).epochMilliseconds;
94
+ const now = Temporal.Now.instant().epochMilliseconds;
95
+ const baseTs = options?.delay == null ? now : now + options.delay.total("millisecond");
74
96
  const multi = this.#redis.multi();
75
97
  for (const message of messages) {
76
- const encodedMessage = this.#codec.encode([crypto.randomUUID(), message]);
98
+ const ts = this.#getMonotonicTimestamp(baseTs);
99
+ const encodedMessage = this.#codec.encode([
100
+ crypto.randomUUID(),
101
+ message,
102
+ options?.orderingKey
103
+ ]);
77
104
  multi.zadd(this.#queueKey, ts, encodedMessage);
78
105
  }
79
106
  await multi.exec();
80
- if (ts < 1) this.#redis.publish(this.#channelKey, "");
107
+ if (baseTs <= now) this.#redis.publish(this.#channelKey, "");
108
+ }
109
+ /**
110
+ * Returns the Redis key used to lock a specific ordering key.
111
+ */
112
+ #getOrderingLockKey(orderingKey) {
113
+ return `${this.#lockKey}:ordering:${orderingKey}`;
81
114
  }
82
115
  async #poll() {
83
116
  logger.debug("Polling for messages...");
@@ -91,10 +124,25 @@ var RedisMessageQueue = class {
91
124
  logger.debug("Found {messages} messages to process.", { messages: messages.length });
92
125
  try {
93
126
  if (messages.length < 1) return;
94
- const encodedMessage = messages[0];
95
- await this.#redis.zrem(this.#queueKey, encodedMessage);
96
- const [_, message] = this.#codec.decode(encodedMessage);
97
- return message;
127
+ for (const encodedMessage of messages) {
128
+ const decoded = this.#codec.decode(encodedMessage);
129
+ const orderingKey = decoded[2];
130
+ if (orderingKey != null) {
131
+ const orderingLockKey = this.#getOrderingLockKey(orderingKey);
132
+ const lockResult = await this.#redis.set(orderingLockKey, this.#workerId, "EX", 60, "NX");
133
+ if (lockResult == null) continue;
134
+ }
135
+ const removed = await this.#redis.zrem(this.#queueKey, encodedMessage);
136
+ if (removed === 0) {
137
+ if (orderingKey != null) await this.#redis.del(this.#getOrderingLockKey(orderingKey));
138
+ continue;
139
+ }
140
+ return {
141
+ message: decoded[1],
142
+ orderingKey
143
+ };
144
+ }
145
+ return;
98
146
  } finally {
99
147
  await this.#redis.del(this.#lockKey);
100
148
  }
@@ -104,15 +152,20 @@ var RedisMessageQueue = class {
104
152
  const signal = options.signal;
105
153
  const poll = async () => {
106
154
  while (!signal?.aborted) {
107
- let message;
155
+ let result;
108
156
  try {
109
- message = await this.#poll();
157
+ result = await this.#poll();
110
158
  } catch (error) {
111
159
  logger.error("Error polling for messages: {error}", { error });
112
160
  return;
113
161
  }
114
- if (message === void 0) return;
115
- await handler(message);
162
+ if (result === void 0) return;
163
+ const { message, orderingKey } = result;
164
+ try {
165
+ await handler(message);
166
+ } finally {
167
+ if (orderingKey != null) await this.#redis.del(this.#getOrderingLockKey(orderingKey));
168
+ }
116
169
  }
117
170
  };
118
171
  await this.#subRedis.subscribe(this.#channelKey);
package/dist/mq.js CHANGED
@@ -46,6 +46,8 @@ var RedisMessageQueue = class {
46
46
  #codec;
47
47
  #pollIntervalMs;
48
48
  #loopHandle;
49
+ #lastTimestamp = 0;
50
+ #sequenceInMs = 0;
49
51
  /**
50
52
  * Creates a new Redis message queue.
51
53
  * @param redis The Redis client factory.
@@ -61,22 +63,53 @@ var RedisMessageQueue = class {
61
63
  this.#codec = options.codec ?? new JsonCodec();
62
64
  this.#pollIntervalMs = Temporal.Duration.from(options.pollInterval ?? { seconds: 5 }).total("millisecond");
63
65
  }
66
+ /**
67
+ * Returns a monotonically increasing timestamp to ensure message ordering.
68
+ * When multiple messages are enqueued in the same millisecond, a fractional
69
+ * sequence number is added to preserve insertion order.
70
+ */
71
+ #getMonotonicTimestamp(baseTimestamp) {
72
+ if (baseTimestamp === this.#lastTimestamp) this.#sequenceInMs++;
73
+ else {
74
+ this.#lastTimestamp = baseTimestamp;
75
+ this.#sequenceInMs = 0;
76
+ }
77
+ return baseTimestamp + this.#sequenceInMs * .001;
78
+ }
64
79
  async enqueue(message, options) {
65
- const ts = options?.delay == null ? 0 : Temporal.Now.instant().add(options.delay).epochMilliseconds;
66
- const encodedMessage = this.#codec.encode([crypto.randomUUID(), message]);
80
+ const now = Temporal.Now.instant().epochMilliseconds;
81
+ const baseTs = options?.delay == null ? now : now + options.delay.total("millisecond");
82
+ const ts = this.#getMonotonicTimestamp(baseTs);
83
+ const encodedMessage = this.#codec.encode([
84
+ crypto.randomUUID(),
85
+ message,
86
+ options?.orderingKey
87
+ ]);
67
88
  await this.#redis.zadd(this.#queueKey, ts, encodedMessage);
68
- if (ts < 1) this.#redis.publish(this.#channelKey, "");
89
+ if (baseTs <= now) this.#redis.publish(this.#channelKey, "");
69
90
  }
70
91
  async enqueueMany(messages, options) {
71
92
  if (messages.length === 0) return;
72
- const ts = options?.delay == null ? 0 : Temporal.Now.instant().add(options.delay).epochMilliseconds;
93
+ const now = Temporal.Now.instant().epochMilliseconds;
94
+ const baseTs = options?.delay == null ? now : now + options.delay.total("millisecond");
73
95
  const multi = this.#redis.multi();
74
96
  for (const message of messages) {
75
- const encodedMessage = this.#codec.encode([crypto.randomUUID(), message]);
97
+ const ts = this.#getMonotonicTimestamp(baseTs);
98
+ const encodedMessage = this.#codec.encode([
99
+ crypto.randomUUID(),
100
+ message,
101
+ options?.orderingKey
102
+ ]);
76
103
  multi.zadd(this.#queueKey, ts, encodedMessage);
77
104
  }
78
105
  await multi.exec();
79
- if (ts < 1) this.#redis.publish(this.#channelKey, "");
106
+ if (baseTs <= now) this.#redis.publish(this.#channelKey, "");
107
+ }
108
+ /**
109
+ * Returns the Redis key used to lock a specific ordering key.
110
+ */
111
+ #getOrderingLockKey(orderingKey) {
112
+ return `${this.#lockKey}:ordering:${orderingKey}`;
80
113
  }
81
114
  async #poll() {
82
115
  logger.debug("Polling for messages...");
@@ -90,10 +123,25 @@ var RedisMessageQueue = class {
90
123
  logger.debug("Found {messages} messages to process.", { messages: messages.length });
91
124
  try {
92
125
  if (messages.length < 1) return;
93
- const encodedMessage = messages[0];
94
- await this.#redis.zrem(this.#queueKey, encodedMessage);
95
- const [_, message] = this.#codec.decode(encodedMessage);
96
- return message;
126
+ for (const encodedMessage of messages) {
127
+ const decoded = this.#codec.decode(encodedMessage);
128
+ const orderingKey = decoded[2];
129
+ if (orderingKey != null) {
130
+ const orderingLockKey = this.#getOrderingLockKey(orderingKey);
131
+ const lockResult = await this.#redis.set(orderingLockKey, this.#workerId, "EX", 60, "NX");
132
+ if (lockResult == null) continue;
133
+ }
134
+ const removed = await this.#redis.zrem(this.#queueKey, encodedMessage);
135
+ if (removed === 0) {
136
+ if (orderingKey != null) await this.#redis.del(this.#getOrderingLockKey(orderingKey));
137
+ continue;
138
+ }
139
+ return {
140
+ message: decoded[1],
141
+ orderingKey
142
+ };
143
+ }
144
+ return;
97
145
  } finally {
98
146
  await this.#redis.del(this.#lockKey);
99
147
  }
@@ -103,15 +151,20 @@ var RedisMessageQueue = class {
103
151
  const signal = options.signal;
104
152
  const poll = async () => {
105
153
  while (!signal?.aborted) {
106
- let message;
154
+ let result;
107
155
  try {
108
- message = await this.#poll();
156
+ result = await this.#poll();
109
157
  } catch (error) {
110
158
  logger.error("Error polling for messages: {error}", { error });
111
159
  return;
112
160
  }
113
- if (message === void 0) return;
114
- await handler(message);
161
+ if (result === void 0) return;
162
+ const { message, orderingKey } = result;
163
+ try {
164
+ await handler(message);
165
+ } finally {
166
+ if (orderingKey != null) await this.#redis.del(this.#getOrderingLockKey(orderingKey));
167
+ }
115
168
  }
116
169
  };
117
170
  await this.#subRedis.subscribe(this.#channelKey);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fedify/redis",
3
- "version": "2.0.0-dev.241+58c8126c",
3
+ "version": "2.0.0-dev.279+ce1bdc22",
4
4
  "description": "Redis drivers for Fedify",
5
5
  "keywords": [
6
6
  "fedify",
@@ -82,21 +82,23 @@
82
82
  },
83
83
  "peerDependencies": {
84
84
  "ioredis": "^5.8.2",
85
- "@fedify/fedify": "^2.0.0-dev.241+58c8126c"
85
+ "@fedify/fedify": "^2.0.0-dev.279+ce1bdc22"
86
86
  },
87
87
  "devDependencies": {
88
88
  "@std/async": "npm:@jsr/std__async@^1.0.13",
89
89
  "@types/node": "^22.17.0",
90
90
  "tsdown": "^0.12.9",
91
91
  "typescript": "^5.9.3",
92
- "@fedify/fixture": "^2.0.0",
93
- "@fedify/testing": "^2.0.0-dev.241+58c8126c"
92
+ "@fedify/testing": "^2.0.0-dev.279+ce1bdc22",
93
+ "@fedify/fixture": "^2.0.0"
94
94
  },
95
95
  "scripts": {
96
96
  "build:self": "tsdown",
97
97
  "build": "pnpm --filter @fedify/redis... run build:self",
98
98
  "prepublish": "pnpm build",
99
- "test": "pnpm build && node --experimental-transform-types --test",
100
- "test:bun": "pnpm build && bun test --timeout=10000"
99
+ "pretest": "pnpm build",
100
+ "test": "node --experimental-transform-types --test",
101
+ "pretest:bun": "pnpm build",
102
+ "test:bun": "bun test --timeout=10000"
101
103
  }
102
104
  }