mail-time 5.1.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,6 +1,7 @@
1
1
  import {
2
2
  debug,
3
3
  logError,
4
+ hasOwnProp,
4
5
  isSendClaimUpdate,
5
6
  isSendLeaseGuardedUpdate,
6
7
  isAppendMailOptionUpdate,
@@ -15,9 +16,12 @@ import { createHash } from 'crypto';
15
16
  * @property {(key: string) => Promise<string|null>} get
16
17
  * @property {(key: string, value: string, options?: object) => Promise<unknown>} set
17
18
  * @property {(key: string|string[]) => Promise<number>} del
18
- * @property {() => Promise<string>} ping
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]
19
23
  * @property {(options: object) => AsyncIterable<string|string[]>} [scanIterator]
20
- * @property {(key: string, field: string) => Promise<string|null>} [hGet]
24
+ * @property {(key: string, field: string) => Promise<string|null|undefined>} [hGet]
21
25
  * @property {(script: string, options: { keys: string[], arguments: string[] }) => Promise<unknown>} [eval]
22
26
  * @property {(sha: string, options: { keys: string[], arguments: string[] }) => Promise<unknown>} [evalSha]
23
27
  * @property {(script: string) => Promise<string>} [scriptLoad]
@@ -37,6 +41,7 @@ const KEY_TYPES = new Set(['letter', 'sendat', 'concatletter']);
37
41
  const DEFAULT_PREFIX = 'default';
38
42
  const VALID_PREFIX = /^[A-Za-z0-9_\-:.]+$/;
39
43
  const TAGGED_ITERATE_LIMIT = 100;
44
+ const clientTransactions = new WeakMap();
40
45
 
41
46
  const PUSH_TAGGED_TASK_SCRIPT = `
42
47
  redis.call('HSET', KEYS[1], ARGV[1], ARGV[2])
@@ -69,17 +74,19 @@ const ITERATE_TAGGED_TASKS_SCRIPT = `
69
74
  redis.call('ZREM', KEYS[2], uuid)
70
75
  else
71
76
  local task = cjson.decode(payload)
72
- if task.isSent or task.isFailed or task.isCancelled or tonumber(task.tries or 0) >= maxTries then
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
73
80
  redis.call('ZREM', KEYS[2], uuid)
74
81
  elseif task.isSending then
75
82
  local eligibleAt = tonumber(task.sendingAt or 0) + sendingTimeout
76
83
  if eligibleAt > now then
77
84
  redis.call('ZADD', KEYS[2], eligibleAt, uuid)
78
85
  else
79
- table.insert(tasks, task)
86
+ table.insert(tasks, payload)
80
87
  end
81
88
  elseif tonumber(task.sendAt or 0) <= now then
82
- table.insert(tasks, task)
89
+ table.insert(tasks, payload)
83
90
  else
84
91
  redis.call('ZADD', KEYS[2], tonumber(task.sendAt), uuid)
85
92
  end
@@ -106,28 +113,33 @@ const UPDATE_TAGGED_TASK_SCRIPT = `
106
113
  local sendingTimeout = tonumber(ARGV[5])
107
114
  local expectedTries = tonumber(ARGV[6])
108
115
  local leaseSendingAt = tonumber(ARGV[7])
116
+ if payload ~= ARGV[8] then return -1 end
109
117
 
110
118
  if mode == 'claim' then
111
- if task.isSent or task.isFailed or task.isCancelled or tonumber(task.tries or 0) ~= expectedTries then
119
+ if task.isSent or task.isFailed or task.isCancelled or task.isSettled or tonumber(task.tries or 0) ~= expectedTries then
112
120
  return 0
113
121
  end
114
122
  if task.isSending and tonumber(task.sendingAt or 0) > now - sendingTimeout then
115
123
  return 0
116
124
  end
117
125
  elseif mode == 'lease' then
118
- if task.isCancelled or task.isFailed or not task.isSending
126
+ if task.isSent or task.isSettled or task.isCancelled or task.isFailed or not task.isSending
119
127
  or tonumber(task.tries or 0) ~= expectedTries
120
128
  or tonumber(task.sendingAt or 0) ~= leaseSendingAt then
121
129
  return 0
122
130
  end
123
131
  end
124
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
125
137
  for key, value in pairs(update) do
126
138
  task[key] = value
127
139
  end
128
- redis.call('HSET', KEYS[1], ARGV[1], cjson.encode(task))
140
+ redis.call('HSET', KEYS[1], ARGV[1], ARGV[9])
129
141
 
130
- if task.isSent or task.isFailed or task.isCancelled then
142
+ if task.isSent or task.isFailed or task.isCancelled or task.isSettled then
131
143
  redis.call('ZREM', KEYS[2], ARGV[1])
132
144
  if #KEYS > 3 then
133
145
  local concatUuid = redis.call('GET', KEYS[3])
@@ -152,12 +164,11 @@ const APPEND_TAGGED_MAIL_OPTION_SCRIPT = `
152
164
  return 0
153
165
  end
154
166
  local task = cjson.decode(payload)
155
- if task.isSending or task.isSent or task.isFailed or task.isCancelled then
167
+ if task.isSending or task.isSent or task.isFailed or task.isCancelled or task.isSettled or type(task.recipientResults) == 'table' then
156
168
  return 0
157
169
  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))
170
+ if payload ~= ARGV[3] then return -1 end
171
+ redis.call('HSET', KEYS[1], ARGV[1], ARGV[4])
161
172
  return 1
162
173
  `;
163
174
 
@@ -168,7 +179,9 @@ const REMOVE_TAGGED_TASK_SCRIPT = `
168
179
  return 0
169
180
  end
170
181
  local task = cjson.decode(payload)
171
- if ARGV[2] == 'lease' and (task.isCancelled or task.isFailed or not task.isSending
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
172
185
  or tonumber(task.tries or 0) ~= tonumber(ARGV[3])
173
186
  or tonumber(task.sendingAt or 0) ~= tonumber(ARGV[4])) then
174
187
  return 0
@@ -187,6 +200,18 @@ const REMOVE_TAGGED_TASK_SCRIPT = `
187
200
  return 1
188
201
  `;
189
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
+
190
215
  const sha1Hex = (string) => createHash('sha1').update(string).digest('hex');
191
216
 
192
217
  const isNoScriptError = (error) => {
@@ -199,6 +224,8 @@ const canReleaseLease = (currentTask, updateObj) => {
199
224
  && currentTask.isSending === true
200
225
  && (typeof currentTask.sendingAt === 'number' ? currentTask.sendingAt : 0) === updateObj.leaseSendingAt
201
226
  && currentTask.isCancelled !== true
227
+ && currentTask.isSent !== true
228
+ && currentTask.isSettled !== true
202
229
  && currentTask.isFailed !== true;
203
230
  };
204
231
 
@@ -206,7 +233,7 @@ const canClaimTask = (currentTask, task, now, sendingTimeout) => {
206
233
  if (!currentTask) {
207
234
  return false;
208
235
  }
209
- if (currentTask.isSent === true || currentTask.isFailed === true || currentTask.isCancelled === true) {
236
+ if (currentTask.isSent === true || currentTask.isFailed === true || currentTask.isCancelled === true || currentTask.isSettled === true) {
210
237
  return false;
211
238
  }
212
239
  if (currentTask.tries !== task.tries) {
@@ -225,11 +252,11 @@ const isIterateCandidate = (candidate, now, sendingTimeout, maxTries) => {
225
252
  if (!candidate || typeof candidate !== 'object') {
226
253
  return false;
227
254
  }
228
- if (candidate.isSent === true || candidate.isFailed === true || candidate.isCancelled === true) {
255
+ if (candidate.isSent === true || candidate.isFailed === true || candidate.isCancelled === true || candidate.isSettled === true) {
229
256
  return false;
230
257
  }
231
258
  const tries = typeof candidate.tries === 'number' ? candidate.tries : 0;
232
- if (tries >= maxTries) {
259
+ if (tries >= maxTries && !(Array.isArray(candidate.recipientResults) && candidate.isSending === true)) {
233
260
  return false;
234
261
  }
235
262
  if (candidate.isSending === true) {
@@ -257,6 +284,7 @@ class RedisQueue {
257
284
  */
258
285
  constructor (opts) {
259
286
  this.name = 'redis-queue';
287
+ this.supportsRecipientPolicies = true;
260
288
  if (!opts || typeof opts !== 'object') {
261
289
  throw new TypeError('[mail-time] Configuration object must be passed into RedisQueue constructor');
262
290
  }
@@ -319,6 +347,17 @@ class RedisQueue {
319
347
  throw new Error(`[mail-time] [RedisQueue] unknown script "${scriptKey}"`);
320
348
  }
321
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
+
322
361
  if (this.__loadedShas.has(sha) && typeof this.client.evalSha === 'function') {
323
362
  try {
324
363
  return await this.client.evalSha(sha, options);
@@ -386,7 +425,9 @@ class RedisQueue {
386
425
  }
387
426
 
388
427
  try {
389
- 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();
390
431
  if (ping === 'PONG') {
391
432
  return {
392
433
  status: 'OK',
@@ -443,7 +484,8 @@ class RedisQueue {
443
484
  if (!Array.isArray(candidates)) {
444
485
  return;
445
486
  }
446
- for (const candidate of candidates) {
487
+ for (const value of candidates) {
488
+ const candidate = normalizePolicyArrays(typeof value === 'string' ? JSON.parse(value) : value);
447
489
  if (isIterateCandidate(candidate, now, sendingTimeout, maxTries)) {
448
490
  await this.mailTimeInstance.___dispatch(candidate);
449
491
  }
@@ -520,7 +562,7 @@ class RedisQueue {
520
562
  }
521
563
 
522
564
  const task = JSON.parse(taskJSON);
523
- 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) {
524
566
  return null;
525
567
  }
526
568
 
@@ -593,35 +635,59 @@ class RedisQueue {
593
635
  * @returns {Promise<boolean>} returns `true` if cancelled or `false` if not found, was sent, or was cancelled previously
594
636
  */
595
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) {
596
654
  this.__debug('[cancel]', uuid);
597
655
  if (typeof uuid !== 'string') {
598
656
  return false;
599
657
  }
600
658
 
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'));
609
- if (!taskJSON) {
610
- return false;
611
- }
612
-
613
- const task = JSON.parse(taskJSON);
614
- 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);
615
687
  return false;
688
+ } finally {
689
+ if (!this.useHashTags && atomic) await this.client.unwatch?.();
616
690
  }
617
-
618
- if (!this.mailTimeInstance.keepHistory) {
619
- return await this.remove(task);
620
- }
621
-
622
- return await this.update(task, {
623
- isCancelled: true,
624
- });
625
691
  }
626
692
 
627
693
  /**
@@ -634,6 +700,11 @@ class RedisQueue {
634
700
  * @returns {Promise<boolean>} returns `true` if removed or `false` if not found
635
701
  */
636
702
  async remove(task, opts) {
703
+ return await this.__serialize(() => this.__remove(task, opts));
704
+ }
705
+
706
+ /** @internal */
707
+ async __remove(task, opts) {
637
708
  this.__debug('[remove]', task?.uuid);
638
709
  if (!task || typeof task !== 'object' || typeof task.uuid !== 'string') {
639
710
  return false;
@@ -680,6 +751,8 @@ class RedisQueue {
680
751
  || currentTask.isSending !== true
681
752
  || (typeof currentTask.sendingAt === 'number' ? currentTask.sendingAt : 0) !== opts.leaseSendingAt
682
753
  || currentTask.isCancelled === true
754
+ || currentTask.isSent === true
755
+ || currentTask.isSettled === true
683
756
  || currentTask.isFailed === true) {
684
757
  await this.client.unwatch?.();
685
758
  return false;
@@ -723,6 +796,11 @@ class RedisQueue {
723
796
  * @returns {Promise<boolean>} returns `true` if updated or `false` if not found or no changes was made
724
797
  */
725
798
  async update(task, updateObj) {
799
+ return await this.__serialize(() => this.__update(task, updateObj));
800
+ }
801
+
802
+ /** @internal */
803
+ async __update(task, updateObj) {
726
804
  this.__debug('[update]', task?.uuid);
727
805
  if (!task || typeof task !== 'object' || typeof task.uuid !== 'string' || !updateObj || typeof updateObj !== 'object') {
728
806
  return false;
@@ -733,38 +811,41 @@ class RedisQueue {
733
811
  const isClaim = isSendClaimUpdate(updateObj);
734
812
  const isAppend = isAppendMailOptionUpdate(updateObj);
735
813
  const isLeaseRelease = isSendLeaseGuardedUpdate(updateObj);
814
+ if ((hasOwnProp(updateObj, 'recipientResults') || hasOwnProp(updateObj, 'isSettled')) && !isClaim && !isLeaseRelease) return false;
736
815
  const now = isClaim && typeof updateObj.sendingAt === 'number' ? updateObj.sendingAt : Date.now();
737
816
  const sendingTimeout = this.mailTimeInstance?.sendingTimeout || 300000;
738
817
 
739
818
  try {
740
819
  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);
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;
754
847
  }
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;
848
+ return false;
768
849
  }
769
850
 
770
851
  if (isAppend) {
@@ -778,7 +859,7 @@ class RedisQueue {
778
859
  return false;
779
860
  }
780
861
  const currentTask = JSON.parse(taskJSON);
781
- 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)) {
782
863
  return false;
783
864
  }
784
865
  currentTask.mailOptions = [...(currentTask.mailOptions || []), updateObj.appendMailOption];
@@ -793,7 +874,7 @@ class RedisQueue {
793
874
  return false;
794
875
  }
795
876
  const currentTask = JSON.parse(taskJSON);
796
- 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)) {
797
878
  await this.client.unwatch?.();
798
879
  return false;
799
880
  }
@@ -833,7 +914,7 @@ class RedisQueue {
833
914
  const updatedTask = { ...currentTask, ...stripInternalUpdateMeta(updateObj) };
834
915
  const multi = this.client.multi();
835
916
  multi.set(letterKey, JSON.stringify(updatedTask));
836
- if (updatedTask.isSent === true || updatedTask.isFailed === true || updatedTask.isCancelled === true) {
917
+ if (updatedTask.isSent === true || updatedTask.isFailed === true || updatedTask.isCancelled === true || updatedTask.isSettled === true) {
837
918
  multi.del(sendatKey);
838
919
  } else if (updatedTask.sendAt) {
839
920
  multi.set(sendatKey, `${+updatedTask.sendAt}`);
@@ -852,7 +933,7 @@ class RedisQueue {
852
933
  const updatedTask = { ...currentTask, ...stripInternalUpdateMeta(updateObj) };
853
934
  await this.client.set(letterKey, JSON.stringify(updatedTask));
854
935
 
855
- if (updatedTask.isSent === true || updatedTask.isFailed === true || updatedTask.isCancelled === true) {
936
+ if (updatedTask.isSent === true || updatedTask.isFailed === true || updatedTask.isCancelled === true || updatedTask.isSettled === true) {
856
937
  await this.client.del(sendatKey);
857
938
  } else if (updatedTask.sendAt) {
858
939
  await this.client.set(sendatKey, `${+updatedTask.sendAt}`);