qdone 2.0.8-alpha → 2.0.10-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/index.js +1 -2
- package/commonjs/src/consumer.js +80 -386
- package/commonjs/src/scheduler/jobExecutor.js +321 -0
- package/commonjs/src/scheduler/queueManager.js +145 -0
- package/commonjs/src/scheduler/systemMonitor.js +65 -0
- package/index.js +1 -1
- package/package.json +1 -1
- package/src/consumer.js +55 -292
- package/src/scheduler/jobExecutor.js +242 -0
- package/src/scheduler/queueManager.js +88 -0
- package/src/scheduler/systemMonitor.js +58 -0
package/src/consumer.js
CHANGED
|
@@ -2,243 +2,45 @@
|
|
|
2
2
|
* Consumer implementation.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import {
|
|
6
|
-
ChangeMessageVisibilityCommand,
|
|
7
|
-
ReceiveMessageCommand,
|
|
8
|
-
DeleteMessageCommand
|
|
9
|
-
} from '@aws-sdk/client-sqs'
|
|
5
|
+
import { ReceiveMessageCommand } from '@aws-sdk/client-sqs'
|
|
10
6
|
import chalk from 'chalk'
|
|
11
7
|
import Debug from 'debug'
|
|
12
8
|
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
9
|
+
import { SystemMonitor } from './scheduler/systemMonitor.js'
|
|
10
|
+
import { QueueManager } from './scheduler/queueManager.js'
|
|
11
|
+
import { JobExecutor } from './scheduler/jobExecutor.js'
|
|
15
12
|
import { getOptionsWithDefaults } from './defaults.js'
|
|
16
13
|
import { getSQSClient } from './sqs.js'
|
|
17
14
|
|
|
18
|
-
|
|
19
|
-
// Throwing an instance of this Error allows the processMessages callback to
|
|
20
|
-
// refuse a message which then gets immediately returned to the queue.
|
|
21
|
-
//
|
|
22
|
-
// This has the side effect of throtting the queue since it stops polling on
|
|
23
|
-
// the queue until the next queue resolution in processMessages.
|
|
24
|
-
//
|
|
25
|
-
// This is useful for implementing schedulers on top of qdone, for example, to
|
|
26
|
-
// look at the queue name and decide whether to take on a new message.
|
|
27
|
-
//
|
|
28
|
-
export class DoNotProcess extends Error {}
|
|
29
|
-
|
|
30
|
-
const debug = Debug('qdone:worker')
|
|
15
|
+
const debug = Debug('qdone:consumer')
|
|
31
16
|
|
|
32
17
|
// Global flag for shutdown request
|
|
33
18
|
let shutdownRequested = false
|
|
34
19
|
const shutdownCallbacks = []
|
|
35
20
|
|
|
36
21
|
export function requestShutdown () {
|
|
22
|
+
debug('requestShutdown')
|
|
37
23
|
shutdownRequested = true
|
|
38
24
|
for (const callback of shutdownCallbacks) {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export async function processMessage (message, callback, qname, qrl, opt) {
|
|
44
|
-
debug('processMessage', message, qname, qrl)
|
|
45
|
-
const payload = JSON.parse(message.Body)
|
|
46
|
-
if (opt.verbose) {
|
|
47
|
-
console.error(chalk.blue(' Processing payload:'), payload)
|
|
48
|
-
} else if (!opt.disableLog) {
|
|
49
|
-
console.log(JSON.stringify({
|
|
50
|
-
event: 'MESSAGE_PROCESSING_START',
|
|
51
|
-
timestamp: new Date(),
|
|
52
|
-
messageId: message.MessageId,
|
|
53
|
-
payload
|
|
54
|
-
}))
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
const jobStart = new Date()
|
|
58
|
-
let visibilityTimeout = 30 // this should be the queue timeout
|
|
59
|
-
let timeoutExtender
|
|
60
|
-
|
|
61
|
-
async function extendTimeout () {
|
|
62
|
-
debug('extendTimeout')
|
|
63
|
-
const maxJobRun = 12 * 60 * 60
|
|
64
|
-
const jobRunTime = ((new Date()) - jobStart) / 1000
|
|
65
|
-
// Double every time, up to max
|
|
66
|
-
visibilityTimeout = Math.min(visibilityTimeout * 2, maxJobRun - jobRunTime, opt.killAfter - jobRunTime)
|
|
67
|
-
if (opt.verbose) {
|
|
68
|
-
console.error(
|
|
69
|
-
chalk.blue(' Ran for ') + jobRunTime +
|
|
70
|
-
chalk.blue(' seconds, requesting another ') + visibilityTimeout +
|
|
71
|
-
chalk.blue(' seconds')
|
|
72
|
-
)
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
try {
|
|
76
|
-
const result = await getSQSClient().send(new ChangeMessageVisibilityCommand({
|
|
77
|
-
QueueUrl: qrl,
|
|
78
|
-
ReceiptHandle: message.ReceiptHandle,
|
|
79
|
-
VisibilityTimeout: visibilityTimeout
|
|
80
|
-
}))
|
|
81
|
-
debug('ChangeMessageVisibility returned', result)
|
|
82
|
-
if (
|
|
83
|
-
jobRunTime + visibilityTimeout >= maxJobRun ||
|
|
84
|
-
jobRunTime + visibilityTimeout >= opt.killAfter
|
|
85
|
-
) {
|
|
86
|
-
if (opt.verbose) console.error(chalk.yellow(' warning: this is our last time extension'))
|
|
87
|
-
} else {
|
|
88
|
-
// Extend when we get 50% of the way to timeout
|
|
89
|
-
timeoutExtender = setTimeout(extendTimeout, visibilityTimeout * 1000 * 0.5)
|
|
90
|
-
}
|
|
91
|
-
} catch (err) {
|
|
92
|
-
debug('ChangeMessageVisibility threw', err)
|
|
93
|
-
// Rejection means we're ouuta time, whatever, let the job die
|
|
94
|
-
if (opt.verbose) {
|
|
95
|
-
console.error(chalk.red(' failed to extend job: ') + err)
|
|
96
|
-
} else if (!opt.disableLog) {
|
|
97
|
-
// Production error logging
|
|
98
|
-
console.log(JSON.stringify({
|
|
99
|
-
event: 'MESSAGE_PROCESSING_FAILED',
|
|
100
|
-
reason: 'ran longer than --kill-after',
|
|
101
|
-
timestamp: new Date(),
|
|
102
|
-
messageId: message.MessageId,
|
|
103
|
-
payload,
|
|
104
|
-
errorMessage: err.toString().split('\n').slice(1).join('\n').trim() || undefined,
|
|
105
|
-
err
|
|
106
|
-
}))
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
// Extend when we get 50% of the way to timeout
|
|
112
|
-
timeoutExtender = setTimeout(extendTimeout, visibilityTimeout * 1000 * 0.5)
|
|
113
|
-
debug('timeout', visibilityTimeout * 1000 * 0.5)
|
|
114
|
-
|
|
115
|
-
try {
|
|
116
|
-
// Process message
|
|
117
|
-
const queue = qname.slice(opt.prefix.length)
|
|
118
|
-
const result = await callback(queue, payload)
|
|
119
|
-
debug('processMessage callback finished', { payload, result })
|
|
120
|
-
clearTimeout(timeoutExtender)
|
|
121
|
-
if (opt.verbose) {
|
|
122
|
-
console.error(chalk.green(' SUCCESS'))
|
|
123
|
-
console.error(chalk.blue(' cleaning up (removing message) ...'))
|
|
124
|
-
}
|
|
125
|
-
await getSQSClient().send(new DeleteMessageCommand({
|
|
126
|
-
QueueUrl: qrl,
|
|
127
|
-
ReceiptHandle: message.ReceiptHandle
|
|
128
|
-
}))
|
|
129
|
-
if (opt.verbose) {
|
|
130
|
-
console.error(chalk.blue(' done'))
|
|
131
|
-
console.error()
|
|
132
|
-
} else if (!opt.disableLog) {
|
|
133
|
-
console.log(JSON.stringify({
|
|
134
|
-
event: 'MESSAGE_PROCESSING_COMPLETE',
|
|
135
|
-
timestamp: new Date(),
|
|
136
|
-
messageId: message.MessageId,
|
|
137
|
-
payload
|
|
138
|
-
}))
|
|
139
|
-
}
|
|
140
|
-
return { noJobs: 0, jobsSucceeded: 1, jobsFailed: 0 }
|
|
141
|
-
} catch (err) {
|
|
142
|
-
debug('exec.catch')
|
|
143
|
-
clearTimeout(timeoutExtender)
|
|
144
|
-
|
|
145
|
-
// If the callback does not want to process this message, return to queue
|
|
146
|
-
if (err instanceof DoNotProcess) {
|
|
147
|
-
if (opt.verbose) {
|
|
148
|
-
console.error(chalk.blue(' callback ') + chalk.yellow('REFUSED'))
|
|
149
|
-
console.error(chalk.blue(' cleaning up (removing message) ...'))
|
|
150
|
-
}
|
|
151
|
-
const result = await getSQSClient().send(new ChangeMessageVisibilityCommand({
|
|
152
|
-
QueueUrl: qrl,
|
|
153
|
-
ReceiptHandle: message.ReceiptHandle,
|
|
154
|
-
VisibilityTimeout: 0
|
|
155
|
-
}))
|
|
156
|
-
debug('ChangeMessageVisibility returned', result)
|
|
157
|
-
return { noJobs: 1, jobsSucceeded: 0, jobsFailed: 0 }
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
// Fail path for job execution
|
|
161
|
-
if (opt.verbose) {
|
|
162
|
-
console.error(chalk.red(' FAILED'))
|
|
163
|
-
console.error(chalk.blue(' error : ') + err)
|
|
164
|
-
} else if (!opt.disableLog) {
|
|
165
|
-
// Production error logging
|
|
166
|
-
console.log(JSON.stringify({
|
|
167
|
-
event: 'MESSAGE_PROCESSING_FAILED',
|
|
168
|
-
reason: 'exception thrown',
|
|
169
|
-
timestamp: new Date(),
|
|
170
|
-
messageId: message.MessageId,
|
|
171
|
-
payload,
|
|
172
|
-
errorMessage: err.toString().split('\n').slice(1).join('\n').trim() || undefined,
|
|
173
|
-
err
|
|
174
|
-
}))
|
|
175
|
-
}
|
|
176
|
-
return { noJobs: 0, jobsSucceeded: 0, jobsFailed: 1 }
|
|
25
|
+
debug('callback', callback)
|
|
26
|
+
callback()
|
|
27
|
+
// try { callback() } catch (e) { }
|
|
177
28
|
}
|
|
29
|
+
debug('requestShutdown done')
|
|
178
30
|
}
|
|
179
31
|
|
|
180
|
-
|
|
181
|
-
// Pull work off of a single queue
|
|
182
|
-
//
|
|
183
|
-
export async function pollSingleQueue (qname, qrl, callback, opt) {
|
|
184
|
-
debug('pollSingleQueue', { qname, qrl, callback, opt })
|
|
32
|
+
export async function getMessages (qrl, opt, maxMessages) {
|
|
185
33
|
const params = {
|
|
186
34
|
AttributeNames: ['All'],
|
|
187
|
-
MaxNumberOfMessages:
|
|
35
|
+
MaxNumberOfMessages: maxMessages,
|
|
188
36
|
MessageAttributeNames: ['All'],
|
|
189
37
|
QueueUrl: qrl,
|
|
190
38
|
VisibilityTimeout: 30,
|
|
191
39
|
WaitTimeSeconds: opt.waitTime
|
|
192
40
|
}
|
|
193
41
|
const response = await getSQSClient().send(new ReceiveMessageCommand(params))
|
|
194
|
-
debug('ReceiveMessage response', response)
|
|
195
|
-
|
|
196
|
-
if (response.Messages) {
|
|
197
|
-
const message = response.Messages[0]
|
|
198
|
-
if (opt.verbose) console.error(chalk.blue(' Found message ' + message.MessageId))
|
|
199
|
-
return processMessage(message, callback, qname, qrl, opt)
|
|
200
|
-
} else {
|
|
201
|
-
return { noJobs: 1, jobsSucceeded: 0, jobsFailed: 0 }
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
//
|
|
206
|
-
// Resolve a set of queues
|
|
207
|
-
//
|
|
208
|
-
export async function resolveQueues (queues, opt) {
|
|
209
|
-
// Start processing
|
|
210
|
-
if (opt.verbose) console.error(chalk.blue('Resolving queues: ') + queues.join(' '))
|
|
211
|
-
const qnames = queues.map(queue => normalizeQueueName(queue, opt))
|
|
212
|
-
const pairs = await getQnameUrlPairs(qnames, opt)
|
|
213
|
-
|
|
214
|
-
// Figure out which pairs are active
|
|
215
|
-
const activePairs = []
|
|
216
|
-
if (opt.activeOnly) {
|
|
217
|
-
debug({ pairsBeforeCheck: pairs })
|
|
218
|
-
await Promise.all(pairs.map(async pair => {
|
|
219
|
-
const { idle } = await cheapIdleCheck(pair.qname, pair.qrl, opt)
|
|
220
|
-
if (!idle) activePairs.push(pair)
|
|
221
|
-
}))
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
// Finished resolving
|
|
225
|
-
debug('getQnameUrlPairs.then')
|
|
226
|
-
if (opt.verbose) {
|
|
227
|
-
console.error(chalk.blue(' done'))
|
|
228
|
-
console.error()
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
// Figure out which queues we want to listen on, choosing between active and
|
|
232
|
-
// all, filtering out failed queues if the user wants that
|
|
233
|
-
const selectedPairs = (opt.activeOnly ? activePairs : pairs)
|
|
234
|
-
.filter(({ qname }) => {
|
|
235
|
-
const suf = opt.failSuffix + (opt.fifo ? '.fifo' : '')
|
|
236
|
-
const isFailQueue = qname.slice(-suf.length) === suf
|
|
237
|
-
const shouldInclude = opt.includeFailed ? true : !isFailQueue
|
|
238
|
-
return shouldInclude
|
|
239
|
-
})
|
|
240
|
-
|
|
241
|
-
return selectedPairs
|
|
42
|
+
// debug('ReceiveMessage response', response)
|
|
43
|
+
return response.Messages || []
|
|
242
44
|
}
|
|
243
45
|
|
|
244
46
|
//
|
|
@@ -248,8 +50,17 @@ export async function processMessages (queues, callback, options) {
|
|
|
248
50
|
const opt = getOptionsWithDefaults(options)
|
|
249
51
|
debug('processMessages', { queues, callback, options, opt })
|
|
250
52
|
|
|
251
|
-
const
|
|
252
|
-
const
|
|
53
|
+
const systemMonitor = new SystemMonitor(opt)
|
|
54
|
+
const jobExecutor = new JobExecutor(opt)
|
|
55
|
+
const queueManager = new QueueManager(opt, queues)
|
|
56
|
+
const maxActiveJobs = 100
|
|
57
|
+
debug({ systemMonitor, jobExecutor, queueManager })
|
|
58
|
+
|
|
59
|
+
shutdownCallbacks.push(() => {
|
|
60
|
+
systemMonitor.shutdown()
|
|
61
|
+
queueManager.shutdown()
|
|
62
|
+
jobExecutor.shutdown()
|
|
63
|
+
})
|
|
253
64
|
|
|
254
65
|
// This delay function keeps a timeout reference around so it can be
|
|
255
66
|
// cancelled at shutdown
|
|
@@ -258,89 +69,41 @@ export async function processMessages (queues, callback, options) {
|
|
|
258
69
|
delayTimeout = setTimeout(resolve, ms)
|
|
259
70
|
})
|
|
260
71
|
|
|
261
|
-
//
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
shutdownCallbacks.push(shutdownCallback)
|
|
273
|
-
|
|
274
|
-
// Listen to a queue until it is out of messages
|
|
275
|
-
async function listenLoop (qname, qrl) {
|
|
276
|
-
try {
|
|
277
|
-
if (shutdownRequested) return
|
|
278
|
-
if (opt.verbose) {
|
|
279
|
-
console.error(
|
|
280
|
-
chalk.blue('Looking for work on ') +
|
|
281
|
-
qname.slice(opt.prefix.length) +
|
|
282
|
-
chalk.blue(' (' + qrl + ')')
|
|
283
|
-
)
|
|
284
|
-
}
|
|
285
|
-
// Aggregate the results
|
|
286
|
-
const { noJobs, jobsSucceeded, jobsFailed } = await pollSingleQueue(qname, qrl, callback, opt)
|
|
287
|
-
stats.noJobs += noJobs
|
|
288
|
-
stats.jobsFailed += jobsFailed
|
|
289
|
-
stats.jobsSucceeded += jobsSucceeded
|
|
290
|
-
|
|
291
|
-
// No work? return to outer loop
|
|
292
|
-
if (noJobs) return
|
|
293
|
-
|
|
294
|
-
// Otherwise keep going
|
|
295
|
-
return listenLoop(qname, qrl)
|
|
296
|
-
} catch (err) {
|
|
297
|
-
// TODO: Sentry
|
|
298
|
-
console.error(chalk.red(' ERROR in listenLoop'))
|
|
299
|
-
console.error(chalk.blue(' error : ') + err)
|
|
300
|
-
} finally {
|
|
301
|
-
delete activeLoops[qname]
|
|
72
|
+
// Keep track of how many messages could be returned from each queue
|
|
73
|
+
const activeQrls = new Set()
|
|
74
|
+
let maxReturnCount = 0
|
|
75
|
+
const listen = async (qname, qrl, maxMessages) => {
|
|
76
|
+
activeQrls.add(qrl)
|
|
77
|
+
maxReturnCount += maxMessages
|
|
78
|
+
const messages = await getMessages(qrl, opt, maxMessages)
|
|
79
|
+
for (const message of messages) {
|
|
80
|
+
jobExecutor.executeJob(message, callback, qname, qrl)
|
|
302
81
|
}
|
|
82
|
+
maxReturnCount -= maxMessages
|
|
83
|
+
activeQrls.delete(qrl)
|
|
303
84
|
}
|
|
304
85
|
|
|
305
|
-
// Resolve loop
|
|
306
86
|
while (!shutdownRequested) { // eslint-disable-line
|
|
307
|
-
|
|
308
|
-
const
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
//
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
// Launch listen loop for each queue
|
|
325
|
-
for (const { qname, qrl } of selectedPairs) {
|
|
326
|
-
if (!activeLoops[qname]) activeLoops[qname] = listenLoop(qname, qrl)
|
|
327
|
-
}
|
|
328
|
-
}
|
|
329
|
-
// Wait until the next time we need to resolve
|
|
330
|
-
if (!shutdownRequested) {
|
|
331
|
-
const msSoFar = Math.max(0, new Date() - start)
|
|
332
|
-
const msUntilNextResolve = Math.max(0, opt.waitTime * 1000 - msSoFar)
|
|
333
|
-
debug({ msSoFar, msUntilNextResolve })
|
|
334
|
-
if (msUntilNextResolve) {
|
|
335
|
-
if (opt.verbose) console.error(chalk.blue('Will resolve queues again in ' + Math.round(msUntilNextResolve / 1000) + ' seconds'))
|
|
336
|
-
await delay(msUntilNextResolve)
|
|
87
|
+
// Figure out how we are running
|
|
88
|
+
const allowedJobs = maxActiveJobs - jobExecutor.activeJobCount() - maxReturnCount
|
|
89
|
+
const maxLatency = 100
|
|
90
|
+
const latency = systemMonitor.getLatency().setTimeout
|
|
91
|
+
const latencyFactor = 1 - Math.abs(Math.min(latency / maxLatency, 1)) // 0 if latency is at max, 1 if latency 0
|
|
92
|
+
const targetJobs = Math.round(allowedJobs * latencyFactor)
|
|
93
|
+
debug({ allowedJobs, maxLatency, latency, latencyFactor, targetJobs, activeQrls })
|
|
94
|
+
|
|
95
|
+
let jobsLeft = targetJobs
|
|
96
|
+
for (const { qname, qrl } of queueManager.getPairs()) {
|
|
97
|
+
if (jobsLeft <= 0 || activeQrls.has(qrl)) continue
|
|
98
|
+
const maxMessages = Math.min(10, jobsLeft)
|
|
99
|
+
listen(qname, qrl, maxMessages)
|
|
100
|
+
jobsLeft -= maxMessages
|
|
101
|
+
if (this.opt.verbose) {
|
|
102
|
+
console.error(chalk.blue('Listening on: '), qname)
|
|
337
103
|
}
|
|
104
|
+
debug({ listenedTo: { qname, maxMessages, jobsLeft } })
|
|
338
105
|
}
|
|
106
|
+
await delay(1000)
|
|
339
107
|
}
|
|
340
|
-
|
|
341
|
-
// Wait on all work to finish
|
|
342
|
-
// shutdownCallback()
|
|
343
|
-
await Promise.all(Object.values(activeLoops))
|
|
108
|
+
debug('after all')
|
|
344
109
|
}
|
|
345
|
-
|
|
346
|
-
debug('loaded')
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Component to manage all the currently executing jobs, including extending
|
|
3
|
+
* their visibility timeouts and deleting them when they are successful.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
ChangeMessageVisibilityBatchCommand,
|
|
8
|
+
DeleteMessageBatchCommand
|
|
9
|
+
} from '@aws-sdk/client-sqs'
|
|
10
|
+
import chalk from 'chalk'
|
|
11
|
+
import Debug from 'debug'
|
|
12
|
+
|
|
13
|
+
import { getSQSClient } from '../sqs.js'
|
|
14
|
+
|
|
15
|
+
const debug = Debug('qdone:jobExecutor')
|
|
16
|
+
|
|
17
|
+
const maxJobSeconds = 12 * 60 * 60
|
|
18
|
+
|
|
19
|
+
export class JobExecutor {
|
|
20
|
+
constructor (opt) {
|
|
21
|
+
this.opt = opt
|
|
22
|
+
this.jobs = []
|
|
23
|
+
this.stats = {
|
|
24
|
+
activeJobs: 0,
|
|
25
|
+
sqsCalls: 0,
|
|
26
|
+
timeoutsExtended: 0,
|
|
27
|
+
jobsSucceeded: 0,
|
|
28
|
+
jobsFailed: 0,
|
|
29
|
+
jobsDeleted: 0
|
|
30
|
+
}
|
|
31
|
+
this.maintainVisibility()
|
|
32
|
+
debug({ this: this })
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
shutdown () {
|
|
36
|
+
this.shutdownRequested = true
|
|
37
|
+
if (this.stats.activeJobs === 0 && this.jobs.length === 0) {
|
|
38
|
+
clearTimeout(this.maintainVisibilityTimeout)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
activeJobCount () {
|
|
43
|
+
return this.stats.activeJobs
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Changes message visibility on all running jobs using as few calls as possible.
|
|
48
|
+
*/
|
|
49
|
+
async maintainVisibility () {
|
|
50
|
+
debug('maintainVisibility', this.jobs)
|
|
51
|
+
const now = new Date()
|
|
52
|
+
const jobsToExtendByQrl = new Map()
|
|
53
|
+
const jobsToDeleteByQrl = new Map()
|
|
54
|
+
const jobsToCleanup = new Set()
|
|
55
|
+
|
|
56
|
+
if (this.opt.verbose) {
|
|
57
|
+
console.error(chalk.blue('Stats: '), this.stats)
|
|
58
|
+
console.error(chalk.blue('Running: '), this.jobs.map(({ qname, message }) => ({ qname, payload: message.Body })))
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Build list of jobs we need to deal with
|
|
62
|
+
for (const job of this.jobs) {
|
|
63
|
+
const jobRunTime = (now - job.start) / 1000
|
|
64
|
+
if (job.status === 'complete') {
|
|
65
|
+
const jobsToDelete = jobsToDeleteByQrl.get(job.qrl) || []
|
|
66
|
+
jobsToDelete.push(job)
|
|
67
|
+
jobsToDeleteByQrl.set(job.qrl, jobsToDelete)
|
|
68
|
+
} else if (job.status === 'failed') {
|
|
69
|
+
jobsToCleanup.add(job)
|
|
70
|
+
} else if (jobRunTime >= job.exendAtSecond) {
|
|
71
|
+
// Add it to our organized list of jobs
|
|
72
|
+
const jobsToExtend = jobsToExtendByQrl.get(job.qrl) || []
|
|
73
|
+
jobsToExtend.push(job)
|
|
74
|
+
jobsToExtendByQrl.set(job.qrl, jobsToExtend)
|
|
75
|
+
|
|
76
|
+
// Update the visibility timeout, double every time, up to max
|
|
77
|
+
const doubled = job.visibilityTimeout * 2
|
|
78
|
+
const secondsUntilMax = maxJobSeconds - jobRunTime
|
|
79
|
+
const secondsUntilKill = this.opt.killAfter - jobRunTime
|
|
80
|
+
job.visibilityTimeout = Math.min(double, secondsUntilMax, secondsUntilKill)
|
|
81
|
+
job.extendAtSecond = jobRunTime + job.visibilityTimeout // this is what we use next time
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
debug('maintainVisibility', { jobsToDeleteByQrl, jobsToExtendByQrl })
|
|
85
|
+
|
|
86
|
+
// Extend in batches for each queue
|
|
87
|
+
for (const [qrl, jobsToExtend] of jobsToExtendByQrl) {
|
|
88
|
+
while (jobsToExtend.length) {
|
|
89
|
+
// Build list of messages to go in this batch
|
|
90
|
+
const entries = []
|
|
91
|
+
let messageId = 0
|
|
92
|
+
while (messageId++ < 10 && jobsToExtend.length) {
|
|
93
|
+
const job = jobsToExtend.shift()
|
|
94
|
+
const entry = {
|
|
95
|
+
Id: '' + messageId,
|
|
96
|
+
ReceiptHandle: job.message.ReceiptHandle,
|
|
97
|
+
VisibilityTimeout: job.visibilityTimeout
|
|
98
|
+
}
|
|
99
|
+
entries.push(entry)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Change batch
|
|
103
|
+
const input = { QueueUrl: qrl, Entries: entries }
|
|
104
|
+
debug({ ChangeMessageVisibilityBatch: input })
|
|
105
|
+
const result = await getSQSClient().send(new ChangeMessageVisibilityBatchCommand(input))
|
|
106
|
+
debug('ChangeMessageVisibilityBatch returned', result)
|
|
107
|
+
this.stats.sqsCalls++
|
|
108
|
+
this.stats.timeoutsExtended += 10
|
|
109
|
+
// TODO Sentry
|
|
110
|
+
}
|
|
111
|
+
if (this.opt.verbose) {
|
|
112
|
+
console.error(chalk.blue('Extended these jobs: '), jobsToExtend)
|
|
113
|
+
} else if (!this.opt.disableLog) {
|
|
114
|
+
console.log(JSON.stringify({
|
|
115
|
+
event: 'EXTEND_VISIBILITY_TIMEOUTS',
|
|
116
|
+
timestamp: now,
|
|
117
|
+
messageIds: jobsToExtend.map(({ message }) => message.MessageId)
|
|
118
|
+
}))
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Delete in batches for each queue
|
|
123
|
+
for (const [qrl, jobsToDelete] of jobsToDeleteByQrl) {
|
|
124
|
+
while (jobsToDelete.length) {
|
|
125
|
+
// Build list of messages to go in this batch
|
|
126
|
+
const entries = []
|
|
127
|
+
let messageId = 0
|
|
128
|
+
while (messageId++ < 10 && jobsToDelete.length) {
|
|
129
|
+
const job = jobsToDelete.shift()
|
|
130
|
+
const entry = {
|
|
131
|
+
Id: '' + messageId,
|
|
132
|
+
ReceiptHandle: job.message.ReceiptHandle,
|
|
133
|
+
VisibilityTimeout: job.visibilityTimeout
|
|
134
|
+
}
|
|
135
|
+
entries.push(entry)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Delete batch
|
|
139
|
+
const input = { QueueUrl: qrl, Entries: entries }
|
|
140
|
+
debug({ DeleteMessageBatch: input })
|
|
141
|
+
const result = await getSQSClient().send(new DeleteMessageBatchCommand(input))
|
|
142
|
+
this.stats.sqsCalls++
|
|
143
|
+
this.stats.jobsDeleted += 10
|
|
144
|
+
debug('DeleteMessageBatch returned', result)
|
|
145
|
+
// TODO Sentry
|
|
146
|
+
}
|
|
147
|
+
if (this.opt.verbose) {
|
|
148
|
+
console.error(chalk.blue('Deleted these finished jobs: '), jobsToDelete)
|
|
149
|
+
} else if (!this.opt.disableLog) {
|
|
150
|
+
console.log(JSON.stringify({
|
|
151
|
+
event: 'DELETE_MESSAGES',
|
|
152
|
+
timestamp: now,
|
|
153
|
+
messageIds: jobsToDelete.map(({ message }) => message.MessageId)
|
|
154
|
+
}))
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Get rid of deleted and failed jobs
|
|
159
|
+
this.jobs = this.jobs.filter(j => j.status === 'processing')
|
|
160
|
+
|
|
161
|
+
// Check again later, unless we are shutting down and nothing's left
|
|
162
|
+
if (this.shutdownRequested && this.stats.activeJobs === 0 && this.jobs.length === 0) return
|
|
163
|
+
this.maintainVisibilityTimeout = setTimeout(() => this.maintainVisibility(), 10 * 1000)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async executeJob (message, callback, qname, qrl) {
|
|
167
|
+
// Create job entry and track it
|
|
168
|
+
const payload = this.opt.json ? JSON.parse(message.Body) : message.Body
|
|
169
|
+
const visibilityTimeout = 30
|
|
170
|
+
const job = {
|
|
171
|
+
status: 'processing',
|
|
172
|
+
start: new Date(),
|
|
173
|
+
visibilityTimeout: 30,
|
|
174
|
+
extendAtSecond: visibilityTimeout / 2,
|
|
175
|
+
payload: this.opt.json ? JSON.parse(message.Body) : message.Body,
|
|
176
|
+
message,
|
|
177
|
+
callback,
|
|
178
|
+
qname,
|
|
179
|
+
qrl
|
|
180
|
+
}
|
|
181
|
+
debug('executeJob', job)
|
|
182
|
+
this.jobs.push(job)
|
|
183
|
+
this.stats.activeJobs++
|
|
184
|
+
if (this.opt.verbose) {
|
|
185
|
+
console.error(chalk.blue('Executing:'), qname, chalk.blue('-->'), job.payload)
|
|
186
|
+
} else if (!this.opt.disableLog) {
|
|
187
|
+
console.log(JSON.stringify({
|
|
188
|
+
event: 'MESSAGE_PROCESSING_START',
|
|
189
|
+
timestamp: new Date(),
|
|
190
|
+
qname,
|
|
191
|
+
messageId: message.MessageId,
|
|
192
|
+
payload: job.payload
|
|
193
|
+
}))
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Execute job
|
|
197
|
+
try {
|
|
198
|
+
const queue = qname.slice(this.opt.prefix.length)
|
|
199
|
+
const result = await callback(queue, payload)
|
|
200
|
+
debug('executeJob callback finished', { payload, result })
|
|
201
|
+
if (this.opt.verbose) {
|
|
202
|
+
console.error(chalk.green('SUCCESS'), message.Body)
|
|
203
|
+
}
|
|
204
|
+
job.status = 'complete'
|
|
205
|
+
|
|
206
|
+
if (this.opt.verbose) {
|
|
207
|
+
console.error(chalk.blue(' done'))
|
|
208
|
+
console.error()
|
|
209
|
+
} else if (!this.opt.disableLog) {
|
|
210
|
+
console.log(JSON.stringify({
|
|
211
|
+
event: 'MESSAGE_PROCESSING_COMPLETE',
|
|
212
|
+
timestamp: new Date(),
|
|
213
|
+
messageId: message.MessageId,
|
|
214
|
+
payload
|
|
215
|
+
}))
|
|
216
|
+
}
|
|
217
|
+
this.stats.jobsSucceeded++
|
|
218
|
+
} catch (err) {
|
|
219
|
+
debug('exec.catch')
|
|
220
|
+
// Fail path for job execution
|
|
221
|
+
if (this.opt.verbose) {
|
|
222
|
+
console.error(chalk.red('FAILED'), message.Body)
|
|
223
|
+
console.error(chalk.blue(' error : ') + err)
|
|
224
|
+
} else if (!this.opt.disableLog) {
|
|
225
|
+
// Production error logging
|
|
226
|
+
console.log(JSON.stringify({
|
|
227
|
+
event: 'MESSAGE_PROCESSING_FAILED',
|
|
228
|
+
reason: 'exception thrown',
|
|
229
|
+
qname,
|
|
230
|
+
timestamp: new Date(),
|
|
231
|
+
messageId: message.MessageId,
|
|
232
|
+
payload,
|
|
233
|
+
errorMessage: err.toString().split('\n').slice(1).join('\n').trim() || undefined,
|
|
234
|
+
err
|
|
235
|
+
}))
|
|
236
|
+
}
|
|
237
|
+
job.status = 'failed'
|
|
238
|
+
this.stats.jobsFailed++
|
|
239
|
+
}
|
|
240
|
+
this.stats.activeJobs--
|
|
241
|
+
}
|
|
242
|
+
}
|