mail-time 5.0.0 → 5.1.0

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.
package/README.md CHANGED
@@ -537,12 +537,16 @@ For deeper JoSk semantics, install the JoSk skill: **`npx skills add veliovgroup
537
537
 
538
538
  | Constructor | Required option | Optional |
539
539
  | ---------------------------------------- | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
540
- | `new RedisQueue({ client, prefix? })` | connected `redis@^4/^5` client with `watch()` + `multi()` | `prefix` — inherited from `MailTime` when omitted. Redis Cluster prefixes must map to one hash slot. |
540
+ | `new RedisQueue({ client, prefix?, useHashTags? })` | connected `redis@^4/^5` standalone or Cluster client | `prefix` — inherited from `MailTime` when omitted. Set `useHashTags: true` on Redis/KeyDB Cluster; set same option on JoSk Redis adapter. |
541
541
  | `new MongoQueue({ db, prefix? })` | `db` from `MongoClient#db()` | `prefix` — inherited from `MailTime` when omitted. Indexes auto-created on first `ready()`. |
542
542
  | `new PostgresQueue({ client, prefix? })` | `pg.Pool` (recommended) or `pg.Client` | `prefix` — inherited from `MailTime` when omitted. `mail_time_queue` table auto-migrated on first `ready()`. |
543
543
 
544
544
  For custom adapters see [docs/queue-api.md](https://github.com/veliovgroup/mail-time/blob/master/docs/queue-api.md).
545
545
 
546
+ ### Redis Cluster
547
+
548
+ Set `useHashTags: true` on both `RedisQueue` and its JoSk Redis adapter. Queue state then uses `mailtime:{prefix}:letters` plus a tagged sorted-set schedule, so node-redis `createCluster()` can route every Lua operation to one slot. One prefix is one slot; shard high-volume traffic across prefixes. Tagged `iterate` returns at most 100 due rows per tick. Existing standalone keys are not read in this mode. Follow [Redis Cluster migration](https://github.com/veliovgroup/mail-time/blob/master/docs/migration-v5-v5.1.md) before cutover.
549
+
546
550
  ### Module functions
547
551
 
548
552
  - `mailTimePreset(name, overrides?)` → fresh MailTime constructor config. Deep-clones the named [preset](#settings-presets) and deep-merges your overrides (scalars win, nested `josk` composes). Throws on unknown `name` or non-object `overrides`.
@@ -563,6 +567,7 @@ Upgrade checklists, adapter contract changes, and rollout notes live in the docs
563
567
  | 3.x | 4.1 | [v3 → v4](https://github.com/veliovgroup/mail-time/blob/master/docs/migration-v3-v4.md) |
564
568
  | 4.0 | 4.1 | [v4.0 → v4.1](https://github.com/veliovgroup/mail-time/blob/master/docs/migration-v4-v4.1.md) |
565
569
  | 4.1 | 5.0 | [v4.1 → v5](https://github.com/veliovgroup/mail-time/blob/master/docs/migration-v4.1-v5.md) |
570
+ | 5.0 | 5.1 | [v5 → v5.1 Redis Cluster](https://github.com/veliovgroup/mail-time/blob/master/docs/migration-v5-v5.1.md) |
566
571
 
567
572
  New in v4 (opt-in): `mailTimePreset`, `concurrency`/`mode`, `sendingTimeout`, `drain()`/`pause()`/`resume()`, per-recipient handling, AI skills bundle.
568
573
 
@@ -4,7 +4,17 @@ export type RedisClient = {
4
4
  set: (key: string, value: string, options?: object) => Promise<unknown>;
5
5
  del: (key: string | string[]) => Promise<number>;
6
6
  ping: () => Promise<string>;
7
- scanIterator: (options: object) => AsyncIterable<string | string[]>;
7
+ scanIterator?: ((options: object) => AsyncIterable<string | string[]>) | undefined;
8
+ hGet?: ((key: string, field: string) => Promise<string | null>) | undefined;
9
+ eval?: ((script: string, options: {
10
+ keys: string[];
11
+ arguments: string[];
12
+ }) => Promise<unknown>) | undefined;
13
+ evalSha?: ((sha: string, options: {
14
+ keys: string[];
15
+ arguments: string[];
16
+ }) => Promise<unknown>) | undefined;
17
+ scriptLoad?: ((script: string) => Promise<string>) | undefined;
8
18
  watch?: ((key: string) => Promise<unknown>) | undefined;
9
19
  unwatch?: (() => Promise<unknown>) | undefined;
10
20
  multi?: (() => object) | undefined;
@@ -12,6 +22,10 @@ export type RedisClient = {
12
22
  export type RedisQueueOption = {
13
23
  client: RedisClient;
14
24
  prefix?: string | undefined;
25
+ /**
26
+ * - Use Redis Cluster hash-tag keys (`mailtime:{prefix}:*`). Default keeps existing standalone keys.
27
+ */
28
+ useHashTags?: boolean | undefined;
15
29
  };
16
30
  /** Class representing Redis Queue for MailTime */
17
31
  export class RedisQueue {
@@ -22,8 +36,12 @@ export class RedisQueue {
22
36
  constructor(opts: RedisQueueOption);
23
37
  name: string;
24
38
  client: RedisClient;
39
+ useHashTags: boolean;
25
40
  prefix: any;
26
41
  uniqueName: string | undefined;
42
+ lettersKey: string | undefined;
43
+ scheduleKey: string | undefined;
44
+ concatKeysKey: string | undefined;
27
45
  /**
28
46
  * @async
29
47
  * @memberOf RedisQueue
package/adapters/redis.js CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  stripInternalUpdateMeta,
8
8
  isSendLeaseRemove,
9
9
  } from '../helpers.js';
10
+ import { createHash } from 'crypto';
10
11
 
11
12
  /**
12
13
  * @typedef {object} RedisClient
@@ -15,7 +16,11 @@ import {
15
16
  * @property {(key: string, value: string, options?: object) => Promise<unknown>} set
16
17
  * @property {(key: string|string[]) => Promise<number>} del
17
18
  * @property {() => Promise<string>} ping
18
- * @property {(options: object) => AsyncIterable<string|string[]>} scanIterator
19
+ * @property {(options: object) => AsyncIterable<string|string[]>} [scanIterator]
20
+ * @property {(key: string, field: string) => Promise<string|null>} [hGet]
21
+ * @property {(script: string, options: { keys: string[], arguments: string[] }) => Promise<unknown>} [eval]
22
+ * @property {(sha: string, options: { keys: string[], arguments: string[] }) => Promise<unknown>} [evalSha]
23
+ * @property {(script: string) => Promise<string>} [scriptLoad]
19
24
  * @property {(key: string) => Promise<unknown>} [watch]
20
25
  * @property {() => Promise<unknown>} [unwatch]
21
26
  * @property {() => object} [multi]
@@ -25,10 +30,168 @@ import {
25
30
  * @typedef {object} RedisQueueOption
26
31
  * @property {RedisClient} client
27
32
  * @property {string} [prefix]
33
+ * @property {boolean} [useHashTags] - Use Redis Cluster hash-tag keys (`mailtime:{prefix}:*`). Default keeps existing standalone keys.
28
34
  */
29
35
 
30
36
  const KEY_TYPES = new Set(['letter', 'sendat', 'concatletter']);
31
37
  const DEFAULT_PREFIX = 'default';
38
+ const VALID_PREFIX = /^[A-Za-z0-9_\-:.]+$/;
39
+ const TAGGED_ITERATE_LIMIT = 100;
40
+
41
+ const PUSH_TAGGED_TASK_SCRIPT = `
42
+ redis.call('HSET', KEYS[1], ARGV[1], ARGV[2])
43
+ redis.call('ZADD', KEYS[2], tonumber(ARGV[3]), ARGV[1])
44
+ if #KEYS > 3 then
45
+ redis.call('SET', KEYS[3], ARGV[1], 'PXAT', tonumber(ARGV[4]))
46
+ redis.call('ZADD', KEYS[4], tonumber(ARGV[4]), KEYS[3])
47
+ end
48
+ return 1
49
+ `;
50
+
51
+ const ITERATE_TAGGED_TASKS_SCRIPT = `
52
+ local now = tonumber(ARGV[1])
53
+ local maxTries = tonumber(ARGV[2])
54
+ local sendingTimeout = tonumber(ARGV[3])
55
+ local limit = tonumber(ARGV[4])
56
+ local scanLimit = tonumber(ARGV[5])
57
+ local tasks = {}
58
+ if #KEYS > 2 then
59
+ local expiredPointers = redis.call('ZRANGEBYSCORE', KEYS[3], '-inf', now, 'LIMIT', 0, 100)
60
+ for _, pointerKey in ipairs(expiredPointers) do
61
+ redis.call('DEL', pointerKey)
62
+ redis.call('ZREM', KEYS[3], pointerKey)
63
+ end
64
+ end
65
+ local due = redis.call('ZRANGEBYSCORE', KEYS[2], '-inf', now, 'LIMIT', 0, scanLimit)
66
+ for _, uuid in ipairs(due) do
67
+ local payload = redis.call('HGET', KEYS[1], uuid)
68
+ if not payload then
69
+ redis.call('ZREM', KEYS[2], uuid)
70
+ else
71
+ local task = cjson.decode(payload)
72
+ if task.isSent or task.isFailed or task.isCancelled or tonumber(task.tries or 0) >= maxTries then
73
+ redis.call('ZREM', KEYS[2], uuid)
74
+ elseif task.isSending then
75
+ local eligibleAt = tonumber(task.sendingAt or 0) + sendingTimeout
76
+ if eligibleAt > now then
77
+ redis.call('ZADD', KEYS[2], eligibleAt, uuid)
78
+ else
79
+ table.insert(tasks, task)
80
+ end
81
+ elseif tonumber(task.sendAt or 0) <= now then
82
+ table.insert(tasks, task)
83
+ else
84
+ redis.call('ZADD', KEYS[2], tonumber(task.sendAt), uuid)
85
+ end
86
+ if #tasks >= limit then
87
+ break
88
+ end
89
+ end
90
+ end
91
+
92
+ return cjson.encode(tasks)
93
+ `;
94
+
95
+ const UPDATE_TAGGED_TASK_SCRIPT = `
96
+ local payload = redis.call('HGET', KEYS[1], ARGV[1])
97
+ if not payload then
98
+ redis.call('ZREM', KEYS[2], ARGV[1])
99
+ return 0
100
+ end
101
+
102
+ local task = cjson.decode(payload)
103
+ local update = cjson.decode(ARGV[2])
104
+ local mode = ARGV[3]
105
+ local now = tonumber(ARGV[4])
106
+ local sendingTimeout = tonumber(ARGV[5])
107
+ local expectedTries = tonumber(ARGV[6])
108
+ local leaseSendingAt = tonumber(ARGV[7])
109
+
110
+ if mode == 'claim' then
111
+ if task.isSent or task.isFailed or task.isCancelled or tonumber(task.tries or 0) ~= expectedTries then
112
+ return 0
113
+ end
114
+ if task.isSending and tonumber(task.sendingAt or 0) > now - sendingTimeout then
115
+ return 0
116
+ end
117
+ elseif mode == 'lease' then
118
+ if task.isCancelled or task.isFailed or not task.isSending
119
+ or tonumber(task.tries or 0) ~= expectedTries
120
+ or tonumber(task.sendingAt or 0) ~= leaseSendingAt then
121
+ return 0
122
+ end
123
+ end
124
+
125
+ for key, value in pairs(update) do
126
+ task[key] = value
127
+ end
128
+ redis.call('HSET', KEYS[1], ARGV[1], cjson.encode(task))
129
+
130
+ if task.isSent or task.isFailed or task.isCancelled then
131
+ redis.call('ZREM', KEYS[2], ARGV[1])
132
+ if #KEYS > 3 then
133
+ local concatUuid = redis.call('GET', KEYS[3])
134
+ if concatUuid == ARGV[1] then
135
+ redis.call('DEL', KEYS[3])
136
+ redis.call('ZREM', KEYS[4], KEYS[3])
137
+ elseif not concatUuid then
138
+ redis.call('ZREM', KEYS[4], KEYS[3])
139
+ end
140
+ end
141
+ elseif task.isSending then
142
+ redis.call('ZADD', KEYS[2], tonumber(task.sendingAt or now) + sendingTimeout, ARGV[1])
143
+ else
144
+ redis.call('ZADD', KEYS[2], tonumber(task.sendAt), ARGV[1])
145
+ end
146
+ return 1
147
+ `;
148
+
149
+ const APPEND_TAGGED_MAIL_OPTION_SCRIPT = `
150
+ local payload = redis.call('HGET', KEYS[1], ARGV[1])
151
+ if not payload then
152
+ return 0
153
+ end
154
+ local task = cjson.decode(payload)
155
+ if task.isSending or task.isSent or task.isFailed or task.isCancelled then
156
+ return 0
157
+ end
158
+ task.mailOptions = task.mailOptions or {}
159
+ table.insert(task.mailOptions, cjson.decode(ARGV[2]))
160
+ redis.call('HSET', KEYS[1], ARGV[1], cjson.encode(task))
161
+ return 1
162
+ `;
163
+
164
+ const REMOVE_TAGGED_TASK_SCRIPT = `
165
+ local payload = redis.call('HGET', KEYS[1], ARGV[1])
166
+ if not payload then
167
+ redis.call('ZREM', KEYS[2], ARGV[1])
168
+ return 0
169
+ end
170
+ local task = cjson.decode(payload)
171
+ if ARGV[2] == 'lease' and (task.isCancelled or task.isFailed or not task.isSending
172
+ or tonumber(task.tries or 0) ~= tonumber(ARGV[3])
173
+ or tonumber(task.sendingAt or 0) ~= tonumber(ARGV[4])) then
174
+ return 0
175
+ end
176
+ redis.call('HDEL', KEYS[1], ARGV[1])
177
+ redis.call('ZREM', KEYS[2], ARGV[1])
178
+ if #KEYS > 3 then
179
+ local concatUuid = redis.call('GET', KEYS[3])
180
+ if concatUuid == ARGV[1] then
181
+ redis.call('DEL', KEYS[3])
182
+ redis.call('ZREM', KEYS[4], KEYS[3])
183
+ elseif not concatUuid then
184
+ redis.call('ZREM', KEYS[4], KEYS[3])
185
+ end
186
+ end
187
+ return 1
188
+ `;
189
+
190
+ const sha1Hex = (string) => createHash('sha1').update(string).digest('hex');
191
+
192
+ const isNoScriptError = (error) => {
193
+ return !!error && (error.code === 'NOSCRIPT' || (typeof error.message === 'string' && error.message.includes('NOSCRIPT')));
194
+ };
32
195
 
33
196
  const canReleaseLease = (currentTask, updateObj) => {
34
197
  return currentTask
@@ -102,7 +265,21 @@ class RedisQueue {
102
265
  throw new Error('[mail-time] [RedisQueue] required {client} option is missing, e.g. returned from `redis.createClient()` or `redis.createCluster()` method');
103
266
  }
104
267
 
268
+ if (opts.useHashTags !== undefined && typeof opts.useHashTags !== 'boolean') {
269
+ throw new TypeError(`[mail-time] [RedisQueue] {useHashTags} option must be a boolean (received: ${typeof opts.useHashTags})`);
270
+ }
271
+
105
272
  this.client = opts.client;
273
+ this.useHashTags = opts.useHashTags === true;
274
+ this.__scriptSources = {
275
+ push: PUSH_TAGGED_TASK_SCRIPT,
276
+ iterate: ITERATE_TAGGED_TASKS_SCRIPT,
277
+ update: UPDATE_TAGGED_TASK_SCRIPT,
278
+ append: APPEND_TAGGED_MAIL_OPTION_SCRIPT,
279
+ remove: REMOVE_TAGGED_TASK_SCRIPT,
280
+ };
281
+ this.__scriptShas = Object.fromEntries(Object.entries(this.__scriptSources).map(([name, source]) => [name, sha1Hex(source)]));
282
+ this.__loadedShas = new Set();
106
283
  if (typeof opts.prefix === 'string') {
107
284
  this.__applyPrefix(opts.prefix);
108
285
  }
@@ -110,8 +287,16 @@ class RedisQueue {
110
287
 
111
288
  /** @internal */
112
289
  __applyPrefix(prefix) {
290
+ if (this.useHashTags && !VALID_PREFIX.test(prefix)) {
291
+ throw new Error(`[mail-time] [RedisQueue] {prefix} option must match ${VALID_PREFIX} when {useHashTags} is true (received: "${prefix}")`);
292
+ }
113
293
  this.prefix = prefix;
114
- this.uniqueName = `mailtime:${prefix}`;
294
+ this.uniqueName = this.useHashTags ? `mailtime:{${prefix}}` : `mailtime:${prefix}`;
295
+ if (this.useHashTags) {
296
+ this.lettersKey = `${this.uniqueName}:letters`;
297
+ this.scheduleKey = `${this.uniqueName}:schedule`;
298
+ this.concatKeysKey = `${this.uniqueName}:concatkeys`;
299
+ }
115
300
  }
116
301
 
117
302
  /** @internal */
@@ -126,6 +311,49 @@ class RedisQueue {
126
311
  debug(this.mailTimeInstance?.debug === true, `[${this.name}]`, ...args);
127
312
  }
128
313
 
314
+ /** @internal */
315
+ async __runScript(scriptKey, options) {
316
+ const source = this.__scriptSources[scriptKey];
317
+ const sha = this.__scriptShas[scriptKey];
318
+ if (!source || !sha) {
319
+ throw new Error(`[mail-time] [RedisQueue] unknown script "${scriptKey}"`);
320
+ }
321
+
322
+ if (this.__loadedShas.has(sha) && typeof this.client.evalSha === 'function') {
323
+ try {
324
+ return await this.client.evalSha(sha, options);
325
+ } catch (error) {
326
+ if (!isNoScriptError(error)) {
327
+ throw error;
328
+ }
329
+ this.__loadedShas.delete(sha);
330
+ }
331
+ }
332
+
333
+ if (typeof this.client.scriptLoad === 'function' && typeof this.client.evalSha === 'function') {
334
+ try {
335
+ await this.client.scriptLoad(source);
336
+ this.__loadedShas.add(sha);
337
+ return await this.client.evalSha(sha, options);
338
+ } catch (error) {
339
+ if (!isNoScriptError(error)) {
340
+ this.__debug(`[script:${scriptKey}] scriptLoad failed; falling back to EVAL`, error);
341
+ }
342
+ }
343
+ }
344
+
345
+ if (typeof this.client.eval !== 'function') {
346
+ throw new Error('[mail-time] [RedisQueue] Redis Cluster client must support EVAL');
347
+ }
348
+ return await this.client.eval(source, options);
349
+ }
350
+
351
+ /** @internal */
352
+ __getTaggedConcatKey(to) {
353
+ this.__ensurePrefix();
354
+ return `${this.uniqueName}:concatletter:${to}`;
355
+ }
356
+
129
357
  /**
130
358
  * @async
131
359
  * @memberOf RedisQueue
@@ -203,6 +431,25 @@ class RedisQueue {
203
431
  const maxTries = (this.mailTimeInstance && typeof this.mailTimeInstance.maxTries === 'number')
204
432
  ? this.mailTimeInstance.maxTries
205
433
  : 60;
434
+
435
+ if (this.useHashTags) {
436
+ this.__ensurePrefix();
437
+ const dispatchLimit = Number.isFinite(limit) && limit > 0 ? Math.floor(limit) : TAGGED_ITERATE_LIMIT;
438
+ const payload = await this.__runScript('iterate', {
439
+ keys: [this.lettersKey, this.scheduleKey, this.concatKeysKey],
440
+ arguments: [`${now}`, `${maxTries}`, `${sendingTimeout}`, `${dispatchLimit}`, `${Math.max(dispatchLimit * 20, 100)}`],
441
+ });
442
+ const candidates = payload ? JSON.parse(String(payload)) : [];
443
+ if (!Array.isArray(candidates)) {
444
+ return;
445
+ }
446
+ for (const candidate of candidates) {
447
+ if (isIterateCandidate(candidate, now, sendingTimeout, maxTries)) {
448
+ await this.mailTimeInstance.___dispatch(candidate);
449
+ }
450
+ }
451
+ return;
452
+ }
206
453
  let dispatched = 0;
207
454
 
208
455
  const matchPattern = this.__getKey('*', 'sendat');
@@ -259,14 +506,15 @@ class RedisQueue {
259
506
  return null;
260
507
  }
261
508
 
262
- const concatKey = this.__getKey(to, 'concatletter');
509
+ const concatKey = this.useHashTags ? this.__getTaggedConcatKey(to) : this.__getKey(to, 'concatletter');
263
510
  const uuid = await this.client.get(concatKey);
264
511
  if (!uuid) {
265
512
  return null;
266
513
  }
267
514
 
268
- const letterKey = this.__getKey(uuid, 'letter');
269
- const taskJSON = await this.client.get(letterKey);
515
+ const taskJSON = this.useHashTags
516
+ ? await this.client.hGet(this.lettersKey, uuid)
517
+ : await this.client.get(this.__getKey(uuid, 'letter'));
270
518
  if (!taskJSON) {
271
519
  return null;
272
520
  }
@@ -297,6 +545,19 @@ class RedisQueue {
297
545
  task.sendAt = +task.sendAt;
298
546
  }
299
547
 
548
+ if (this.useHashTags) {
549
+ this.__ensurePrefix();
550
+ const keys = [this.lettersKey, this.scheduleKey];
551
+ const args = [task.uuid, JSON.stringify(task), `${+task.sendAt}`];
552
+ if (task.to) {
553
+ keys.push(this.__getTaggedConcatKey(task.to));
554
+ keys.push(this.concatKeysKey);
555
+ args.push(`${task.sendAt - 128}`);
556
+ }
557
+ await this.__runScript('push', { keys, arguments: args });
558
+ return;
559
+ }
560
+
300
561
  const letterKey = this.__getKey(task.uuid, 'letter');
301
562
  const sendatKey = this.__getKey(task.uuid, 'sendat');
302
563
  const taskJSON = JSON.stringify(task);
@@ -337,9 +598,14 @@ class RedisQueue {
337
598
  return false;
338
599
  }
339
600
 
340
- await this.client.del(this.__getKey(uuid, 'sendat'));
341
- const letterKey = this.__getKey(uuid, 'letter');
342
- const taskJSON = await this.client.get(letterKey);
601
+ if (this.useHashTags) {
602
+ this.__ensurePrefix();
603
+ } else {
604
+ await this.client.del(this.__getKey(uuid, 'sendat'));
605
+ }
606
+ const taskJSON = this.useHashTags
607
+ ? await this.client.hGet(this.lettersKey, uuid)
608
+ : await this.client.get(this.__getKey(uuid, 'letter'));
343
609
  if (!taskJSON) {
344
610
  return false;
345
611
  }
@@ -373,6 +639,30 @@ class RedisQueue {
373
639
  return false;
374
640
  }
375
641
 
642
+ if (this.useHashTags) {
643
+ this.__ensurePrefix();
644
+ try {
645
+ const keys = [this.lettersKey, this.scheduleKey];
646
+ if (task.to) {
647
+ keys.push(this.__getTaggedConcatKey(task.to));
648
+ keys.push(this.concatKeysKey);
649
+ }
650
+ const result = await this.__runScript('remove', {
651
+ keys,
652
+ arguments: [
653
+ task.uuid,
654
+ isSendLeaseRemove(opts) ? 'lease' : 'plain',
655
+ `${opts?.leaseTries || 0}`,
656
+ `${opts?.leaseSendingAt || 0}`,
657
+ ],
658
+ });
659
+ return Number(result) >= 1;
660
+ } catch (opError) {
661
+ logError('[remove] [tagged] [opError]', opError);
662
+ return false;
663
+ }
664
+ }
665
+
376
666
  const letterKey = this.__getKey(task.uuid, 'letter');
377
667
  if (isSendLeaseRemove(opts)) {
378
668
  if (typeof this.client.watch !== 'function' || typeof this.client.multi !== 'function') {
@@ -447,6 +737,36 @@ class RedisQueue {
447
737
  const sendingTimeout = this.mailTimeInstance?.sendingTimeout || 300000;
448
738
 
449
739
  try {
740
+ if (this.useHashTags) {
741
+ if (isAppend) {
742
+ const result = await this.__runScript('append', {
743
+ keys: [this.lettersKey],
744
+ arguments: [task.uuid, JSON.stringify(updateObj.appendMailOption)],
745
+ });
746
+ return Number(result) >= 1;
747
+ }
748
+
749
+ const mode = isClaim ? 'claim' : (isLeaseRelease ? 'lease' : 'plain');
750
+ const keys = [this.lettersKey, this.scheduleKey];
751
+ if (task.to) {
752
+ keys.push(this.__getTaggedConcatKey(task.to));
753
+ keys.push(this.concatKeysKey);
754
+ }
755
+ const result = await this.__runScript('update', {
756
+ keys,
757
+ arguments: [
758
+ task.uuid,
759
+ JSON.stringify(stripInternalUpdateMeta(updateObj)),
760
+ mode,
761
+ `${now}`,
762
+ `${sendingTimeout}`,
763
+ `${isClaim ? task.tries : (updateObj.leaseTries || 0)}`,
764
+ `${updateObj.leaseSendingAt || 0}`,
765
+ ],
766
+ });
767
+ return Number(result) >= 1;
768
+ }
769
+
450
770
  if (isAppend) {
451
771
  if (typeof this.client.watch !== 'function' || typeof this.client.multi !== 'function') {
452
772
  if (!RedisQueue.__atomicAppendWarned) {
@@ -558,6 +878,15 @@ class RedisQueue {
558
878
  throw new Error(`[mail-time] [RedisQueue] [__getKey] unsupported key "${type}" passed into the second argument`);
559
879
  }
560
880
  this.__ensurePrefix();
881
+ if (this.useHashTags) {
882
+ if (type === 'letter') {
883
+ return this.lettersKey;
884
+ }
885
+ if (type === 'sendat') {
886
+ return this.scheduleKey;
887
+ }
888
+ return this.__getTaggedConcatKey(uuid);
889
+ }
561
890
  return `${this.uniqueName}:${type}:${uuid}`;
562
891
  }
563
892
  }
package/index.cjs CHANGED
@@ -729,7 +729,11 @@ class MongoQueue {
729
729
  * @property {(key: string, value: string, options?: object) => Promise<unknown>} set
730
730
  * @property {(key: string|string[]) => Promise<number>} del
731
731
  * @property {() => Promise<string>} ping
732
- * @property {(options: object) => AsyncIterable<string|string[]>} scanIterator
732
+ * @property {(options: object) => AsyncIterable<string|string[]>} [scanIterator]
733
+ * @property {(key: string, field: string) => Promise<string|null>} [hGet]
734
+ * @property {(script: string, options: { keys: string[], arguments: string[] }) => Promise<unknown>} [eval]
735
+ * @property {(sha: string, options: { keys: string[], arguments: string[] }) => Promise<unknown>} [evalSha]
736
+ * @property {(script: string) => Promise<string>} [scriptLoad]
733
737
  * @property {(key: string) => Promise<unknown>} [watch]
734
738
  * @property {() => Promise<unknown>} [unwatch]
735
739
  * @property {() => object} [multi]
@@ -739,10 +743,168 @@ class MongoQueue {
739
743
  * @typedef {object} RedisQueueOption
740
744
  * @property {RedisClient} client
741
745
  * @property {string} [prefix]
746
+ * @property {boolean} [useHashTags] - Use Redis Cluster hash-tag keys (`mailtime:{prefix}:*`). Default keeps existing standalone keys.
742
747
  */
743
748
 
744
749
  const KEY_TYPES = new Set(['letter', 'sendat', 'concatletter']);
745
750
  const DEFAULT_PREFIX$1 = 'default';
751
+ const VALID_PREFIX = /^[A-Za-z0-9_\-:.]+$/;
752
+ const TAGGED_ITERATE_LIMIT = 100;
753
+
754
+ const PUSH_TAGGED_TASK_SCRIPT = `
755
+ redis.call('HSET', KEYS[1], ARGV[1], ARGV[2])
756
+ redis.call('ZADD', KEYS[2], tonumber(ARGV[3]), ARGV[1])
757
+ if #KEYS > 3 then
758
+ redis.call('SET', KEYS[3], ARGV[1], 'PXAT', tonumber(ARGV[4]))
759
+ redis.call('ZADD', KEYS[4], tonumber(ARGV[4]), KEYS[3])
760
+ end
761
+ return 1
762
+ `;
763
+
764
+ const ITERATE_TAGGED_TASKS_SCRIPT = `
765
+ local now = tonumber(ARGV[1])
766
+ local maxTries = tonumber(ARGV[2])
767
+ local sendingTimeout = tonumber(ARGV[3])
768
+ local limit = tonumber(ARGV[4])
769
+ local scanLimit = tonumber(ARGV[5])
770
+ local tasks = {}
771
+ if #KEYS > 2 then
772
+ local expiredPointers = redis.call('ZRANGEBYSCORE', KEYS[3], '-inf', now, 'LIMIT', 0, 100)
773
+ for _, pointerKey in ipairs(expiredPointers) do
774
+ redis.call('DEL', pointerKey)
775
+ redis.call('ZREM', KEYS[3], pointerKey)
776
+ end
777
+ end
778
+ local due = redis.call('ZRANGEBYSCORE', KEYS[2], '-inf', now, 'LIMIT', 0, scanLimit)
779
+ for _, uuid in ipairs(due) do
780
+ local payload = redis.call('HGET', KEYS[1], uuid)
781
+ if not payload then
782
+ redis.call('ZREM', KEYS[2], uuid)
783
+ else
784
+ local task = cjson.decode(payload)
785
+ if task.isSent or task.isFailed or task.isCancelled or tonumber(task.tries or 0) >= maxTries then
786
+ redis.call('ZREM', KEYS[2], uuid)
787
+ elseif task.isSending then
788
+ local eligibleAt = tonumber(task.sendingAt or 0) + sendingTimeout
789
+ if eligibleAt > now then
790
+ redis.call('ZADD', KEYS[2], eligibleAt, uuid)
791
+ else
792
+ table.insert(tasks, task)
793
+ end
794
+ elseif tonumber(task.sendAt or 0) <= now then
795
+ table.insert(tasks, task)
796
+ else
797
+ redis.call('ZADD', KEYS[2], tonumber(task.sendAt), uuid)
798
+ end
799
+ if #tasks >= limit then
800
+ break
801
+ end
802
+ end
803
+ end
804
+
805
+ return cjson.encode(tasks)
806
+ `;
807
+
808
+ const UPDATE_TAGGED_TASK_SCRIPT = `
809
+ local payload = redis.call('HGET', KEYS[1], ARGV[1])
810
+ if not payload then
811
+ redis.call('ZREM', KEYS[2], ARGV[1])
812
+ return 0
813
+ end
814
+
815
+ local task = cjson.decode(payload)
816
+ local update = cjson.decode(ARGV[2])
817
+ local mode = ARGV[3]
818
+ local now = tonumber(ARGV[4])
819
+ local sendingTimeout = tonumber(ARGV[5])
820
+ local expectedTries = tonumber(ARGV[6])
821
+ local leaseSendingAt = tonumber(ARGV[7])
822
+
823
+ if mode == 'claim' then
824
+ if task.isSent or task.isFailed or task.isCancelled or tonumber(task.tries or 0) ~= expectedTries then
825
+ return 0
826
+ end
827
+ if task.isSending and tonumber(task.sendingAt or 0) > now - sendingTimeout then
828
+ return 0
829
+ end
830
+ elseif mode == 'lease' then
831
+ if task.isCancelled or task.isFailed or not task.isSending
832
+ or tonumber(task.tries or 0) ~= expectedTries
833
+ or tonumber(task.sendingAt or 0) ~= leaseSendingAt then
834
+ return 0
835
+ end
836
+ end
837
+
838
+ for key, value in pairs(update) do
839
+ task[key] = value
840
+ end
841
+ redis.call('HSET', KEYS[1], ARGV[1], cjson.encode(task))
842
+
843
+ if task.isSent or task.isFailed or task.isCancelled then
844
+ redis.call('ZREM', KEYS[2], ARGV[1])
845
+ if #KEYS > 3 then
846
+ local concatUuid = redis.call('GET', KEYS[3])
847
+ if concatUuid == ARGV[1] then
848
+ redis.call('DEL', KEYS[3])
849
+ redis.call('ZREM', KEYS[4], KEYS[3])
850
+ elseif not concatUuid then
851
+ redis.call('ZREM', KEYS[4], KEYS[3])
852
+ end
853
+ end
854
+ elseif task.isSending then
855
+ redis.call('ZADD', KEYS[2], tonumber(task.sendingAt or now) + sendingTimeout, ARGV[1])
856
+ else
857
+ redis.call('ZADD', KEYS[2], tonumber(task.sendAt), ARGV[1])
858
+ end
859
+ return 1
860
+ `;
861
+
862
+ const APPEND_TAGGED_MAIL_OPTION_SCRIPT = `
863
+ local payload = redis.call('HGET', KEYS[1], ARGV[1])
864
+ if not payload then
865
+ return 0
866
+ end
867
+ local task = cjson.decode(payload)
868
+ if task.isSending or task.isSent or task.isFailed or task.isCancelled then
869
+ return 0
870
+ end
871
+ task.mailOptions = task.mailOptions or {}
872
+ table.insert(task.mailOptions, cjson.decode(ARGV[2]))
873
+ redis.call('HSET', KEYS[1], ARGV[1], cjson.encode(task))
874
+ return 1
875
+ `;
876
+
877
+ const REMOVE_TAGGED_TASK_SCRIPT = `
878
+ local payload = redis.call('HGET', KEYS[1], ARGV[1])
879
+ if not payload then
880
+ redis.call('ZREM', KEYS[2], ARGV[1])
881
+ return 0
882
+ end
883
+ local task = cjson.decode(payload)
884
+ if ARGV[2] == 'lease' and (task.isCancelled or task.isFailed or not task.isSending
885
+ or tonumber(task.tries or 0) ~= tonumber(ARGV[3])
886
+ or tonumber(task.sendingAt or 0) ~= tonumber(ARGV[4])) then
887
+ return 0
888
+ end
889
+ redis.call('HDEL', KEYS[1], ARGV[1])
890
+ redis.call('ZREM', KEYS[2], ARGV[1])
891
+ if #KEYS > 3 then
892
+ local concatUuid = redis.call('GET', KEYS[3])
893
+ if concatUuid == ARGV[1] then
894
+ redis.call('DEL', KEYS[3])
895
+ redis.call('ZREM', KEYS[4], KEYS[3])
896
+ elseif not concatUuid then
897
+ redis.call('ZREM', KEYS[4], KEYS[3])
898
+ end
899
+ end
900
+ return 1
901
+ `;
902
+
903
+ const sha1Hex = (string) => crypto.createHash('sha1').update(string).digest('hex');
904
+
905
+ const isNoScriptError = (error) => {
906
+ return !!error && (error.code === 'NOSCRIPT' || (typeof error.message === 'string' && error.message.includes('NOSCRIPT')));
907
+ };
746
908
 
747
909
  const canReleaseLease = (currentTask, updateObj) => {
748
910
  return currentTask
@@ -816,7 +978,21 @@ class RedisQueue {
816
978
  throw new Error('[mail-time] [RedisQueue] required {client} option is missing, e.g. returned from `redis.createClient()` or `redis.createCluster()` method');
817
979
  }
818
980
 
981
+ if (opts.useHashTags !== undefined && typeof opts.useHashTags !== 'boolean') {
982
+ throw new TypeError(`[mail-time] [RedisQueue] {useHashTags} option must be a boolean (received: ${typeof opts.useHashTags})`);
983
+ }
984
+
819
985
  this.client = opts.client;
986
+ this.useHashTags = opts.useHashTags === true;
987
+ this.__scriptSources = {
988
+ push: PUSH_TAGGED_TASK_SCRIPT,
989
+ iterate: ITERATE_TAGGED_TASKS_SCRIPT,
990
+ update: UPDATE_TAGGED_TASK_SCRIPT,
991
+ append: APPEND_TAGGED_MAIL_OPTION_SCRIPT,
992
+ remove: REMOVE_TAGGED_TASK_SCRIPT,
993
+ };
994
+ this.__scriptShas = Object.fromEntries(Object.entries(this.__scriptSources).map(([name, source]) => [name, sha1Hex(source)]));
995
+ this.__loadedShas = new Set();
820
996
  if (typeof opts.prefix === 'string') {
821
997
  this.__applyPrefix(opts.prefix);
822
998
  }
@@ -824,8 +1000,16 @@ class RedisQueue {
824
1000
 
825
1001
  /** @internal */
826
1002
  __applyPrefix(prefix) {
1003
+ if (this.useHashTags && !VALID_PREFIX.test(prefix)) {
1004
+ throw new Error(`[mail-time] [RedisQueue] {prefix} option must match ${VALID_PREFIX} when {useHashTags} is true (received: "${prefix}")`);
1005
+ }
827
1006
  this.prefix = prefix;
828
- this.uniqueName = `mailtime:${prefix}`;
1007
+ this.uniqueName = this.useHashTags ? `mailtime:{${prefix}}` : `mailtime:${prefix}`;
1008
+ if (this.useHashTags) {
1009
+ this.lettersKey = `${this.uniqueName}:letters`;
1010
+ this.scheduleKey = `${this.uniqueName}:schedule`;
1011
+ this.concatKeysKey = `${this.uniqueName}:concatkeys`;
1012
+ }
829
1013
  }
830
1014
 
831
1015
  /** @internal */
@@ -840,6 +1024,49 @@ class RedisQueue {
840
1024
  debug(this.mailTimeInstance?.debug === true, `[${this.name}]`, ...args);
841
1025
  }
842
1026
 
1027
+ /** @internal */
1028
+ async __runScript(scriptKey, options) {
1029
+ const source = this.__scriptSources[scriptKey];
1030
+ const sha = this.__scriptShas[scriptKey];
1031
+ if (!source || !sha) {
1032
+ throw new Error(`[mail-time] [RedisQueue] unknown script "${scriptKey}"`);
1033
+ }
1034
+
1035
+ if (this.__loadedShas.has(sha) && typeof this.client.evalSha === 'function') {
1036
+ try {
1037
+ return await this.client.evalSha(sha, options);
1038
+ } catch (error) {
1039
+ if (!isNoScriptError(error)) {
1040
+ throw error;
1041
+ }
1042
+ this.__loadedShas.delete(sha);
1043
+ }
1044
+ }
1045
+
1046
+ if (typeof this.client.scriptLoad === 'function' && typeof this.client.evalSha === 'function') {
1047
+ try {
1048
+ await this.client.scriptLoad(source);
1049
+ this.__loadedShas.add(sha);
1050
+ return await this.client.evalSha(sha, options);
1051
+ } catch (error) {
1052
+ if (!isNoScriptError(error)) {
1053
+ this.__debug(`[script:${scriptKey}] scriptLoad failed; falling back to EVAL`, error);
1054
+ }
1055
+ }
1056
+ }
1057
+
1058
+ if (typeof this.client.eval !== 'function') {
1059
+ throw new Error('[mail-time] [RedisQueue] Redis Cluster client must support EVAL');
1060
+ }
1061
+ return await this.client.eval(source, options);
1062
+ }
1063
+
1064
+ /** @internal */
1065
+ __getTaggedConcatKey(to) {
1066
+ this.__ensurePrefix();
1067
+ return `${this.uniqueName}:concatletter:${to}`;
1068
+ }
1069
+
843
1070
  /**
844
1071
  * @async
845
1072
  * @memberOf RedisQueue
@@ -917,6 +1144,25 @@ class RedisQueue {
917
1144
  const maxTries = (this.mailTimeInstance && typeof this.mailTimeInstance.maxTries === 'number')
918
1145
  ? this.mailTimeInstance.maxTries
919
1146
  : 60;
1147
+
1148
+ if (this.useHashTags) {
1149
+ this.__ensurePrefix();
1150
+ const dispatchLimit = Number.isFinite(limit) && limit > 0 ? Math.floor(limit) : TAGGED_ITERATE_LIMIT;
1151
+ const payload = await this.__runScript('iterate', {
1152
+ keys: [this.lettersKey, this.scheduleKey, this.concatKeysKey],
1153
+ arguments: [`${now}`, `${maxTries}`, `${sendingTimeout}`, `${dispatchLimit}`, `${Math.max(dispatchLimit * 20, 100)}`],
1154
+ });
1155
+ const candidates = payload ? JSON.parse(String(payload)) : [];
1156
+ if (!Array.isArray(candidates)) {
1157
+ return;
1158
+ }
1159
+ for (const candidate of candidates) {
1160
+ if (isIterateCandidate(candidate, now, sendingTimeout, maxTries)) {
1161
+ await this.mailTimeInstance.___dispatch(candidate);
1162
+ }
1163
+ }
1164
+ return;
1165
+ }
920
1166
  let dispatched = 0;
921
1167
 
922
1168
  const matchPattern = this.__getKey('*', 'sendat');
@@ -973,14 +1219,15 @@ class RedisQueue {
973
1219
  return null;
974
1220
  }
975
1221
 
976
- const concatKey = this.__getKey(to, 'concatletter');
1222
+ const concatKey = this.useHashTags ? this.__getTaggedConcatKey(to) : this.__getKey(to, 'concatletter');
977
1223
  const uuid = await this.client.get(concatKey);
978
1224
  if (!uuid) {
979
1225
  return null;
980
1226
  }
981
1227
 
982
- const letterKey = this.__getKey(uuid, 'letter');
983
- const taskJSON = await this.client.get(letterKey);
1228
+ const taskJSON = this.useHashTags
1229
+ ? await this.client.hGet(this.lettersKey, uuid)
1230
+ : await this.client.get(this.__getKey(uuid, 'letter'));
984
1231
  if (!taskJSON) {
985
1232
  return null;
986
1233
  }
@@ -1011,6 +1258,19 @@ class RedisQueue {
1011
1258
  task.sendAt = +task.sendAt;
1012
1259
  }
1013
1260
 
1261
+ if (this.useHashTags) {
1262
+ this.__ensurePrefix();
1263
+ const keys = [this.lettersKey, this.scheduleKey];
1264
+ const args = [task.uuid, JSON.stringify(task), `${+task.sendAt}`];
1265
+ if (task.to) {
1266
+ keys.push(this.__getTaggedConcatKey(task.to));
1267
+ keys.push(this.concatKeysKey);
1268
+ args.push(`${task.sendAt - 128}`);
1269
+ }
1270
+ await this.__runScript('push', { keys, arguments: args });
1271
+ return;
1272
+ }
1273
+
1014
1274
  const letterKey = this.__getKey(task.uuid, 'letter');
1015
1275
  const sendatKey = this.__getKey(task.uuid, 'sendat');
1016
1276
  const taskJSON = JSON.stringify(task);
@@ -1051,9 +1311,14 @@ class RedisQueue {
1051
1311
  return false;
1052
1312
  }
1053
1313
 
1054
- await this.client.del(this.__getKey(uuid, 'sendat'));
1055
- const letterKey = this.__getKey(uuid, 'letter');
1056
- const taskJSON = await this.client.get(letterKey);
1314
+ if (this.useHashTags) {
1315
+ this.__ensurePrefix();
1316
+ } else {
1317
+ await this.client.del(this.__getKey(uuid, 'sendat'));
1318
+ }
1319
+ const taskJSON = this.useHashTags
1320
+ ? await this.client.hGet(this.lettersKey, uuid)
1321
+ : await this.client.get(this.__getKey(uuid, 'letter'));
1057
1322
  if (!taskJSON) {
1058
1323
  return false;
1059
1324
  }
@@ -1087,6 +1352,30 @@ class RedisQueue {
1087
1352
  return false;
1088
1353
  }
1089
1354
 
1355
+ if (this.useHashTags) {
1356
+ this.__ensurePrefix();
1357
+ try {
1358
+ const keys = [this.lettersKey, this.scheduleKey];
1359
+ if (task.to) {
1360
+ keys.push(this.__getTaggedConcatKey(task.to));
1361
+ keys.push(this.concatKeysKey);
1362
+ }
1363
+ const result = await this.__runScript('remove', {
1364
+ keys,
1365
+ arguments: [
1366
+ task.uuid,
1367
+ isSendLeaseRemove(opts) ? 'lease' : 'plain',
1368
+ `${opts?.leaseTries || 0}`,
1369
+ `${opts?.leaseSendingAt || 0}`,
1370
+ ],
1371
+ });
1372
+ return Number(result) >= 1;
1373
+ } catch (opError) {
1374
+ logError('[remove] [tagged] [opError]', opError);
1375
+ return false;
1376
+ }
1377
+ }
1378
+
1090
1379
  const letterKey = this.__getKey(task.uuid, 'letter');
1091
1380
  if (isSendLeaseRemove(opts)) {
1092
1381
  if (typeof this.client.watch !== 'function' || typeof this.client.multi !== 'function') {
@@ -1161,6 +1450,36 @@ class RedisQueue {
1161
1450
  const sendingTimeout = this.mailTimeInstance?.sendingTimeout || 300000;
1162
1451
 
1163
1452
  try {
1453
+ if (this.useHashTags) {
1454
+ if (isAppend) {
1455
+ const result = await this.__runScript('append', {
1456
+ keys: [this.lettersKey],
1457
+ arguments: [task.uuid, JSON.stringify(updateObj.appendMailOption)],
1458
+ });
1459
+ return Number(result) >= 1;
1460
+ }
1461
+
1462
+ const mode = isClaim ? 'claim' : (isLeaseRelease ? 'lease' : 'plain');
1463
+ const keys = [this.lettersKey, this.scheduleKey];
1464
+ if (task.to) {
1465
+ keys.push(this.__getTaggedConcatKey(task.to));
1466
+ keys.push(this.concatKeysKey);
1467
+ }
1468
+ const result = await this.__runScript('update', {
1469
+ keys,
1470
+ arguments: [
1471
+ task.uuid,
1472
+ JSON.stringify(stripInternalUpdateMeta(updateObj)),
1473
+ mode,
1474
+ `${now}`,
1475
+ `${sendingTimeout}`,
1476
+ `${isClaim ? task.tries : (updateObj.leaseTries || 0)}`,
1477
+ `${updateObj.leaseSendingAt || 0}`,
1478
+ ],
1479
+ });
1480
+ return Number(result) >= 1;
1481
+ }
1482
+
1164
1483
  if (isAppend) {
1165
1484
  if (typeof this.client.watch !== 'function' || typeof this.client.multi !== 'function') {
1166
1485
  if (!RedisQueue.__atomicAppendWarned) {
@@ -1272,6 +1591,15 @@ class RedisQueue {
1272
1591
  throw new Error(`[mail-time] [RedisQueue] [__getKey] unsupported key "${type}" passed into the second argument`);
1273
1592
  }
1274
1593
  this.__ensurePrefix();
1594
+ if (this.useHashTags) {
1595
+ if (type === 'letter') {
1596
+ return this.lettersKey;
1597
+ }
1598
+ if (type === 'sendat') {
1599
+ return this.scheduleKey;
1600
+ }
1601
+ return this.__getTaggedConcatKey(uuid);
1602
+ }
1275
1603
  return `${this.uniqueName}:${type}:${uuid}`;
1276
1604
  }
1277
1605
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mail-time",
3
- "version": "5.0.0",
3
+ "version": "5.1.0",
4
4
  "description": "📮 Email queue extending nodemailer with multi-SMTP transports and horizontally scaled apps support",
5
5
  "main": "index.js",
6
6
  "types": "./index.d.ts",
@@ -18,7 +18,8 @@
18
18
  "index.d.ts",
19
19
  "index.js",
20
20
  "presets.d.ts",
21
- "presets.js"
21
+ "presets.js",
22
+ "scripts/migrate-redis-queue-to-cluster.mjs"
22
23
  ],
23
24
  "exports": {
24
25
  ".": {
@@ -63,6 +64,7 @@
63
64
  "test:jest": "node --experimental-vm-modules ./node_modules/jest/bin/jest.js --config jest.config.mjs",
64
65
  "test:mocha": "mocha ./test/npm.js",
65
66
  "test:mocha:redis": "mocha ./test/npm-redis-redis.js",
67
+ "test:mocha:redis-cluster": "mocha ./test/npm-redis-cluster.js",
66
68
  "test:mocha:mongo": "mocha ./test/npm-redis-mongo.js ./test/npm-mongo-redis.js ./test/npm-ha-mongo-redis.js",
67
69
  "test:mocha:postgres": "mocha ./test/npm-postgres.js",
68
70
  "test:types": "tsc -p ./tsconfig.types.json",
@@ -0,0 +1,108 @@
1
+ import { createClient, createCluster } from 'redis';
2
+
3
+ const args = process.argv.slice(2);
4
+ const readArg = (name) => {
5
+ const index = args.indexOf(name);
6
+ return index === -1 ? void 0 : args[index + 1];
7
+ };
8
+ const sourceUrl = readArg('--source');
9
+ const targetUrl = readArg('--target');
10
+ const prefix = readArg('--prefix');
11
+ const sendingTimeout = Number(readArg('--sending-timeout') || 300000);
12
+ const overwrite = args.includes('--overwrite');
13
+
14
+ if (!sourceUrl || !targetUrl || !prefix || !Number.isFinite(sendingTimeout) || sendingTimeout <= 0) {
15
+ throw new Error('Usage: node scripts/migrate-redis-queue-to-cluster.mjs --source <redis-url> --target <cluster-url> --prefix <prefix> [--sending-timeout <ms>] [--overwrite]');
16
+ }
17
+
18
+ if (!/^[A-Za-z0-9_\-:.]+$/.test(prefix)) {
19
+ throw new Error(`{prefix} must match /^[A-Za-z0-9_\\-:.]+$/ (received: "${prefix}")`);
20
+ }
21
+
22
+ const legacyName = `mailtime:${prefix}`;
23
+ const taggedName = `mailtime:{${prefix}}`;
24
+ const lettersKey = `${taggedName}:letters`;
25
+ const scheduleKey = `${taggedName}:schedule`;
26
+ const concatKeysKey = `${taggedName}:concatkeys`;
27
+ const source = await createClient({ url: sourceUrl }).connect();
28
+ const target = createCluster({ rootNodes: [{ url: targetUrl }] });
29
+
30
+ const scan = async function * (client, pattern) {
31
+ for await (const batch of client.scanIterator({ TYPE: 'string', MATCH: pattern, COUNT: 9999 })) {
32
+ for (const key of (Array.isArray(batch) ? batch : [batch])) {
33
+ yield key;
34
+ }
35
+ }
36
+ };
37
+
38
+ const nextEligibleAt = (task) => {
39
+ if (task.isSent === true || task.isFailed === true || task.isCancelled === true) {
40
+ return null;
41
+ }
42
+ if (task.isSending === true) {
43
+ return (typeof task.sendingAt === 'number' ? task.sendingAt : 0) + sendingTimeout;
44
+ }
45
+ return +task.sendAt;
46
+ };
47
+
48
+ try {
49
+ await target.connect();
50
+ const trackedConcatKeys = await target.zRange(concatKeysKey, 0, -1);
51
+ for (const key of trackedConcatKeys) {
52
+ if (await target.pTTL(key) === -2) {
53
+ await target.zRem(concatKeysKey, key);
54
+ }
55
+ }
56
+ const targetHasData = (await target.hLen(lettersKey)) > 0
57
+ || (await target.zCard(scheduleKey)) > 0
58
+ || (await target.zCard(concatKeysKey)) > 0;
59
+ if (targetHasData && !overwrite) {
60
+ throw new Error(`Target queue "${taggedName}" is not empty. Pass --overwrite only after verifying its contents can be replaced.`);
61
+ }
62
+ if (targetHasData) {
63
+ const concatKeys = await target.zRange(concatKeysKey, 0, -1);
64
+ if (concatKeys.length) {
65
+ await target.del(concatKeys);
66
+ }
67
+ await target.del([lettersKey, scheduleKey, concatKeysKey]);
68
+ }
69
+
70
+ let migrated = 0;
71
+ for await (const key of scan(source, `${legacyName}:letter:*`)) {
72
+ const payload = await source.get(key);
73
+ if (!payload) {
74
+ continue;
75
+ }
76
+ const task = JSON.parse(payload);
77
+ if (!task || typeof task.uuid !== 'string') {
78
+ continue;
79
+ }
80
+ await target.hSet(lettersKey, task.uuid, payload);
81
+ const eligibleAt = nextEligibleAt(task);
82
+ if (eligibleAt !== null && Number.isFinite(eligibleAt)) {
83
+ await target.zAdd(scheduleKey, { score: eligibleAt, value: task.uuid });
84
+ }
85
+ migrated++;
86
+ }
87
+
88
+ for await (const key of scan(source, `${legacyName}:concatletter:*`)) {
89
+ const uuid = await source.get(key);
90
+ if (!uuid) {
91
+ continue;
92
+ }
93
+ const ttl = await source.pTTL(key);
94
+ const to = key.slice(`${legacyName}:concatletter:`.length);
95
+ const targetKey = `${taggedName}:concatletter:${to}`;
96
+ if (ttl > 0) {
97
+ await target.set(targetKey, uuid, { PX: ttl });
98
+ await target.zAdd(concatKeysKey, { score: Date.now() + ttl, value: targetKey });
99
+ } else if (ttl === -1) {
100
+ await target.set(targetKey, uuid);
101
+ await target.zAdd(concatKeysKey, { score: Number.MAX_SAFE_INTEGER, value: targetKey });
102
+ }
103
+ }
104
+
105
+ console.info(`Migrated ${migrated} RedisQueue tasks from "${legacyName}" to "${taggedName}".`);
106
+ } finally {
107
+ await Promise.all([source.close(), target.close()]);
108
+ }