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/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 {(options: object) => AsyncIterable<string|string[]>} scanIterator
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 ping = await this.client.ping();
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 letterKey = this.__getKey(uuid, 'letter');
269
- const taskJSON = await this.client.get(letterKey);
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
- await this.client.del(this.__getKey(uuid, 'sendat'));
341
- const letterKey = this.__getKey(uuid, 'letter');
342
- const taskJSON = await this.client.get(letterKey);
343
- if (!taskJSON) {
344
- return false;
345
- }
346
-
347
- const task = JSON.parse(taskJSON);
348
- if (!task || task.isSent === true || task.isCancelled === true) {
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
  }