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 +6 -1
- package/adapters/redis.d.ts +19 -1
- package/adapters/redis.js +337 -8
- package/index.cjs +336 -8
- package/package.json +4 -2
- package/scripts/migrate-redis-queue-to-cluster.mjs +108 -0
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? })`
|
|
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
|
|
package/adapters/redis.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
269
|
-
|
|
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
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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
|
|
983
|
-
|
|
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
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
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.
|
|
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
|
+
}
|