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/commonjs/src/cache.js +3 -2
- package/commonjs/src/dedup.js +264 -0
- package/commonjs/src/defaults.js +42 -5
- package/commonjs/src/enqueue.js +192 -114
- package/commonjs/src/qrlCache.js +2 -1
- package/commonjs/src/scheduler/jobExecutor.js +3 -0
- package/commonjs/src/scheduler/queueManager.js +1 -1
- package/npm-shrinkwrap.json +3 -2
- package/package.json +4 -3
- package/src/cache.js +3 -2
- package/src/check.js +205 -0
- package/src/cli.js +81 -3
- package/src/dedup.js +256 -0
- package/src/defaults.js +40 -4
- package/src/enqueue.js +185 -114
- package/src/qrlCache.js +1 -1
- package/src/scheduler/jobExecutor.js +5 -0
- package/src/scheduler/queueManager.js +1 -1
- package/src/worker.js +6 -0
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: '
|
|
58
|
-
{ name: '
|
|
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
|
+
}
|