mail-time 5.0.0 → 5.2.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 +50 -14
- package/adapters/mongo.d.ts +1 -0
- package/adapters/mongo.js +34 -16
- package/adapters/postgres.d.ts +1 -0
- package/adapters/postgres.js +45 -24
- package/adapters/redis.d.ts +24 -2
- package/adapters/redis.js +442 -32
- package/index.cjs +1093 -87
- package/index.d.cts +136 -14
- package/index.d.ts +136 -14
- package/index.js +270 -15
- package/package.json +13 -4
- package/recipient-policy-lease.js +90 -0
- package/recipient-policy.js +166 -0
- package/recipient-rejections.js +62 -0
- package/scripts/migrate-redis-queue-to-cluster.mjs +112 -0
package/adapters/redis.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import {
|
|
2
2
|
debug,
|
|
3
3
|
logError,
|
|
4
|
+
hasOwnProp,
|
|
4
5
|
isSendClaimUpdate,
|
|
5
6
|
isSendLeaseGuardedUpdate,
|
|
6
7
|
isAppendMailOptionUpdate,
|
|
7
8
|
stripInternalUpdateMeta,
|
|
8
9
|
isSendLeaseRemove,
|
|
9
10
|
} from '../helpers.js';
|
|
11
|
+
import { createHash } from 'crypto';
|
|
10
12
|
|
|
11
13
|
/**
|
|
12
14
|
* @typedef {object} RedisClient
|
|
@@ -14,8 +16,15 @@ import {
|
|
|
14
16
|
* @property {(key: string) => Promise<string|null>} get
|
|
15
17
|
* @property {(key: string, value: string, options?: object) => Promise<unknown>} set
|
|
16
18
|
* @property {(key: string|string[]) => Promise<number>} del
|
|
17
|
-
* @property {() => Promise<string>} ping
|
|
18
|
-
* @property {(
|
|
19
|
+
* @property {() => Promise<string>} [ping]
|
|
20
|
+
* @property {() => unknown} [getRandomNode]
|
|
21
|
+
* @property {(...args: any[]) => any} [nodeClient]
|
|
22
|
+
* @property {(firstKey: string, isReadonly: boolean, args: string[]) => Promise<unknown>} [sendCommand]
|
|
23
|
+
* @property {(options: object) => AsyncIterable<string|string[]>} [scanIterator]
|
|
24
|
+
* @property {(key: string, field: string) => Promise<string|null|undefined>} [hGet]
|
|
25
|
+
* @property {(script: string, options: { keys: string[], arguments: string[] }) => Promise<unknown>} [eval]
|
|
26
|
+
* @property {(sha: string, options: { keys: string[], arguments: string[] }) => Promise<unknown>} [evalSha]
|
|
27
|
+
* @property {(script: string) => Promise<string>} [scriptLoad]
|
|
19
28
|
* @property {(key: string) => Promise<unknown>} [watch]
|
|
20
29
|
* @property {() => Promise<unknown>} [unwatch]
|
|
21
30
|
* @property {() => object} [multi]
|
|
@@ -25,10 +34,189 @@ import {
|
|
|
25
34
|
* @typedef {object} RedisQueueOption
|
|
26
35
|
* @property {RedisClient} client
|
|
27
36
|
* @property {string} [prefix]
|
|
37
|
+
* @property {boolean} [useHashTags] - Use Redis Cluster hash-tag keys (`mailtime:{prefix}:*`). Default keeps existing standalone keys.
|
|
28
38
|
*/
|
|
29
39
|
|
|
30
40
|
const KEY_TYPES = new Set(['letter', 'sendat', 'concatletter']);
|
|
31
41
|
const DEFAULT_PREFIX = 'default';
|
|
42
|
+
const VALID_PREFIX = /^[A-Za-z0-9_\-:.]+$/;
|
|
43
|
+
const TAGGED_ITERATE_LIMIT = 100;
|
|
44
|
+
const clientTransactions = new WeakMap();
|
|
45
|
+
|
|
46
|
+
const PUSH_TAGGED_TASK_SCRIPT = `
|
|
47
|
+
redis.call('HSET', KEYS[1], ARGV[1], ARGV[2])
|
|
48
|
+
redis.call('ZADD', KEYS[2], tonumber(ARGV[3]), ARGV[1])
|
|
49
|
+
if #KEYS > 3 then
|
|
50
|
+
redis.call('SET', KEYS[3], ARGV[1], 'PXAT', tonumber(ARGV[4]))
|
|
51
|
+
redis.call('ZADD', KEYS[4], tonumber(ARGV[4]), KEYS[3])
|
|
52
|
+
end
|
|
53
|
+
return 1
|
|
54
|
+
`;
|
|
55
|
+
|
|
56
|
+
const ITERATE_TAGGED_TASKS_SCRIPT = `
|
|
57
|
+
local now = tonumber(ARGV[1])
|
|
58
|
+
local maxTries = tonumber(ARGV[2])
|
|
59
|
+
local sendingTimeout = tonumber(ARGV[3])
|
|
60
|
+
local limit = tonumber(ARGV[4])
|
|
61
|
+
local scanLimit = tonumber(ARGV[5])
|
|
62
|
+
local tasks = {}
|
|
63
|
+
if #KEYS > 2 then
|
|
64
|
+
local expiredPointers = redis.call('ZRANGEBYSCORE', KEYS[3], '-inf', now, 'LIMIT', 0, 100)
|
|
65
|
+
for _, pointerKey in ipairs(expiredPointers) do
|
|
66
|
+
redis.call('DEL', pointerKey)
|
|
67
|
+
redis.call('ZREM', KEYS[3], pointerKey)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
local due = redis.call('ZRANGEBYSCORE', KEYS[2], '-inf', now, 'LIMIT', 0, scanLimit)
|
|
71
|
+
for _, uuid in ipairs(due) do
|
|
72
|
+
local payload = redis.call('HGET', KEYS[1], uuid)
|
|
73
|
+
if not payload then
|
|
74
|
+
redis.call('ZREM', KEYS[2], uuid)
|
|
75
|
+
else
|
|
76
|
+
local task = cjson.decode(payload)
|
|
77
|
+
if task.isSent or task.isFailed or task.isCancelled or task.isSettled then
|
|
78
|
+
redis.call('ZREM', KEYS[2], uuid)
|
|
79
|
+
elseif tonumber(task.tries or 0) >= maxTries and not (type(task.recipientResults) == 'table' and task.isSending) then
|
|
80
|
+
redis.call('ZREM', KEYS[2], uuid)
|
|
81
|
+
elseif task.isSending then
|
|
82
|
+
local eligibleAt = tonumber(task.sendingAt or 0) + sendingTimeout
|
|
83
|
+
if eligibleAt > now then
|
|
84
|
+
redis.call('ZADD', KEYS[2], eligibleAt, uuid)
|
|
85
|
+
else
|
|
86
|
+
table.insert(tasks, payload)
|
|
87
|
+
end
|
|
88
|
+
elseif tonumber(task.sendAt or 0) <= now then
|
|
89
|
+
table.insert(tasks, payload)
|
|
90
|
+
else
|
|
91
|
+
redis.call('ZADD', KEYS[2], tonumber(task.sendAt), uuid)
|
|
92
|
+
end
|
|
93
|
+
if #tasks >= limit then
|
|
94
|
+
break
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
return cjson.encode(tasks)
|
|
100
|
+
`;
|
|
101
|
+
|
|
102
|
+
const UPDATE_TAGGED_TASK_SCRIPT = `
|
|
103
|
+
local payload = redis.call('HGET', KEYS[1], ARGV[1])
|
|
104
|
+
if not payload then
|
|
105
|
+
redis.call('ZREM', KEYS[2], ARGV[1])
|
|
106
|
+
return 0
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
local task = cjson.decode(payload)
|
|
110
|
+
local update = cjson.decode(ARGV[2])
|
|
111
|
+
local mode = ARGV[3]
|
|
112
|
+
local now = tonumber(ARGV[4])
|
|
113
|
+
local sendingTimeout = tonumber(ARGV[5])
|
|
114
|
+
local expectedTries = tonumber(ARGV[6])
|
|
115
|
+
local leaseSendingAt = tonumber(ARGV[7])
|
|
116
|
+
if payload ~= ARGV[8] then return -1 end
|
|
117
|
+
|
|
118
|
+
if mode == 'claim' then
|
|
119
|
+
if task.isSent or task.isFailed or task.isCancelled or task.isSettled or tonumber(task.tries or 0) ~= expectedTries then
|
|
120
|
+
return 0
|
|
121
|
+
end
|
|
122
|
+
if task.isSending and tonumber(task.sendingAt or 0) > now - sendingTimeout then
|
|
123
|
+
return 0
|
|
124
|
+
end
|
|
125
|
+
elseif mode == 'lease' then
|
|
126
|
+
if task.isSent or task.isSettled or task.isCancelled or task.isFailed or not task.isSending
|
|
127
|
+
or tonumber(task.tries or 0) ~= expectedTries
|
|
128
|
+
or tonumber(task.sendingAt or 0) ~= leaseSendingAt then
|
|
129
|
+
return 0
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
if mode == 'cancel' and (task.isSent or task.isSettled or task.isCancelled
|
|
134
|
+
or (type(task.recipientResults) == 'table' and task.isFailed)) then
|
|
135
|
+
return 0
|
|
136
|
+
end
|
|
137
|
+
for key, value in pairs(update) do
|
|
138
|
+
task[key] = value
|
|
139
|
+
end
|
|
140
|
+
redis.call('HSET', KEYS[1], ARGV[1], ARGV[9])
|
|
141
|
+
|
|
142
|
+
if task.isSent or task.isFailed or task.isCancelled or task.isSettled then
|
|
143
|
+
redis.call('ZREM', KEYS[2], ARGV[1])
|
|
144
|
+
if #KEYS > 3 then
|
|
145
|
+
local concatUuid = redis.call('GET', KEYS[3])
|
|
146
|
+
if concatUuid == ARGV[1] then
|
|
147
|
+
redis.call('DEL', KEYS[3])
|
|
148
|
+
redis.call('ZREM', KEYS[4], KEYS[3])
|
|
149
|
+
elseif not concatUuid then
|
|
150
|
+
redis.call('ZREM', KEYS[4], KEYS[3])
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
elseif task.isSending then
|
|
154
|
+
redis.call('ZADD', KEYS[2], tonumber(task.sendingAt or now) + sendingTimeout, ARGV[1])
|
|
155
|
+
else
|
|
156
|
+
redis.call('ZADD', KEYS[2], tonumber(task.sendAt), ARGV[1])
|
|
157
|
+
end
|
|
158
|
+
return 1
|
|
159
|
+
`;
|
|
160
|
+
|
|
161
|
+
const APPEND_TAGGED_MAIL_OPTION_SCRIPT = `
|
|
162
|
+
local payload = redis.call('HGET', KEYS[1], ARGV[1])
|
|
163
|
+
if not payload then
|
|
164
|
+
return 0
|
|
165
|
+
end
|
|
166
|
+
local task = cjson.decode(payload)
|
|
167
|
+
if task.isSending or task.isSent or task.isFailed or task.isCancelled or task.isSettled or type(task.recipientResults) == 'table' then
|
|
168
|
+
return 0
|
|
169
|
+
end
|
|
170
|
+
if payload ~= ARGV[3] then return -1 end
|
|
171
|
+
redis.call('HSET', KEYS[1], ARGV[1], ARGV[4])
|
|
172
|
+
return 1
|
|
173
|
+
`;
|
|
174
|
+
|
|
175
|
+
const REMOVE_TAGGED_TASK_SCRIPT = `
|
|
176
|
+
local payload = redis.call('HGET', KEYS[1], ARGV[1])
|
|
177
|
+
if not payload then
|
|
178
|
+
redis.call('ZREM', KEYS[2], ARGV[1])
|
|
179
|
+
return 0
|
|
180
|
+
end
|
|
181
|
+
local task = cjson.decode(payload)
|
|
182
|
+
if ARGV[2] == 'cancel' and (task.isSent or task.isSettled or task.isCancelled
|
|
183
|
+
or (type(task.recipientResults) == 'table' and task.isFailed)) then return 0 end
|
|
184
|
+
if ARGV[2] == 'lease' and (task.isSent or task.isSettled or task.isCancelled or task.isFailed or not task.isSending
|
|
185
|
+
or tonumber(task.tries or 0) ~= tonumber(ARGV[3])
|
|
186
|
+
or tonumber(task.sendingAt or 0) ~= tonumber(ARGV[4])) then
|
|
187
|
+
return 0
|
|
188
|
+
end
|
|
189
|
+
redis.call('HDEL', KEYS[1], ARGV[1])
|
|
190
|
+
redis.call('ZREM', KEYS[2], ARGV[1])
|
|
191
|
+
if #KEYS > 3 then
|
|
192
|
+
local concatUuid = redis.call('GET', KEYS[3])
|
|
193
|
+
if concatUuid == ARGV[1] then
|
|
194
|
+
redis.call('DEL', KEYS[3])
|
|
195
|
+
redis.call('ZREM', KEYS[4], KEYS[3])
|
|
196
|
+
elseif not concatUuid then
|
|
197
|
+
redis.call('ZREM', KEYS[4], KEYS[3])
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
return 1
|
|
201
|
+
`;
|
|
202
|
+
|
|
203
|
+
const normalizePolicyArrays = (task) => {
|
|
204
|
+
if (task.recipientResults && !Array.isArray(task.recipientResults) && Object.keys(task.recipientResults).length === 0) task.recipientResults = [];
|
|
205
|
+
if (Array.isArray(task.recipientResults)) {
|
|
206
|
+
for (const result of task.recipientResults) {
|
|
207
|
+
for (const field of ['sources', 'reasons']) {
|
|
208
|
+
if (result[field] && !Array.isArray(result[field]) && Object.keys(result[field]).length === 0) result[field] = [];
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return task;
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
const sha1Hex = (string) => createHash('sha1').update(string).digest('hex');
|
|
216
|
+
|
|
217
|
+
const isNoScriptError = (error) => {
|
|
218
|
+
return !!error && (error.code === 'NOSCRIPT' || (typeof error.message === 'string' && error.message.includes('NOSCRIPT')));
|
|
219
|
+
};
|
|
32
220
|
|
|
33
221
|
const canReleaseLease = (currentTask, updateObj) => {
|
|
34
222
|
return currentTask
|
|
@@ -36,6 +224,8 @@ const canReleaseLease = (currentTask, updateObj) => {
|
|
|
36
224
|
&& currentTask.isSending === true
|
|
37
225
|
&& (typeof currentTask.sendingAt === 'number' ? currentTask.sendingAt : 0) === updateObj.leaseSendingAt
|
|
38
226
|
&& currentTask.isCancelled !== true
|
|
227
|
+
&& currentTask.isSent !== true
|
|
228
|
+
&& currentTask.isSettled !== true
|
|
39
229
|
&& currentTask.isFailed !== true;
|
|
40
230
|
};
|
|
41
231
|
|
|
@@ -43,7 +233,7 @@ const canClaimTask = (currentTask, task, now, sendingTimeout) => {
|
|
|
43
233
|
if (!currentTask) {
|
|
44
234
|
return false;
|
|
45
235
|
}
|
|
46
|
-
if (currentTask.isSent === true || currentTask.isFailed === true || currentTask.isCancelled === true) {
|
|
236
|
+
if (currentTask.isSent === true || currentTask.isFailed === true || currentTask.isCancelled === true || currentTask.isSettled === true) {
|
|
47
237
|
return false;
|
|
48
238
|
}
|
|
49
239
|
if (currentTask.tries !== task.tries) {
|
|
@@ -62,11 +252,11 @@ const isIterateCandidate = (candidate, now, sendingTimeout, maxTries) => {
|
|
|
62
252
|
if (!candidate || typeof candidate !== 'object') {
|
|
63
253
|
return false;
|
|
64
254
|
}
|
|
65
|
-
if (candidate.isSent === true || candidate.isFailed === true || candidate.isCancelled === true) {
|
|
255
|
+
if (candidate.isSent === true || candidate.isFailed === true || candidate.isCancelled === true || candidate.isSettled === true) {
|
|
66
256
|
return false;
|
|
67
257
|
}
|
|
68
258
|
const tries = typeof candidate.tries === 'number' ? candidate.tries : 0;
|
|
69
|
-
if (tries >= maxTries) {
|
|
259
|
+
if (tries >= maxTries && !(Array.isArray(candidate.recipientResults) && candidate.isSending === true)) {
|
|
70
260
|
return false;
|
|
71
261
|
}
|
|
72
262
|
if (candidate.isSending === true) {
|
|
@@ -94,6 +284,7 @@ class RedisQueue {
|
|
|
94
284
|
*/
|
|
95
285
|
constructor (opts) {
|
|
96
286
|
this.name = 'redis-queue';
|
|
287
|
+
this.supportsRecipientPolicies = true;
|
|
97
288
|
if (!opts || typeof opts !== 'object') {
|
|
98
289
|
throw new TypeError('[mail-time] Configuration object must be passed into RedisQueue constructor');
|
|
99
290
|
}
|
|
@@ -102,7 +293,21 @@ class RedisQueue {
|
|
|
102
293
|
throw new Error('[mail-time] [RedisQueue] required {client} option is missing, e.g. returned from `redis.createClient()` or `redis.createCluster()` method');
|
|
103
294
|
}
|
|
104
295
|
|
|
296
|
+
if (opts.useHashTags !== undefined && typeof opts.useHashTags !== 'boolean') {
|
|
297
|
+
throw new TypeError(`[mail-time] [RedisQueue] {useHashTags} option must be a boolean (received: ${typeof opts.useHashTags})`);
|
|
298
|
+
}
|
|
299
|
+
|
|
105
300
|
this.client = opts.client;
|
|
301
|
+
this.useHashTags = opts.useHashTags === true;
|
|
302
|
+
this.__scriptSources = {
|
|
303
|
+
push: PUSH_TAGGED_TASK_SCRIPT,
|
|
304
|
+
iterate: ITERATE_TAGGED_TASKS_SCRIPT,
|
|
305
|
+
update: UPDATE_TAGGED_TASK_SCRIPT,
|
|
306
|
+
append: APPEND_TAGGED_MAIL_OPTION_SCRIPT,
|
|
307
|
+
remove: REMOVE_TAGGED_TASK_SCRIPT,
|
|
308
|
+
};
|
|
309
|
+
this.__scriptShas = Object.fromEntries(Object.entries(this.__scriptSources).map(([name, source]) => [name, sha1Hex(source)]));
|
|
310
|
+
this.__loadedShas = new Set();
|
|
106
311
|
if (typeof opts.prefix === 'string') {
|
|
107
312
|
this.__applyPrefix(opts.prefix);
|
|
108
313
|
}
|
|
@@ -110,8 +315,16 @@ class RedisQueue {
|
|
|
110
315
|
|
|
111
316
|
/** @internal */
|
|
112
317
|
__applyPrefix(prefix) {
|
|
318
|
+
if (this.useHashTags && !VALID_PREFIX.test(prefix)) {
|
|
319
|
+
throw new Error(`[mail-time] [RedisQueue] {prefix} option must match ${VALID_PREFIX} when {useHashTags} is true (received: "${prefix}")`);
|
|
320
|
+
}
|
|
113
321
|
this.prefix = prefix;
|
|
114
|
-
this.uniqueName = `mailtime:${prefix}`;
|
|
322
|
+
this.uniqueName = this.useHashTags ? `mailtime:{${prefix}}` : `mailtime:${prefix}`;
|
|
323
|
+
if (this.useHashTags) {
|
|
324
|
+
this.lettersKey = `${this.uniqueName}:letters`;
|
|
325
|
+
this.scheduleKey = `${this.uniqueName}:schedule`;
|
|
326
|
+
this.concatKeysKey = `${this.uniqueName}:concatkeys`;
|
|
327
|
+
}
|
|
115
328
|
}
|
|
116
329
|
|
|
117
330
|
/** @internal */
|
|
@@ -126,6 +339,60 @@ class RedisQueue {
|
|
|
126
339
|
debug(this.mailTimeInstance?.debug === true, `[${this.name}]`, ...args);
|
|
127
340
|
}
|
|
128
341
|
|
|
342
|
+
/** @internal */
|
|
343
|
+
async __runScript(scriptKey, options) {
|
|
344
|
+
const source = this.__scriptSources[scriptKey];
|
|
345
|
+
const sha = this.__scriptShas[scriptKey];
|
|
346
|
+
if (!source || !sha) {
|
|
347
|
+
throw new Error(`[mail-time] [RedisQueue] unknown script "${scriptKey}"`);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if (this.useHashTags && typeof this.client.nodeClient === 'function' && typeof this.client.sendCommand === 'function') {
|
|
351
|
+
const key = options.keys[0];
|
|
352
|
+
const args = [`${options.keys.length}`, ...options.keys, ...options.arguments];
|
|
353
|
+
try {
|
|
354
|
+
return await this.client.sendCommand(key, false, ['EVALSHA', sha, ...args]);
|
|
355
|
+
} catch (error) {
|
|
356
|
+
if (!isNoScriptError(error)) throw error;
|
|
357
|
+
}
|
|
358
|
+
return await this.client.sendCommand(key, false, ['EVAL', source, ...args]);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if (this.__loadedShas.has(sha) && typeof this.client.evalSha === 'function') {
|
|
362
|
+
try {
|
|
363
|
+
return await this.client.evalSha(sha, options);
|
|
364
|
+
} catch (error) {
|
|
365
|
+
if (!isNoScriptError(error)) {
|
|
366
|
+
throw error;
|
|
367
|
+
}
|
|
368
|
+
this.__loadedShas.delete(sha);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (typeof this.client.scriptLoad === 'function' && typeof this.client.evalSha === 'function') {
|
|
373
|
+
try {
|
|
374
|
+
await this.client.scriptLoad(source);
|
|
375
|
+
this.__loadedShas.add(sha);
|
|
376
|
+
return await this.client.evalSha(sha, options);
|
|
377
|
+
} catch (error) {
|
|
378
|
+
if (!isNoScriptError(error)) {
|
|
379
|
+
this.__debug(`[script:${scriptKey}] scriptLoad failed; falling back to EVAL`, error);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (typeof this.client.eval !== 'function') {
|
|
385
|
+
throw new Error('[mail-time] [RedisQueue] Redis Cluster client must support EVAL');
|
|
386
|
+
}
|
|
387
|
+
return await this.client.eval(source, options);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** @internal */
|
|
391
|
+
__getTaggedConcatKey(to) {
|
|
392
|
+
this.__ensurePrefix();
|
|
393
|
+
return `${this.uniqueName}:concatletter:${to}`;
|
|
394
|
+
}
|
|
395
|
+
|
|
129
396
|
/**
|
|
130
397
|
* @async
|
|
131
398
|
* @memberOf RedisQueue
|
|
@@ -158,7 +425,9 @@ class RedisQueue {
|
|
|
158
425
|
}
|
|
159
426
|
|
|
160
427
|
try {
|
|
161
|
-
const
|
|
428
|
+
const pingClient = typeof this.client.ping === 'function' ? this.client
|
|
429
|
+
: await this.client.nodeClient(this.client.getRandomNode());
|
|
430
|
+
const ping = await pingClient.ping();
|
|
162
431
|
if (ping === 'PONG') {
|
|
163
432
|
return {
|
|
164
433
|
status: 'OK',
|
|
@@ -203,6 +472,26 @@ class RedisQueue {
|
|
|
203
472
|
const maxTries = (this.mailTimeInstance && typeof this.mailTimeInstance.maxTries === 'number')
|
|
204
473
|
? this.mailTimeInstance.maxTries
|
|
205
474
|
: 60;
|
|
475
|
+
|
|
476
|
+
if (this.useHashTags) {
|
|
477
|
+
this.__ensurePrefix();
|
|
478
|
+
const dispatchLimit = Number.isFinite(limit) && limit > 0 ? Math.floor(limit) : TAGGED_ITERATE_LIMIT;
|
|
479
|
+
const payload = await this.__runScript('iterate', {
|
|
480
|
+
keys: [this.lettersKey, this.scheduleKey, this.concatKeysKey],
|
|
481
|
+
arguments: [`${now}`, `${maxTries}`, `${sendingTimeout}`, `${dispatchLimit}`, `${Math.max(dispatchLimit * 20, 100)}`],
|
|
482
|
+
});
|
|
483
|
+
const candidates = payload ? JSON.parse(String(payload)) : [];
|
|
484
|
+
if (!Array.isArray(candidates)) {
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
for (const value of candidates) {
|
|
488
|
+
const candidate = normalizePolicyArrays(typeof value === 'string' ? JSON.parse(value) : value);
|
|
489
|
+
if (isIterateCandidate(candidate, now, sendingTimeout, maxTries)) {
|
|
490
|
+
await this.mailTimeInstance.___dispatch(candidate);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
206
495
|
let dispatched = 0;
|
|
207
496
|
|
|
208
497
|
const matchPattern = this.__getKey('*', 'sendat');
|
|
@@ -259,20 +548,21 @@ class RedisQueue {
|
|
|
259
548
|
return null;
|
|
260
549
|
}
|
|
261
550
|
|
|
262
|
-
const concatKey = this.__getKey(to, 'concatletter');
|
|
551
|
+
const concatKey = this.useHashTags ? this.__getTaggedConcatKey(to) : this.__getKey(to, 'concatletter');
|
|
263
552
|
const uuid = await this.client.get(concatKey);
|
|
264
553
|
if (!uuid) {
|
|
265
554
|
return null;
|
|
266
555
|
}
|
|
267
556
|
|
|
268
|
-
const
|
|
269
|
-
|
|
557
|
+
const taskJSON = this.useHashTags
|
|
558
|
+
? await this.client.hGet(this.lettersKey, uuid)
|
|
559
|
+
: await this.client.get(this.__getKey(uuid, 'letter'));
|
|
270
560
|
if (!taskJSON) {
|
|
271
561
|
return null;
|
|
272
562
|
}
|
|
273
563
|
|
|
274
564
|
const task = JSON.parse(taskJSON);
|
|
275
|
-
if (!task || task.isSent === true || task.isCancelled === true || task.isFailed === true || task.sendAt > sendAt || task.isSending === true || task.tries >= this.mailTimeInstance.maxTries) {
|
|
565
|
+
if (!task || task.isSettled === true || task.recipientResults != null || task.isSent === true || task.isCancelled === true || task.isFailed === true || task.sendAt > sendAt || task.isSending === true || task.tries >= this.mailTimeInstance.maxTries) {
|
|
276
566
|
return null;
|
|
277
567
|
}
|
|
278
568
|
|
|
@@ -297,6 +587,19 @@ class RedisQueue {
|
|
|
297
587
|
task.sendAt = +task.sendAt;
|
|
298
588
|
}
|
|
299
589
|
|
|
590
|
+
if (this.useHashTags) {
|
|
591
|
+
this.__ensurePrefix();
|
|
592
|
+
const keys = [this.lettersKey, this.scheduleKey];
|
|
593
|
+
const args = [task.uuid, JSON.stringify(task), `${+task.sendAt}`];
|
|
594
|
+
if (task.to) {
|
|
595
|
+
keys.push(this.__getTaggedConcatKey(task.to));
|
|
596
|
+
keys.push(this.concatKeysKey);
|
|
597
|
+
args.push(`${task.sendAt - 128}`);
|
|
598
|
+
}
|
|
599
|
+
await this.__runScript('push', { keys, arguments: args });
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
|
|
300
603
|
const letterKey = this.__getKey(task.uuid, 'letter');
|
|
301
604
|
const sendatKey = this.__getKey(task.uuid, 'sendat');
|
|
302
605
|
const taskJSON = JSON.stringify(task);
|
|
@@ -332,30 +635,59 @@ class RedisQueue {
|
|
|
332
635
|
* @returns {Promise<boolean>} returns `true` if cancelled or `false` if not found, was sent, or was cancelled previously
|
|
333
636
|
*/
|
|
334
637
|
async cancel(uuid) {
|
|
638
|
+
return await this.__serialize(() => this.__cancel(uuid));
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/** @internal */
|
|
642
|
+
async __serialize(operation) {
|
|
643
|
+
if (this.useHashTags) return await operation();
|
|
644
|
+
const previous = clientTransactions.get(this.client) || Promise.resolve();
|
|
645
|
+
const pending = previous.then(operation);
|
|
646
|
+
const settled = pending.catch(() => void 0);
|
|
647
|
+
clientTransactions.set(this.client, settled);
|
|
648
|
+
try { return await pending; }
|
|
649
|
+
finally { if (clientTransactions.get(this.client) === settled) clientTransactions.delete(this.client); }
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/** @internal */
|
|
653
|
+
async __cancel(uuid) {
|
|
335
654
|
this.__debug('[cancel]', uuid);
|
|
336
655
|
if (typeof uuid !== 'string') {
|
|
337
656
|
return false;
|
|
338
657
|
}
|
|
339
658
|
|
|
340
|
-
|
|
341
|
-
const
|
|
342
|
-
const
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
659
|
+
this.__ensurePrefix();
|
|
660
|
+
const atomic = typeof this.client.watch === 'function' && typeof this.client.multi === 'function';
|
|
661
|
+
const letterKey = this.__getKey(uuid);
|
|
662
|
+
try {
|
|
663
|
+
if (!this.useHashTags && atomic) await this.client.watch(letterKey);
|
|
664
|
+
const payload = this.useHashTags ? await this.client.hGet(this.lettersKey, uuid) : await this.client.get(letterKey);
|
|
665
|
+
const task = payload ? JSON.parse(payload) : null;
|
|
666
|
+
if (!task || task.isSent || task.isCancelled || task.isSettled || (task.recipientResults != null && task.isFailed)) return false;
|
|
667
|
+
if (this.useHashTags) {
|
|
668
|
+
const keys = [this.lettersKey, this.scheduleKey];
|
|
669
|
+
if (task.to) keys.push(this.__getTaggedConcatKey(task.to), this.concatKeysKey);
|
|
670
|
+
const keep = this.mailTimeInstance.keepHistory;
|
|
671
|
+
const args = keep
|
|
672
|
+
? [uuid, JSON.stringify({ isCancelled: true }), 'cancel', `${Date.now()}`, `${this.mailTimeInstance.sendingTimeout || 300000}`, '0', '0', payload, JSON.stringify({ ...task, isCancelled: true })]
|
|
673
|
+
: [uuid, 'cancel', '0', '0'];
|
|
674
|
+
return Number(await this.__runScript(keep ? 'update' : 'remove', { keys, arguments: args })) >= 1;
|
|
675
|
+
}
|
|
676
|
+
if (!atomic) {
|
|
677
|
+
if (task.recipientResults != null) return false;
|
|
678
|
+
return this.mailTimeInstance.keepHistory ? await this.__update(task, { isCancelled: true }) : await this.__remove(task);
|
|
679
|
+
}
|
|
680
|
+
const multi = this.client.multi();
|
|
681
|
+
if (this.mailTimeInstance.keepHistory) multi.set(letterKey, JSON.stringify({ ...task, isCancelled: true }));
|
|
682
|
+
else multi.del(letterKey);
|
|
683
|
+
multi.del(this.__getKey(uuid, 'sendat'));
|
|
684
|
+
return (await multi.exec()) !== null;
|
|
685
|
+
} catch (error) {
|
|
686
|
+
logError('[cancel] storage error', error);
|
|
349
687
|
return false;
|
|
688
|
+
} finally {
|
|
689
|
+
if (!this.useHashTags && atomic) await this.client.unwatch?.();
|
|
350
690
|
}
|
|
351
|
-
|
|
352
|
-
if (!this.mailTimeInstance.keepHistory) {
|
|
353
|
-
return await this.remove(task);
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
return await this.update(task, {
|
|
357
|
-
isCancelled: true,
|
|
358
|
-
});
|
|
359
691
|
}
|
|
360
692
|
|
|
361
693
|
/**
|
|
@@ -368,11 +700,40 @@ class RedisQueue {
|
|
|
368
700
|
* @returns {Promise<boolean>} returns `true` if removed or `false` if not found
|
|
369
701
|
*/
|
|
370
702
|
async remove(task, opts) {
|
|
703
|
+
return await this.__serialize(() => this.__remove(task, opts));
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/** @internal */
|
|
707
|
+
async __remove(task, opts) {
|
|
371
708
|
this.__debug('[remove]', task?.uuid);
|
|
372
709
|
if (!task || typeof task !== 'object' || typeof task.uuid !== 'string') {
|
|
373
710
|
return false;
|
|
374
711
|
}
|
|
375
712
|
|
|
713
|
+
if (this.useHashTags) {
|
|
714
|
+
this.__ensurePrefix();
|
|
715
|
+
try {
|
|
716
|
+
const keys = [this.lettersKey, this.scheduleKey];
|
|
717
|
+
if (task.to) {
|
|
718
|
+
keys.push(this.__getTaggedConcatKey(task.to));
|
|
719
|
+
keys.push(this.concatKeysKey);
|
|
720
|
+
}
|
|
721
|
+
const result = await this.__runScript('remove', {
|
|
722
|
+
keys,
|
|
723
|
+
arguments: [
|
|
724
|
+
task.uuid,
|
|
725
|
+
isSendLeaseRemove(opts) ? 'lease' : 'plain',
|
|
726
|
+
`${opts?.leaseTries || 0}`,
|
|
727
|
+
`${opts?.leaseSendingAt || 0}`,
|
|
728
|
+
],
|
|
729
|
+
});
|
|
730
|
+
return Number(result) >= 1;
|
|
731
|
+
} catch (opError) {
|
|
732
|
+
logError('[remove] [tagged] [opError]', opError);
|
|
733
|
+
return false;
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
|
|
376
737
|
const letterKey = this.__getKey(task.uuid, 'letter');
|
|
377
738
|
if (isSendLeaseRemove(opts)) {
|
|
378
739
|
if (typeof this.client.watch !== 'function' || typeof this.client.multi !== 'function') {
|
|
@@ -390,6 +751,8 @@ class RedisQueue {
|
|
|
390
751
|
|| currentTask.isSending !== true
|
|
391
752
|
|| (typeof currentTask.sendingAt === 'number' ? currentTask.sendingAt : 0) !== opts.leaseSendingAt
|
|
392
753
|
|| currentTask.isCancelled === true
|
|
754
|
+
|| currentTask.isSent === true
|
|
755
|
+
|| currentTask.isSettled === true
|
|
393
756
|
|| currentTask.isFailed === true) {
|
|
394
757
|
await this.client.unwatch?.();
|
|
395
758
|
return false;
|
|
@@ -433,6 +796,11 @@ class RedisQueue {
|
|
|
433
796
|
* @returns {Promise<boolean>} returns `true` if updated or `false` if not found or no changes was made
|
|
434
797
|
*/
|
|
435
798
|
async update(task, updateObj) {
|
|
799
|
+
return await this.__serialize(() => this.__update(task, updateObj));
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/** @internal */
|
|
803
|
+
async __update(task, updateObj) {
|
|
436
804
|
this.__debug('[update]', task?.uuid);
|
|
437
805
|
if (!task || typeof task !== 'object' || typeof task.uuid !== 'string' || !updateObj || typeof updateObj !== 'object') {
|
|
438
806
|
return false;
|
|
@@ -443,10 +811,43 @@ class RedisQueue {
|
|
|
443
811
|
const isClaim = isSendClaimUpdate(updateObj);
|
|
444
812
|
const isAppend = isAppendMailOptionUpdate(updateObj);
|
|
445
813
|
const isLeaseRelease = isSendLeaseGuardedUpdate(updateObj);
|
|
814
|
+
if ((hasOwnProp(updateObj, 'recipientResults') || hasOwnProp(updateObj, 'isSettled')) && !isClaim && !isLeaseRelease) return false;
|
|
446
815
|
const now = isClaim && typeof updateObj.sendingAt === 'number' ? updateObj.sendingAt : Date.now();
|
|
447
816
|
const sendingTimeout = this.mailTimeInstance?.sendingTimeout || 300000;
|
|
448
817
|
|
|
449
818
|
try {
|
|
819
|
+
if (this.useHashTags) {
|
|
820
|
+
// JS serialization preserves empty arrays and scalar values that Lua cjson changes.
|
|
821
|
+
// The script compares the exact payload as well as the claim/lease predicates.
|
|
822
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
823
|
+
const payload = await this.client.hGet(this.lettersKey, task.uuid);
|
|
824
|
+
if (!payload) return false;
|
|
825
|
+
const current = normalizePolicyArrays(JSON.parse(payload));
|
|
826
|
+
const persist = stripInternalUpdateMeta(updateObj);
|
|
827
|
+
const next = isAppend
|
|
828
|
+
? { ...current, mailOptions: [...(current.mailOptions || []), updateObj.appendMailOption] }
|
|
829
|
+
: { ...current, ...persist };
|
|
830
|
+
if (isAppend) {
|
|
831
|
+
const result = Number(await this.__runScript('append', {
|
|
832
|
+
keys: [this.lettersKey],
|
|
833
|
+
arguments: [task.uuid, JSON.stringify(updateObj.appendMailOption), payload, JSON.stringify(next)],
|
|
834
|
+
}));
|
|
835
|
+
if (result === -1) continue;
|
|
836
|
+
return result >= 1;
|
|
837
|
+
}
|
|
838
|
+
const keys = [this.lettersKey, this.scheduleKey];
|
|
839
|
+
if (current.to) keys.push(this.__getTaggedConcatKey(current.to), this.concatKeysKey);
|
|
840
|
+
const result = Number(await this.__runScript('update', {
|
|
841
|
+
keys,
|
|
842
|
+
arguments: [task.uuid, JSON.stringify(persist), isClaim ? 'claim' : (isLeaseRelease ? 'lease' : 'plain'),
|
|
843
|
+
`${now}`, `${sendingTimeout}`, `${isClaim ? task.tries : (updateObj.leaseTries || 0)}`,
|
|
844
|
+
`${updateObj.leaseSendingAt || 0}`, payload, JSON.stringify(next)],
|
|
845
|
+
}));
|
|
846
|
+
if (result !== -1) return result >= 1;
|
|
847
|
+
}
|
|
848
|
+
return false;
|
|
849
|
+
}
|
|
850
|
+
|
|
450
851
|
if (isAppend) {
|
|
451
852
|
if (typeof this.client.watch !== 'function' || typeof this.client.multi !== 'function') {
|
|
452
853
|
if (!RedisQueue.__atomicAppendWarned) {
|
|
@@ -458,7 +859,7 @@ class RedisQueue {
|
|
|
458
859
|
return false;
|
|
459
860
|
}
|
|
460
861
|
const currentTask = JSON.parse(taskJSON);
|
|
461
|
-
if (currentTask.isSending === true || currentTask.isSent === true || currentTask.isFailed === true || currentTask.isCancelled === true) {
|
|
862
|
+
if (currentTask.isSending === true || currentTask.isSent === true || currentTask.isFailed === true || currentTask.isCancelled === true || currentTask.isSettled === true || Array.isArray(currentTask.recipientResults)) {
|
|
462
863
|
return false;
|
|
463
864
|
}
|
|
464
865
|
currentTask.mailOptions = [...(currentTask.mailOptions || []), updateObj.appendMailOption];
|
|
@@ -473,7 +874,7 @@ class RedisQueue {
|
|
|
473
874
|
return false;
|
|
474
875
|
}
|
|
475
876
|
const currentTask = JSON.parse(taskJSON);
|
|
476
|
-
if (currentTask.isSending === true || currentTask.isSent === true || currentTask.isFailed === true || currentTask.isCancelled === true) {
|
|
877
|
+
if (currentTask.isSending === true || currentTask.isSent === true || currentTask.isFailed === true || currentTask.isCancelled === true || currentTask.isSettled === true || Array.isArray(currentTask.recipientResults)) {
|
|
477
878
|
await this.client.unwatch?.();
|
|
478
879
|
return false;
|
|
479
880
|
}
|
|
@@ -513,7 +914,7 @@ class RedisQueue {
|
|
|
513
914
|
const updatedTask = { ...currentTask, ...stripInternalUpdateMeta(updateObj) };
|
|
514
915
|
const multi = this.client.multi();
|
|
515
916
|
multi.set(letterKey, JSON.stringify(updatedTask));
|
|
516
|
-
if (updatedTask.isSent === true || updatedTask.isFailed === true || updatedTask.isCancelled === true) {
|
|
917
|
+
if (updatedTask.isSent === true || updatedTask.isFailed === true || updatedTask.isCancelled === true || updatedTask.isSettled === true) {
|
|
517
918
|
multi.del(sendatKey);
|
|
518
919
|
} else if (updatedTask.sendAt) {
|
|
519
920
|
multi.set(sendatKey, `${+updatedTask.sendAt}`);
|
|
@@ -532,7 +933,7 @@ class RedisQueue {
|
|
|
532
933
|
const updatedTask = { ...currentTask, ...stripInternalUpdateMeta(updateObj) };
|
|
533
934
|
await this.client.set(letterKey, JSON.stringify(updatedTask));
|
|
534
935
|
|
|
535
|
-
if (updatedTask.isSent === true || updatedTask.isFailed === true || updatedTask.isCancelled === true) {
|
|
936
|
+
if (updatedTask.isSent === true || updatedTask.isFailed === true || updatedTask.isCancelled === true || updatedTask.isSettled === true) {
|
|
536
937
|
await this.client.del(sendatKey);
|
|
537
938
|
} else if (updatedTask.sendAt) {
|
|
538
939
|
await this.client.set(sendatKey, `${+updatedTask.sendAt}`);
|
|
@@ -558,6 +959,15 @@ class RedisQueue {
|
|
|
558
959
|
throw new Error(`[mail-time] [RedisQueue] [__getKey] unsupported key "${type}" passed into the second argument`);
|
|
559
960
|
}
|
|
560
961
|
this.__ensurePrefix();
|
|
962
|
+
if (this.useHashTags) {
|
|
963
|
+
if (type === 'letter') {
|
|
964
|
+
return this.lettersKey;
|
|
965
|
+
}
|
|
966
|
+
if (type === 'sendat') {
|
|
967
|
+
return this.scheduleKey;
|
|
968
|
+
}
|
|
969
|
+
return this.__getTaggedConcatKey(uuid);
|
|
970
|
+
}
|
|
561
971
|
return `${this.uniqueName}:${type}:${uuid}`;
|
|
562
972
|
}
|
|
563
973
|
}
|