qdone 2.0.34-alpha → 2.0.36-alpha

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/src/check.js ADDED
@@ -0,0 +1,205 @@
1
+ import chalk from 'chalk'
2
+ import Debug from 'debug'
3
+ import {
4
+ GetQueueAttributesCommand,
5
+ SetQueueAttributesCommand,
6
+ QueueDoesNotExist
7
+ } from '@aws-sdk/client-sqs'
8
+
9
+ import {
10
+ qrlCacheGet,
11
+ normalizeQueueName,
12
+ normalizeFailQueueName,
13
+ normalizeDLQName,
14
+ getQnameUrlPairs
15
+ } from './qrlCache.js'
16
+ import { getSQSClient } from './sqs.js'
17
+ import {
18
+ getDLQParams,
19
+ getFailParams,
20
+ getQueueParams,
21
+ getOrCreateFailQueue,
22
+ getOrCreateDLQ
23
+ } from './enqueue.js'
24
+ import { getOptionsWithDefaults } from './defaults.js'
25
+
26
+ const debug = Debug('qdone:check')
27
+
28
+ /**
29
+ * Loops through attributes, checking each and returning true if they match
30
+ */
31
+ export function attributesMatch (current, desired, opt, indent = '') {
32
+ let match = true
33
+ for (const attribute in desired) {
34
+ if (current[attribute] !== desired[attribute]) {
35
+ if (opt.verbose) console.error(chalk.yellow(indent + 'Attribute mismatch: ') + attribute + chalk.yellow(' should be ') + desired[attribute] + chalk.yellow(' but is ') + current[attribute])
36
+ match = false
37
+ }
38
+ }
39
+ return match
40
+ }
41
+
42
+ /**
43
+ * Checks a DLQ, creating if the create option is set and modifying it if the
44
+ * overwrite option is set.
45
+ */
46
+ export async function checkDLQ (queue, qrl, opt, indent = '') {
47
+ debug({ checkDLQ: { queue, qrl } })
48
+ const dqname = normalizeDLQName(queue, opt)
49
+ if (opt.verbose) console.error(chalk.blue(indent + 'checking ') + dqname)
50
+
51
+ // Check DLQ
52
+ let dqrl
53
+ try {
54
+ dqrl = await qrlCacheGet(dqname)
55
+ } catch (e) {
56
+ if (!(e instanceof QueueDoesNotExist)) throw e
57
+ if (opt.verbose) console.error(chalk.red(indent + ' does not exist'))
58
+ if (opt.create) {
59
+ if (opt.verbose) console.error(chalk.green(indent + ' creating'))
60
+ dqrl = await getOrCreateDLQ(queue, opt)
61
+ } else {
62
+ return
63
+ }
64
+ }
65
+
66
+ // Check attributes
67
+ const { params: { Attributes: desired } } = getDLQParams(queue, opt)
68
+ const { Attributes: current } = await getQueueAttributes(dqrl)
69
+ if (attributesMatch(current, desired, opt, indent + ' ')) {
70
+ if (opt.verbose) console.error(chalk.green(indent + ' all good'))
71
+ } else {
72
+ if (opt.overwrite) {
73
+ if (opt.verbose) console.error(chalk.green(indent + ' modifying'))
74
+ return setQueueAttributes(dqrl, desired)
75
+ }
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Checks a fail queue, creating if the create option is set and modifying it if the
81
+ * overwrite option is set.
82
+ */
83
+ export async function checkFailQueue (queue, qrl, opt, indent = '') {
84
+ // Check dead first
85
+ await checkDLQ(queue, qrl, opt, indent)
86
+
87
+ // Check fail queue
88
+ const fqname = normalizeFailQueueName(queue, opt)
89
+ let fqrl
90
+ try {
91
+ fqrl = await qrlCacheGet(fqname)
92
+ } catch (e) {
93
+ if (!(e instanceof QueueDoesNotExist)) throw e
94
+ if (opt.verbose) console.error(chalk.red(indent + ' does not exist'))
95
+ if (opt.create) {
96
+ if (opt.verbose) console.error(chalk.green(indent + ' creating'))
97
+ fqrl = await getOrCreateFailQueue(queue, opt)
98
+ } else {
99
+ return
100
+ }
101
+ }
102
+
103
+ try {
104
+ // Get fail queue params, creating fail queue if it doesn't exist and create flag is set
105
+ if (opt.verbose) console.error(chalk.blue(indent + 'checking ') + fqname)
106
+ const { params: { Attributes: desired } } = await getFailParams(queue, opt)
107
+ const { Attributes: current } = await getQueueAttributes(fqrl)
108
+ if (attributesMatch(current, desired, opt, indent + ' ')) {
109
+ if (opt.verbose) console.error(chalk.green(indent + ' all good'))
110
+ } else {
111
+ if (opt.overwrite) {
112
+ if (opt.verbose) console.error(chalk.green(indent + ' modifying'))
113
+ return setQueueAttributes(fqrl, desired)
114
+ }
115
+ }
116
+ } catch (e) {
117
+ if (!(e instanceof QueueDoesNotExist)) throw e
118
+ if (opt.verbose) console.error(chalk.red(indent + ' missing dlq'))
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Checks a queue, creating or modifying it if the create option is set
124
+ * and it needs it.
125
+ */
126
+ export async function checkQueue (queue, qrl, opt, indent = '') {
127
+ const qname = normalizeQueueName(queue, opt)
128
+ if (opt.verbose) console.error(chalk.blue(indent + 'checking ') + qname)
129
+ await checkFailQueue(queue, qrl, opt, indent + ' ')
130
+ try {
131
+ const { params: { Attributes: desired } } = await getQueueParams(queue, opt)
132
+ const { Attributes: current, $metadata } = await getQueueAttributes(qrl)
133
+ debug({ current, $metadata })
134
+ if (attributesMatch(current, desired, opt, indent + ' ')) {
135
+ if (opt.verbose) console.error(chalk.green(indent + ' all good'))
136
+ } else {
137
+ if (opt.overwrite) {
138
+ if (opt.verbose) console.error(chalk.green(indent + ' modifying'))
139
+ return setQueueAttributes(qrl, desired)
140
+ }
141
+ }
142
+ } catch (e) {
143
+ if (!(e instanceof QueueDoesNotExist)) throw e
144
+ if (opt.verbose) console.error(chalk.red(indent + ' missing fail queue'))
145
+ }
146
+ }
147
+
148
+ export async function getQueueAttributes (qrl) {
149
+ debug('getQueueAttributes(', qrl, ')')
150
+ const client = getSQSClient()
151
+ const params = { AttributeNames: ['All'], QueueUrl: qrl }
152
+ const cmd = new GetQueueAttributesCommand(params)
153
+ // debug({ cmd })
154
+ const data = await client.send(cmd)
155
+ debug('GetQueueAttributes returned', data)
156
+ return data
157
+ }
158
+
159
+ export async function setQueueAttributes (qrl, attributes) {
160
+ debug('setQueueAttributes(', qrl, attributes, ')')
161
+ const client = getSQSClient()
162
+ const params = { Attributes: attributes, QueueUrl: qrl }
163
+ const cmd = new SetQueueAttributesCommand(params)
164
+ debug({ cmd })
165
+ const data = await client.send(cmd)
166
+ debug('SetQueueAttributes returned', data)
167
+ return data
168
+ }
169
+
170
+ //
171
+ // Enqueue a single command
172
+ // Returns a promise for the SQS API response.
173
+ //
174
+ export async function check (queues, options) {
175
+ debug('check(', { queues }, ')')
176
+ const opt = getOptionsWithDefaults(options)
177
+
178
+ // Start processing
179
+ if (opt.verbose) console.error(chalk.blue('Resolving queues: ') + queues.join(' '))
180
+ const qnames = queues.map(queue => normalizeQueueName(queue, opt))
181
+ const pairs = await getQnameUrlPairs(qnames, opt)
182
+
183
+ // Figure out which queues we want to listen on, choosing between active and
184
+ // all, filtering out failed queues if the user wants that
185
+ const selectedPairs = pairs
186
+ .filter(({ qname }) => qname)
187
+ .filter(({ qname }) => {
188
+ const suf = opt.failSuffix + (opt.fifo ? '.fifo' : '')
189
+ const deadSuf = opt.dlqSuffix + (opt.fifo ? '.fifo' : '')
190
+ const isFailQueue = qname.slice(-suf.length) === suf
191
+ const isDeadQueue = qname.slice(-deadSuf.length) === deadSuf
192
+ const isPlain = !isFailQueue && !isDeadQueue
193
+ const shouldInclude = isPlain || (isFailQueue && opt.includeFailed) || (isDeadQueue && opt.includeDead)
194
+ return shouldInclude
195
+ })
196
+
197
+ for (const { qname, qrl } of selectedPairs) {
198
+ debug({ qname, qrl })
199
+ await checkQueue(qname, qrl, opt)
200
+ }
201
+
202
+ debug({ pairs })
203
+ }
204
+
205
+ debug('loaded')
package/src/cli.js CHANGED
@@ -42,6 +42,9 @@ const globalOptionDefinitions = [
42
42
  { name: 'cache-prefix', type: String, description: `Prefix for all keys in cache. [default: ${defaults.cachePrefix}]` },
43
43
  { name: 'cache-ttl-seconds', type: Number, description: `Number of seconds to cache GetQueueAttributes calls. [default: ${defaults.cacheTtlSeconds}]` },
44
44
  { name: 'help', type: Boolean, description: 'Print full help message.' },
45
+ { name: 'external-dedup', type: Boolean, description: 'Moves deduplication from SQS to qdone external cache, allowing a longer deduplication window and alternate semantics.' },
46
+ { name: 'dedup-period', type: Number, description: 'Number of seconds (counting from the first enqueue) to prevent a duplicate message from being sent. Resets after a message with that deduplication id has been successfully processed. Minumum 360 seconds.' },
47
+ { name: 'dedup-stats', type: Boolean, description: 'Keeps statistics on dedup keys. Disabled by default. Increases load on redis.' },
45
48
  { name: 'sentry-dsn', type: String, description: 'Optional Sentry DSN to track unhandled errors.' }
46
49
  ]
47
50
 
@@ -54,8 +57,23 @@ const enqueueOptionDefinitions = [
54
57
  { name: 'delay', type: Number, description: 'Delays delivery of the enqueued message by the given number of seconds (up to 900 seconds, or 15 minutes). Defaults to immediate delivery (no delay).' },
55
58
  { name: 'fail-delay', type: Number, description: 'Delays delivery of all messages on this queue by the given number of seconds (up to 900 seconds, or 15 minutes). Only takes effect if this queue is created during this enqueue operation. Defaults to immediate delivery (no delay).' },
56
59
  { name: 'dlq', type: Boolean, description: 'Send messages from the failed queue to a DLQ.' },
57
- { name: 'dql-suffix', type: String, description: `Suffix to append to each queue to generate DLQ name [default: ${defaults.dlqSuffix}]` },
58
- { name: 'dql-after', type: String, description: `Drives message to the DLQ after this many failures in the failed queue. [default: ${defaults.dlqAfter}]` },
60
+ { name: 'dlq-suffix', type: String, description: `Suffix to append to each queue to generate DLQ name [default: ${defaults.dlqSuffix}]` },
61
+ { name: 'dlq-after', type: String, description: `Drives message to the DLQ after this many failures in the failed queue. [default: ${defaults.dlqAfter}]` },
62
+ { name: 'tag', type: String, multiple: true, description: 'Adds an AWS tag to queue creation. Use the format Key=Value. Can specify multiple times.' }
63
+ ]
64
+
65
+ const checkOptionDefinitions = [
66
+ { name: 'create', type: Boolean, description: 'Create queues that do not exist' },
67
+ { name: 'overwrite', type: Boolean, description: 'Overwrite queue attributes that do not match expected' },
68
+ { name: 'fifo', alias: 'f', type: Boolean, description: 'Create new queues as FIFOs' },
69
+ { name: 'include-failed', type: Boolean, description: 'When using \'*\' do not ignore fail queues.' },
70
+ { name: 'include-dead', type: Boolean, description: 'When using \'*\' do not ignore dead queues.' },
71
+ { name: 'message-retention-period', type: Number, description: `Number of seconds to retain jobs (up to 14 days). [default: ${defaults.messageRetentionPeriod}]` },
72
+ { name: 'delay', type: Number, description: 'Delays delivery of the enqueued message by the given number of seconds (up to 900 seconds, or 15 minutes). Defaults to immediate delivery (no delay).' },
73
+ { name: 'fail-delay', type: Number, description: 'Delays delivery of all messages on this queue by the given number of seconds (up to 900 seconds, or 15 minutes). Only takes effect if this queue is created during this enqueue operation. Defaults to immediate delivery (no delay).' },
74
+ { name: 'dlq', type: Boolean, description: 'Send messages from the failed queue to a DLQ.' },
75
+ { name: 'dlq-suffix', type: String, description: `Suffix to append to each queue to generate DLQ name [default: ${defaults.dlqSuffix}]` },
76
+ { name: 'dlq-after', type: String, description: `Drives message to the DLQ after this many failures in the failed queue. [default: ${defaults.dlqAfter}]` },
59
77
  { name: 'tag', type: String, multiple: true, description: 'Adds an AWS tag to queue creation. Use the format Key=Value. Can specify multiple times.' }
60
78
  ]
61
79
 
@@ -120,6 +138,64 @@ export async function enqueue (argv, testHook) {
120
138
  return result
121
139
  }
122
140
 
141
+ export async function check (argv, testHook) {
142
+ const optionDefinitions = [].concat(checkOptionDefinitions, globalOptionDefinitions)
143
+ const usageSections = [
144
+ { content: 'usage: qdone check [options] <queue>', raw: true },
145
+ { content: 'Options', raw: true },
146
+ { optionList: optionDefinitions },
147
+ { content: 'SQS API Call Complexity', raw: true, long: true },
148
+ {
149
+ content: [
150
+ { count: '2 [ + 3 ]', summary: 'one call to resolve the queue name\none call to check the command\none extra calls if the queue does not match and --modify option is set' }
151
+ ],
152
+ long: true
153
+ },
154
+ awsUsageHeader, awsUsageBody
155
+ ]
156
+ debug('check argv', argv)
157
+
158
+ // Parse command and options
159
+ let queues, options
160
+ try {
161
+ options = commandLineArgs(optionDefinitions, { argv, partial: true })
162
+ setupVerbose(options)
163
+ debug('check options', options)
164
+ if (options.help) return Promise.resolve(console.log(getUsage(usageSections)))
165
+ if (!options._unknown || options._unknown.length === 0) throw new UsageError('check requres one or more <queue> arguments')
166
+ queues = options._unknown
167
+ debug('queues', queues)
168
+ } catch (err) {
169
+ console.log(getUsage(usageSections.filter(s => !s.long)))
170
+ throw err
171
+ }
172
+
173
+ // Process tags
174
+ if (options.tag && options.tag.length) {
175
+ options.tags = {}
176
+ for (const input of options.tag) {
177
+ debug({ input })
178
+ if (input.indexOf('=') === -1) throw new UsageError('Tags must be separated with the "=" character.')
179
+ const [key, ...rest] = input.split('=')
180
+ const value = rest.join('=')
181
+ debug({ input, key, rest, value, tags: options.tags })
182
+ options.tags[key] = value
183
+ }
184
+ }
185
+
186
+ // Load module after AWS global load
187
+ setupAWS(options)
188
+ const { check: checkOriginal } = await import('./check.js')
189
+ const check = testHook || checkOriginal
190
+
191
+ // Normal (non batch) enqueue
192
+ const opt = getOptionsWithDefaults(options)
193
+ const result = (
194
+ await withSentry(async () => check(queues, opt), opt)
195
+ )
196
+ return result
197
+ }
198
+
123
199
  const monitorOptionDefinitions = [
124
200
  { name: 'save', alias: 's', type: Boolean, description: 'Saves data to CloudWatch' }
125
201
  ]
@@ -449,7 +525,7 @@ export async function idleQueues (argv, testHook) {
449
525
  }
450
526
 
451
527
  export async function root (originalArgv, testHook) {
452
- const validCommands = [null, 'enqueue', 'enqueue-batch', 'worker', 'idle-queues', 'monitor']
528
+ const validCommands = [null, 'enqueue', 'enqueue-batch', 'worker', 'idle-queues', 'monitor', 'check']
453
529
  const usageSections = [
454
530
  { content: 'qdone - Command line job queue for SQS', raw: true, long: true },
455
531
  { content: 'usage: qdone [options] <command>', raw: true },
@@ -502,6 +578,8 @@ export async function root (originalArgv, testHook) {
502
578
  return idleQueues(argv, testHook)
503
579
  } else if (command === 'monitor') {
504
580
  return monitor(argv, testHook)
581
+ } else if (command === 'check') {
582
+ return check(argv, testHook)
505
583
  }
506
584
  }
507
585
 
package/src/dedup.js ADDED
@@ -0,0 +1,256 @@
1
+ import { createHash } from 'crypto'
2
+ import { v1 as uuidV1 } from 'uuid'
3
+ import { getCacheClient } from './cache.js'
4
+ import Debug from 'debug'
5
+ const debug = Debug('qdone:dedup')
6
+
7
+ /**
8
+ * Returns a MessageDeduplicationId key appropriate for using with Amazon SQS
9
+ * for the given message. The passed dedupContent will be returned untouched
10
+ * if it meets all the requirements for SQS's MessageDeduplicationId,
11
+ * otherwise disallowed characters will be replaced by `_` and content longer
12
+ * than 128 characters will be truncated and a hash of the content appended.
13
+ * @param {String} dedupContent - Content used to construct the deduplication id.
14
+ * @param {Object} opt - Opt object from getOptionsWithDefaults()
15
+ * @returns {String} the cache key
16
+ */
17
+ export function getDeduplicationId (dedupContent, opt) {
18
+ debug({ getDeduplicationId: { dedupContent } })
19
+ // Don't transmit long keys to redis
20
+ dedupContent = dedupContent.trim().replace(/[^a-zA-Z0-9!"#$%&'()*+,-./:;<=>?@[\\\]^_`{|}~]/g, '_')
21
+ const max = 128
22
+ const sep = '...sha1:'
23
+ if (dedupContent.length > max) {
24
+ dedupContent = dedupContent.slice(0, max - sep.length - 40) + '...sha1:' + createHash('sha1').update(dedupContent).digest('hex')
25
+ }
26
+ return dedupContent
27
+ }
28
+
29
+ /**
30
+ * Returns the cache key given a deduplication id.
31
+ * @param {String} dedupId - a deduplication id returned from getDeduplicationId
32
+ * @param opt - Opt object from getOptionsWithDefaults()
33
+ * @returns the cache key
34
+ */
35
+ export function getCacheKey (dedupId, opt) {
36
+ const cacheKey = opt.cachePrefix + 'dedup:' + dedupId
37
+ debug({ getCacheKey: { cacheKey } })
38
+ return cacheKey
39
+ }
40
+
41
+ /**
42
+ * Modifies a message (parameters to SendMessageCommand) to add the parameters
43
+ * for whatever deduplication options the caller has set.
44
+ * @param {String} message - parameters to SendMessageCommand
45
+ * @param {Object} opt - Opt object from getOptionsWithDefaults()
46
+ * @param {Object} [messageOptions] - optional per message options. We only care about the key deduplicationId.
47
+ * @returns {Object} the modified parameters/message object
48
+ */
49
+ export function addDedupParamsToMessage (message, opt, messageOptions) {
50
+ // Either of these means we need to calculate an id
51
+ if (opt.fifo || opt.externalDedup) {
52
+ const uuidFunction = opt.uuidFunction || uuidV1
53
+
54
+ if (opt.deduplicationId) message.MessageDeduplicationId = opt.deduplicationId
55
+ if (opt.dedupIdPerMessage) message.MessageDeduplicationId = uuidFunction()
56
+ if (messageOptions?.deduplicationId) message.MessageDeduplicationId = messageOptions.deduplicationId
57
+
58
+ // Fallback to using the message body
59
+ if (!message.MessageDeduplicationId) {
60
+ message.MessageDeduplicationId = getDeduplicationId(message.MessageBody, opt)
61
+ }
62
+
63
+ // Track our own dedup id so we can look it up upon ReceiveMessage
64
+ if (opt.externalDedup) {
65
+ message.MessageAttributes = {
66
+ QdoneDeduplicationId: {
67
+ StringValue: message.MessageDeduplicationId,
68
+ DataType: 'String'
69
+ }
70
+ }
71
+ // If we are using our own dedup, then we must disable the SQS dedup by
72
+ // providing a different unique ID. Otherwise SQS will interact with us.
73
+ if (opt.fifo) message.MessageDeduplicationId = uuidFunction()
74
+ }
75
+
76
+ // Non fifo can't have this parameter
77
+ if (!opt.fifo) delete message.MessageDeduplicationId
78
+ }
79
+ return message
80
+ }
81
+
82
+ /**
83
+ * Updates statistics in redis, of which there are two:
84
+ * 1. duplicateSet - a set who's members are cache keys and scores are the number of duplicate
85
+ * runs prevented by dedup.
86
+ * 2. expirationSet - a set who's members are cache keys and scores are when the cache key expires
87
+ * @param {String} cacheKey
88
+ * @param {Number} duplicates - the number of duplicates, must be at least 1 to gather stats
89
+ * @param {Number} expireAt - timestamp for when this key's dedupPeriod expires
90
+ * @param {Object} opt - Opt object from getOptionsWithDefaults()
91
+ * @param {Object} pipeline - (Optional) redis pipeline you will exec() yourself
92
+ */
93
+ export async function updateStats (cacheKey, duplicates, expireAt, opt, pipeline) {
94
+ if (duplicates >= 1) {
95
+ const duplicateSet = opt.cachePrefix + 'dedup-stats:duplicateSet'
96
+ const expirationSet = opt.cachePrefix + 'dedup-stats:expirationSet'
97
+ const hadPipeline = !!pipeline
98
+ if (!hadPipeline) pipeline = getCacheClient(opt).multi()
99
+ pipeline.zadd(duplicateSet, 'GT', duplicates, cacheKey)
100
+ pipeline.zadd(expirationSet, 'GT', expireAt, cacheKey)
101
+ if (!hadPipeline) await pipeline.exec()
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Removes expired items from stats.
107
+ */
108
+ export async function statMaintenance (opt) {
109
+ const duplicateSet = opt.cachePrefix + 'dedup-stats:duplicateSet'
110
+ const expirationSet = opt.cachePrefix + 'dedup-stats:expirationSet'
111
+ const client = getCacheClient(opt)
112
+ const now = new Date().getTime()
113
+
114
+ // Grab a batch of expired keys
115
+ debug({ statMaintenance: { aboutToGo: true, expirationSet }})
116
+ const expiredStats = await client.zrange(expirationSet, '-inf', now, 'BYSCORE')
117
+ debug({ statMaintenance: { expiredStats }})
118
+
119
+ // And remove them from indexes, main storage
120
+ if (expiredStats.length) {
121
+ const result = await client.multi()
122
+ .zrem(expirationSet, expiredStats)
123
+ .zrem(duplicateSet, expiredStats)
124
+ .exec()
125
+ debug({ statMaintenance: { result }})
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Determines whether we should enqueue this message or whether it is a duplicate.
131
+ * Returns true if enqueuing the message would not result in a duplicate.
132
+ * @param {Object} message - Parameters to SendMessageCommand
133
+ * @param {Object} opt - Opt object from getOptionsWithDefaults()
134
+ * @returns {Boolean} true if the message can be enqueued without duplicate, else false
135
+ */
136
+ export async function dedupShouldEnqueue (message, opt) {
137
+ const client = getCacheClient(opt)
138
+ const dedupId = message?.MessageAttributes?.QdoneDeduplicationId?.StringValue
139
+ const cacheKey = getCacheKey(dedupId, opt)
140
+ const expireAt = new Date().getTime() + opt.dedupPeriod
141
+ const copies = await client.incr(cacheKey)
142
+ debug({ action: 'shouldEnqueue', cacheKey, copies })
143
+ if (copies === 1) {
144
+ await client.expireat(cacheKey, expireAt)
145
+ return true
146
+ }
147
+ if (opt.dedupStats) {
148
+ const duplicates = copies - 1
149
+ await updateStats(cacheKey, duplicates, expireAt, opt)
150
+ }
151
+ return false
152
+ }
153
+
154
+ /**
155
+ * Determines which messages we should enqueue, returning only those that
156
+ * would not be duplicates.
157
+ * @param {Array[Object]} messages - Entries array for the SendMessageBatchCommand
158
+ * @param {Object} opt - Opt object from getOptionsWithDefaults()
159
+ * @returns {Array[Object]} an array of messages that can be safely enqueued. Could be empty.
160
+ */
161
+ export async function dedupShouldEnqueueMulti (messages, opt) {
162
+ debug({ dedupShouldEnqueueMulti: { messages, opt }})
163
+ const expireAt = new Date().getTime() + opt.dedupPeriod
164
+ // Increment all
165
+ const incrPipeline = getCacheClient(opt).pipeline()
166
+ for (const message of messages) {
167
+ const dedupId = message?.MessageAttributes?.QdoneDeduplicationId?.StringValue
168
+ const cacheKey = getCacheKey(dedupId, opt)
169
+ incrPipeline.incr(cacheKey)
170
+ }
171
+ const responses = await incrPipeline.exec()
172
+ debug({ dedupShouldEnqueueMulti: { messages, responses } })
173
+
174
+ // Figure out dedup period
175
+ const minDedupPeriod = 6 * 60
176
+ const dedupPeriod = Math.min(opt.dedupPeriod, minDedupPeriod)
177
+
178
+ // Interpret responses and expire keys for races we won
179
+ const expirePipeline = getCacheClient(opt).pipeline()
180
+ const statsPipeline = opt.dedupStats ? getCacheClient(opt).pipeline() : undefined
181
+ const messagesToEnqueue = []
182
+ for (let i = 0; i < messages.length; i++) {
183
+ const message = messages[i]
184
+ const [, copies] = responses[i]
185
+ const dedupId = message?.MessageAttributes?.QdoneDeduplicationId?.StringValue
186
+ const cacheKey = getCacheKey(dedupId, opt)
187
+ if (copies === 1) {
188
+ messagesToEnqueue.push(message)
189
+ expirePipeline.expireat(cacheKey, expireAt)
190
+ } else if (opt.dedupStats) {
191
+ const duplicates = copies - 1
192
+ updateStats(cacheKey, duplicates, expireAt, opt, statsPipeline)
193
+ }
194
+ }
195
+ await expirePipeline.exec()
196
+ if (opt.dedupStats) await statsPipeline.exec()
197
+ return messagesToEnqueue
198
+ }
199
+
200
+ /**
201
+ * Marks a message as processed so that subsequent calls to dedupShouldEnqueue
202
+ * and dedupShouldEnqueueMulti will allow a message to be enqueued again
203
+ * without waiting for dedupPeriod to expire.
204
+ * @param {Object} message - Return value from RecieveMessageCommand
205
+ * @param {Object} opt - Opt object from getOptionsWithDefaults()
206
+ * @returns {Number} 1 if a cache key was deleted, otherwise 0
207
+ */
208
+ export async function dedupSuccessfullyProcessed (message, opt) {
209
+ const client = getCacheClient(opt)
210
+ const dedupId = message?.MessageAttributes?.QdoneDeduplicationId?.StringValue
211
+ if (dedupId) {
212
+ const cacheKey = getCacheKey(dedupId, opt)
213
+ const count = await client.del(cacheKey)
214
+ // Probabalistic stat maintenance
215
+ if (opt.dedupStats) {
216
+ const chance = 1 / 100.0
217
+ if (Math.random() < chance) await statMaintenance(opt)
218
+ }
219
+ return count
220
+ }
221
+ return 0
222
+ }
223
+
224
+ /**
225
+ * Marks an array of messages as processed so that subsequent calls to
226
+ * dedupShouldEnqueue and dedupShouldEnqueueMulti will allow a message to be
227
+ * enqueued again without waiting for dedupPeriod to expire.
228
+ * @param {Array[Object]} messages - Return values from RecieveMessageCommand
229
+ * @param {Object} opt - Opt object from getOptionsWithDefaults()
230
+ * @returns {Number} number of deleted keys
231
+ */
232
+ export async function dedupSuccessfullyProcessedMulti (messages, opt) {
233
+ debug({ messages, dedupSuccessfullyProcessedMulti: { messages, opt }})
234
+ const cacheKeys = []
235
+ for (const message of messages) {
236
+ const dedupId = message?.MessageAttributes?.QdoneDeduplicationId?.StringValue
237
+ if (dedupId) {
238
+ const cacheKey = getCacheKey(dedupId, opt)
239
+ cacheKeys.push(cacheKey)
240
+ }
241
+ }
242
+ debug({ dedupSuccessfullyProcessedMulti: { cacheKeys }})
243
+ if (cacheKeys.length) {
244
+ const numDeleted = await getCacheClient(opt).del(cacheKeys)
245
+ // const numDeleted = results.map(([, val]) => val).reduce((a, b) => a + b, 0)
246
+ debug({ dedupSuccessfullyProcessedMulti: { cacheKeys, numDeleted } })
247
+
248
+ // Probabalistic stat maintenance
249
+ if (opt.dedupStats) {
250
+ const chance = numDeleted / 100.0
251
+ if (Math.random() < chance) await statMaintenance(opt)
252
+ }
253
+ return numDeleted
254
+ }
255
+ return 0
256
+ }