comq 0.17.1 → 0.19.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/.github/workflows/{dev.yaml → features.yaml} +14 -3
- package/discussions/poison-message-handling.md +1124 -0
- package/docs/headers.md +37 -0
- package/features/events.poison.feature +43 -12
- package/features/steps/context.js +18 -2
- package/features/steps/events.js +20 -1
- package/features/steps/poison.js +66 -4
- package/package.json +1 -1
- package/readme.md +138 -14
- package/source/channel.js +218 -30
- package/source/connect.js +31 -21
- package/source/connection.js +11 -2
- package/source/events.js +1 -1
- package/source/index.js +4 -0
- package/source/io.js +27 -1
- package/source/singleton.js +47 -4
- package/source/topology/event.json +7 -1
- package/source/topology/request.json +7 -1
- package/source/verdicts.js +54 -0
- package/test/assert.test.js +2 -2
- package/test/channel.fixtures.js +2 -1
- package/test/channel.routed.test.js +7 -3
- package/test/channel.test.js +697 -5
- package/test/connect.test.js +31 -2
- package/test/helpers.js +15 -0
- package/test/io.diagnostics.test.js +1 -1
- package/test/io.reply.test.js +66 -0
- package/test/presets.test.js +4 -2
- package/test/singleton.test.js +1 -1
- package/test/verdicts.test.js +57 -0
- package/types/connection.d.ts +6 -1
- package/types/diagnostic.d.ts +1 -1
- package/types/index.d.ts +17 -0
- package/types/io.d.ts +3 -1
- package/types/topology.d.ts +16 -0
package/test/channel.test.js
CHANGED
|
@@ -4,12 +4,13 @@
|
|
|
4
4
|
|
|
5
5
|
const { randomBytes } = require('node:crypto')
|
|
6
6
|
const { generate } = require('randomstring')
|
|
7
|
-
const { flip, random, timeout } = require('./helpers')
|
|
7
|
+
const { flip, random, timeout, immediate } = require('./helpers')
|
|
8
8
|
|
|
9
9
|
const backpressure = require('./backpressure')
|
|
10
10
|
const { amqplib } = require('./amqplib.mock')
|
|
11
11
|
const fixtures = require('./channel.fixtures')
|
|
12
12
|
const { create } = require('../source/channel')
|
|
13
|
+
const { Retry, Park } = require('../source/verdicts')
|
|
13
14
|
|
|
14
15
|
it('should be', async () => {
|
|
15
16
|
expect(create).toBeDefined()
|
|
@@ -347,9 +348,12 @@ describe.each(['group', 'exclusive'])('%s subscribe', (option) => {
|
|
|
347
348
|
})
|
|
348
349
|
|
|
349
350
|
it('should assert fanout exchange', async () => {
|
|
350
|
-
|
|
351
|
+
// the retry exchange is asserted alongside it when the channel acknowledges
|
|
352
|
+
const calls = chan.assertExchange.mock.calls.filter(([name]) => name === exchange)
|
|
351
353
|
|
|
352
|
-
|
|
354
|
+
expect(calls).toHaveLength(1)
|
|
355
|
+
|
|
356
|
+
const [name, type, options] = calls[0]
|
|
353
357
|
|
|
354
358
|
expect(name).toStrictEqual(exchange)
|
|
355
359
|
expect(type).toStrictEqual('fanout')
|
|
@@ -385,7 +389,10 @@ describe.each(['group', 'exclusive'])('%s subscribe', (option) => {
|
|
|
385
389
|
it('should bind queue to exchange', async () => {
|
|
386
390
|
const { queue } = await chan.assertQueue.mock.results[0].value
|
|
387
391
|
|
|
388
|
-
|
|
392
|
+
// comq binds its own retry queue too, when the channel acknowledges
|
|
393
|
+
const bindings = chan.bindQueue.mock.calls.filter(([, name]) => !name.startsWith('comq.'))
|
|
394
|
+
|
|
395
|
+
expect(bindings).toHaveLength(1)
|
|
389
396
|
expect(chan.bindQueue).toHaveBeenCalledWith(queue, exchange, '')
|
|
390
397
|
})
|
|
391
398
|
|
|
@@ -910,7 +917,7 @@ describe('diagnostics', () => {
|
|
|
910
917
|
|
|
911
918
|
const callback = /** @type {Function} */ chan.consume.mock.calls[0][1]
|
|
912
919
|
const content = randomBytes(8)
|
|
913
|
-
const properties = { headers: { 'x-attempt': 5 } }
|
|
920
|
+
const properties = { headers: { 'x-comq-attempt': 5 } }
|
|
914
921
|
const message = /** @type {comq.amqp.Message} */ { content, properties }
|
|
915
922
|
|
|
916
923
|
await callback(message)
|
|
@@ -1029,3 +1036,688 @@ describe('release', () => {
|
|
|
1029
1036
|
expect(release).toHaveBeenCalledWith(channel)
|
|
1030
1037
|
})
|
|
1031
1038
|
})
|
|
1039
|
+
|
|
1040
|
+
describe('failed messages', () => {
|
|
1041
|
+
const DELAY = 1000
|
|
1042
|
+
const RETRY = 'comq.retry.' + DELAY
|
|
1043
|
+
|
|
1044
|
+
let queue
|
|
1045
|
+
let exception
|
|
1046
|
+
let consumer
|
|
1047
|
+
|
|
1048
|
+
/** @returns {comq.amqp.Message} */
|
|
1049
|
+
const delivery = (properties = {}, fields = {}) => ({
|
|
1050
|
+
content: randomBytes(8),
|
|
1051
|
+
properties,
|
|
1052
|
+
fields: { exchange: generate(), routingKey: generate(), ...fields }
|
|
1053
|
+
})
|
|
1054
|
+
|
|
1055
|
+
/** The consumer amqplib was given, which is comq's wrapper rather than the callback. */
|
|
1056
|
+
const deliver = async (message) => {
|
|
1057
|
+
const callback = /** @type {Function} */ chan.consume.mock.calls[0][1]
|
|
1058
|
+
|
|
1059
|
+
return await callback(message)
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
const publications = () => chan.publish.mock.calls
|
|
1063
|
+
|
|
1064
|
+
beforeEach(async () => {
|
|
1065
|
+
jest.clearAllMocks()
|
|
1066
|
+
|
|
1067
|
+
topology.acknowledgments = true
|
|
1068
|
+
topology.durable = true
|
|
1069
|
+
topology.confirms = true
|
|
1070
|
+
topology.persistent = false
|
|
1071
|
+
topology.delay = [DELAY, DELAY, DELAY, DELAY] // four rungs, hence five attempts
|
|
1072
|
+
|
|
1073
|
+
channel = await create(connection, topology)
|
|
1074
|
+
chan = await getCreatedChannel()
|
|
1075
|
+
|
|
1076
|
+
queue = generate()
|
|
1077
|
+
exception = new Error(generate())
|
|
1078
|
+
consumer = jest.fn(async () => { throw exception })
|
|
1079
|
+
})
|
|
1080
|
+
|
|
1081
|
+
describe('topology', () => {
|
|
1082
|
+
it('should assert the retry queue and its exchange', async () => {
|
|
1083
|
+
await channel.consume(queue, consumer)
|
|
1084
|
+
|
|
1085
|
+
expect(chan.assertExchange).toHaveBeenCalledWith(RETRY, 'fanout', expect.objectContaining({ durable: true }))
|
|
1086
|
+
|
|
1087
|
+
expect(chan.assertQueue).toHaveBeenCalledWith(RETRY, expect.objectContaining({
|
|
1088
|
+
durable: true,
|
|
1089
|
+
arguments: {
|
|
1090
|
+
'x-message-ttl': DELAY,
|
|
1091
|
+
'x-dead-letter-exchange': ''
|
|
1092
|
+
}
|
|
1093
|
+
}))
|
|
1094
|
+
|
|
1095
|
+
expect(chan.bindQueue).toHaveBeenCalledWith(RETRY, RETRY, '')
|
|
1096
|
+
})
|
|
1097
|
+
|
|
1098
|
+
it('should not set a dead letter routing key', async () => {
|
|
1099
|
+
// the message keeps its own, which names the queue it came from;
|
|
1100
|
+
// a fixed one would make the queue serve a single source
|
|
1101
|
+
await channel.consume(queue, consumer)
|
|
1102
|
+
|
|
1103
|
+
const [, options] = chan.assertQueue.mock.calls.find(([name]) => name === RETRY)
|
|
1104
|
+
|
|
1105
|
+
expect(options.arguments['x-dead-letter-routing-key']).toBeUndefined()
|
|
1106
|
+
})
|
|
1107
|
+
|
|
1108
|
+
it('should assert the parked queue', async () => {
|
|
1109
|
+
await channel.consume(queue, consumer)
|
|
1110
|
+
|
|
1111
|
+
expect(chan.assertQueue).toHaveBeenCalledWith('comq.parked.' + queue,
|
|
1112
|
+
expect.objectContaining({ durable: true }))
|
|
1113
|
+
})
|
|
1114
|
+
|
|
1115
|
+
it('should assert the source queue first', async () => {
|
|
1116
|
+
await channel.consume(queue, consumer)
|
|
1117
|
+
|
|
1118
|
+
expect(chan.assertQueue.mock.calls[0][0]).toStrictEqual(queue)
|
|
1119
|
+
})
|
|
1120
|
+
|
|
1121
|
+
it('should assert the retry topology once per channel', async () => {
|
|
1122
|
+
await channel.consume(generate(), consumer)
|
|
1123
|
+
await channel.consume(generate(), consumer)
|
|
1124
|
+
await channel.consume(generate(), consumer)
|
|
1125
|
+
|
|
1126
|
+
const exchanges = chan.assertExchange.mock.calls.filter(([name]) => name === RETRY)
|
|
1127
|
+
const queues = chan.assertQueue.mock.calls.filter(([name]) => name === RETRY)
|
|
1128
|
+
|
|
1129
|
+
expect(exchanges).toHaveLength(1)
|
|
1130
|
+
expect(queues).toHaveLength(1)
|
|
1131
|
+
})
|
|
1132
|
+
|
|
1133
|
+
it('should assert a parked queue per consumed queue', async () => {
|
|
1134
|
+
const one = generate()
|
|
1135
|
+
const another = generate()
|
|
1136
|
+
|
|
1137
|
+
await channel.consume(one, consumer)
|
|
1138
|
+
await channel.consume(another, consumer)
|
|
1139
|
+
|
|
1140
|
+
expect(chan.assertQueue).toHaveBeenCalledWith('comq.parked.' + one, expect.anything())
|
|
1141
|
+
expect(chan.assertQueue).toHaveBeenCalledWith('comq.parked.' + another, expect.anything())
|
|
1142
|
+
})
|
|
1143
|
+
|
|
1144
|
+
it('should not assert either without acknowledgments', async () => {
|
|
1145
|
+
jest.clearAllMocks()
|
|
1146
|
+
|
|
1147
|
+
topology.acknowledgments = false
|
|
1148
|
+
channel = await create(connection, topology)
|
|
1149
|
+
chan = await getCreatedChannel()
|
|
1150
|
+
|
|
1151
|
+
await channel.consume(queue, consumer)
|
|
1152
|
+
|
|
1153
|
+
const internal = chan.assertQueue.mock.calls.filter(([name]) => name.startsWith('comq.'))
|
|
1154
|
+
|
|
1155
|
+
expect(internal).toHaveLength(0)
|
|
1156
|
+
expect(chan.assertExchange).not.toHaveBeenCalled()
|
|
1157
|
+
})
|
|
1158
|
+
|
|
1159
|
+
it('should declare the parked queue exclusive for an exclusive queue', async () => {
|
|
1160
|
+
jest.clearAllMocks()
|
|
1161
|
+
|
|
1162
|
+
topology.durable = false
|
|
1163
|
+
channel = await create(connection, topology)
|
|
1164
|
+
chan = await getCreatedChannel()
|
|
1165
|
+
|
|
1166
|
+
await channel.consume(queue, consumer)
|
|
1167
|
+
|
|
1168
|
+
const [, options] = chan.assertQueue.mock.calls
|
|
1169
|
+
.find(([name]) => name === 'comq.parked.' + queue)
|
|
1170
|
+
|
|
1171
|
+
expect(options).toMatchObject({ exclusive: true })
|
|
1172
|
+
})
|
|
1173
|
+
|
|
1174
|
+
it('should re-assert after recovery', async () => {
|
|
1175
|
+
await channel.consume(queue, consumer)
|
|
1176
|
+
|
|
1177
|
+
const replacement = await amqplib.connect()
|
|
1178
|
+
|
|
1179
|
+
await channel.recover(replacement)
|
|
1180
|
+
|
|
1181
|
+
const repl = await getCreatedChannel(replacement)
|
|
1182
|
+
|
|
1183
|
+
expect(repl.assertExchange).toHaveBeenCalledWith(RETRY, 'fanout', expect.anything())
|
|
1184
|
+
expect(repl.assertQueue).toHaveBeenCalledWith('comq.parked.' + queue, expect.anything())
|
|
1185
|
+
})
|
|
1186
|
+
})
|
|
1187
|
+
|
|
1188
|
+
describe('retry', () => {
|
|
1189
|
+
it('should publish to the retry exchange keyed by the source queue', async () => {
|
|
1190
|
+
await channel.consume(queue, consumer)
|
|
1191
|
+
|
|
1192
|
+
const message = delivery()
|
|
1193
|
+
|
|
1194
|
+
await deliver(message)
|
|
1195
|
+
|
|
1196
|
+
const [exchange, key] = publications()[0]
|
|
1197
|
+
|
|
1198
|
+
expect(exchange).toStrictEqual(RETRY)
|
|
1199
|
+
expect(key).toStrictEqual(queue)
|
|
1200
|
+
})
|
|
1201
|
+
|
|
1202
|
+
it('should not republish to the exchange the message came from', async () => {
|
|
1203
|
+
await channel.consume(queue, consumer)
|
|
1204
|
+
|
|
1205
|
+
const message = delivery()
|
|
1206
|
+
|
|
1207
|
+
await deliver(message)
|
|
1208
|
+
|
|
1209
|
+
const [exchange] = publications()[0]
|
|
1210
|
+
|
|
1211
|
+
// a fanout source exchange would otherwise redeliver the retry to every subscriber
|
|
1212
|
+
expect(exchange).not.toStrictEqual(message.fields.exchange)
|
|
1213
|
+
})
|
|
1214
|
+
|
|
1215
|
+
it('should publish before acknowledging', async () => {
|
|
1216
|
+
await channel.consume(queue, consumer)
|
|
1217
|
+
|
|
1218
|
+
await deliver(delivery())
|
|
1219
|
+
|
|
1220
|
+
expect(chan.publish.mock.invocationCallOrder[0])
|
|
1221
|
+
.toBeLessThan(chan.ack.mock.invocationCallOrder[0])
|
|
1222
|
+
})
|
|
1223
|
+
|
|
1224
|
+
it('should wait for the confirmation before acknowledging', async () => {
|
|
1225
|
+
await channel.consume(queue, consumer)
|
|
1226
|
+
|
|
1227
|
+
let confirm
|
|
1228
|
+
|
|
1229
|
+
chan.publish.mockImplementationOnce((_0, _1, _2, _3, callback) => { confirm = callback })
|
|
1230
|
+
|
|
1231
|
+
const pending = deliver(delivery())
|
|
1232
|
+
|
|
1233
|
+
await immediate()
|
|
1234
|
+
|
|
1235
|
+
expect(chan.ack).not.toHaveBeenCalled()
|
|
1236
|
+
|
|
1237
|
+
confirm(null)
|
|
1238
|
+
await pending
|
|
1239
|
+
|
|
1240
|
+
expect(chan.ack).toHaveBeenCalled()
|
|
1241
|
+
})
|
|
1242
|
+
|
|
1243
|
+
it('should increment the attempt', async () => {
|
|
1244
|
+
await channel.consume(queue, consumer)
|
|
1245
|
+
|
|
1246
|
+
await deliver(delivery({ headers: { 'x-comq-attempt': 2 } }))
|
|
1247
|
+
|
|
1248
|
+
const [, , , options] = publications()[0]
|
|
1249
|
+
|
|
1250
|
+
expect(options.headers['x-comq-attempt']).toStrictEqual(3)
|
|
1251
|
+
})
|
|
1252
|
+
|
|
1253
|
+
it('should tolerate a message without headers', async () => {
|
|
1254
|
+
// a message published by something that is not comq carries no field table,
|
|
1255
|
+
// and the first delivery of any message carries no attempt either
|
|
1256
|
+
await channel.consume(queue, consumer)
|
|
1257
|
+
|
|
1258
|
+
await expect(deliver(delivery({}))).resolves.not.toThrow()
|
|
1259
|
+
|
|
1260
|
+
const [, , , options] = publications()[0]
|
|
1261
|
+
|
|
1262
|
+
expect(options.headers['x-comq-attempt']).toStrictEqual(2)
|
|
1263
|
+
})
|
|
1264
|
+
|
|
1265
|
+
it('should record the origin on the first failure', async () => {
|
|
1266
|
+
await channel.consume(queue, consumer)
|
|
1267
|
+
|
|
1268
|
+
const message = delivery()
|
|
1269
|
+
|
|
1270
|
+
await deliver(message)
|
|
1271
|
+
|
|
1272
|
+
const [, , , options] = publications()[0]
|
|
1273
|
+
|
|
1274
|
+
expect(options.headers['x-comq-exchange']).toStrictEqual(message.fields.exchange)
|
|
1275
|
+
expect(options.headers['x-comq-key']).toStrictEqual(message.fields.routingKey)
|
|
1276
|
+
})
|
|
1277
|
+
|
|
1278
|
+
it('should keep the recorded origin on later failures', async () => {
|
|
1279
|
+
// a returned retry arrives through the default exchange, so its own fields
|
|
1280
|
+
// no longer say where it was published
|
|
1281
|
+
await channel.consume(queue, consumer)
|
|
1282
|
+
|
|
1283
|
+
const origin = generate()
|
|
1284
|
+
const message = delivery(
|
|
1285
|
+
{ headers: { 'x-comq-attempt': 1, 'x-comq-exchange': origin, 'x-comq-key': generate() } },
|
|
1286
|
+
{ exchange: '' })
|
|
1287
|
+
|
|
1288
|
+
await deliver(message)
|
|
1289
|
+
|
|
1290
|
+
const [, , , options] = publications()[0]
|
|
1291
|
+
|
|
1292
|
+
expect(options.headers['x-comq-exchange']).toStrictEqual(origin)
|
|
1293
|
+
})
|
|
1294
|
+
|
|
1295
|
+
it('should publish persistent from a transient topology', async () => {
|
|
1296
|
+
await channel.consume(queue, consumer)
|
|
1297
|
+
|
|
1298
|
+
await deliver(delivery())
|
|
1299
|
+
|
|
1300
|
+
const [, , , options] = publications()[0]
|
|
1301
|
+
|
|
1302
|
+
expect(options.persistent).toStrictEqual(true)
|
|
1303
|
+
})
|
|
1304
|
+
|
|
1305
|
+
it('should not mutate the message', async () => {
|
|
1306
|
+
await channel.consume(queue, consumer)
|
|
1307
|
+
|
|
1308
|
+
const message = delivery({})
|
|
1309
|
+
|
|
1310
|
+
await deliver(message)
|
|
1311
|
+
|
|
1312
|
+
expect(message.properties.headers).toBeUndefined()
|
|
1313
|
+
})
|
|
1314
|
+
|
|
1315
|
+
it('should emit the `retry` event', async () => {
|
|
1316
|
+
const listener = jest.fn()
|
|
1317
|
+
|
|
1318
|
+
channel.diagnose('retry', listener)
|
|
1319
|
+
|
|
1320
|
+
await channel.consume(queue, consumer)
|
|
1321
|
+
|
|
1322
|
+
const message = delivery()
|
|
1323
|
+
|
|
1324
|
+
await deliver(message)
|
|
1325
|
+
|
|
1326
|
+
expect(listener).toHaveBeenCalledWith(message, exception, 1)
|
|
1327
|
+
})
|
|
1328
|
+
|
|
1329
|
+
it('should not throw', async () => {
|
|
1330
|
+
// amqplib drops the promise it gets back, so a rejection ends the process
|
|
1331
|
+
await channel.consume(queue, consumer)
|
|
1332
|
+
|
|
1333
|
+
await expect(deliver(delivery())).resolves.not.toThrow()
|
|
1334
|
+
})
|
|
1335
|
+
|
|
1336
|
+
it('should not seal the channel', async () => {
|
|
1337
|
+
await channel.consume(queue, consumer)
|
|
1338
|
+
|
|
1339
|
+
await deliver(delivery())
|
|
1340
|
+
|
|
1341
|
+
expect(chan.cancel).not.toHaveBeenCalled()
|
|
1342
|
+
})
|
|
1343
|
+
|
|
1344
|
+
it('should keep consuming', async () => {
|
|
1345
|
+
const another = generate()
|
|
1346
|
+
|
|
1347
|
+
await channel.consume(queue, consumer)
|
|
1348
|
+
await deliver(delivery())
|
|
1349
|
+
|
|
1350
|
+
await expect(channel.consume(another, jest.fn())).resolves.not.toThrow()
|
|
1351
|
+
|
|
1352
|
+
expect(chan.consume).toHaveBeenCalledWith(another, expect.any(Function), expect.anything())
|
|
1353
|
+
})
|
|
1354
|
+
|
|
1355
|
+
it('should ignore an exception thrown after the channel closed', async () => {
|
|
1356
|
+
await channel.consume(queue, consumer)
|
|
1357
|
+
|
|
1358
|
+
consumer.mockImplementationOnce(async () => { throw new Error('Channel closed') })
|
|
1359
|
+
|
|
1360
|
+
await expect(deliver(delivery())).resolves.not.toThrow()
|
|
1361
|
+
|
|
1362
|
+
expect(chan.publish).not.toHaveBeenCalled()
|
|
1363
|
+
expect(chan.ack).not.toHaveBeenCalled()
|
|
1364
|
+
expect(chan.nack).not.toHaveBeenCalled()
|
|
1365
|
+
})
|
|
1366
|
+
|
|
1367
|
+
it('should tolerate a rejection that is not an Error', async () => {
|
|
1368
|
+
await channel.consume(queue, consumer)
|
|
1369
|
+
|
|
1370
|
+
consumer.mockImplementationOnce(async () => { throw undefined }) // eslint-disable-line
|
|
1371
|
+
|
|
1372
|
+
await expect(deliver(delivery())).resolves.not.toThrow()
|
|
1373
|
+
|
|
1374
|
+
expect(chan.publish).toHaveBeenCalled()
|
|
1375
|
+
})
|
|
1376
|
+
})
|
|
1377
|
+
|
|
1378
|
+
describe('the healthy path', () => {
|
|
1379
|
+
it('should not make ordinary publishing persistent', async () => {
|
|
1380
|
+
// only a message that has already failed is worth a disk write; Requests are
|
|
1381
|
+
// transient for the sake of latency and must stay that way
|
|
1382
|
+
jest.clearAllMocks()
|
|
1383
|
+
|
|
1384
|
+
topology.persistent = false
|
|
1385
|
+
channel = await create(connection, topology)
|
|
1386
|
+
chan = await getCreatedChannel()
|
|
1387
|
+
|
|
1388
|
+
await channel.send(generate(), randomBytes(8))
|
|
1389
|
+
await channel.publish(generate(), randomBytes(8))
|
|
1390
|
+
|
|
1391
|
+
for (const [, , , options] of chan.publish.mock.calls) {
|
|
1392
|
+
expect(options.persistent).toStrictEqual(false)
|
|
1393
|
+
}
|
|
1394
|
+
})
|
|
1395
|
+
})
|
|
1396
|
+
|
|
1397
|
+
describe('backoff', () => {
|
|
1398
|
+
const LADDER = [1000, 5000, 25000]
|
|
1399
|
+
|
|
1400
|
+
/** @returns {string[]} the retry queues asserted, in order */
|
|
1401
|
+
const asserted = () => chan.assertQueue.mock.calls
|
|
1402
|
+
.map(([name]) => name)
|
|
1403
|
+
.filter((name) => name.startsWith('comq.retry.'))
|
|
1404
|
+
|
|
1405
|
+
beforeEach(async () => {
|
|
1406
|
+
jest.clearAllMocks()
|
|
1407
|
+
|
|
1408
|
+
topology.delay = LADDER
|
|
1409
|
+
|
|
1410
|
+
channel = await create(connection, topology)
|
|
1411
|
+
chan = await getCreatedChannel()
|
|
1412
|
+
})
|
|
1413
|
+
|
|
1414
|
+
it('should declare a queue and an exchange per rung', async () => {
|
|
1415
|
+
await channel.consume(queue, consumer)
|
|
1416
|
+
|
|
1417
|
+
expect(asserted()).toStrictEqual(LADDER.map((delay) => 'comq.retry.' + delay))
|
|
1418
|
+
|
|
1419
|
+
for (const delay of LADDER) {
|
|
1420
|
+
const name = 'comq.retry.' + delay
|
|
1421
|
+
|
|
1422
|
+
expect(chan.assertExchange).toHaveBeenCalledWith(name, 'fanout', expect.anything())
|
|
1423
|
+
expect(chan.bindQueue).toHaveBeenCalledWith(name, name, '')
|
|
1424
|
+
|
|
1425
|
+
const [, options] = chan.assertQueue.mock.calls.find(([asserted]) => asserted === name)
|
|
1426
|
+
|
|
1427
|
+
expect(options.arguments['x-message-ttl']).toStrictEqual(delay)
|
|
1428
|
+
}
|
|
1429
|
+
})
|
|
1430
|
+
|
|
1431
|
+
it('should declare a repeated rung once', async () => {
|
|
1432
|
+
jest.clearAllMocks()
|
|
1433
|
+
|
|
1434
|
+
topology.delay = [1000, 5000, 1000]
|
|
1435
|
+
channel = await create(connection, topology)
|
|
1436
|
+
chan = await getCreatedChannel()
|
|
1437
|
+
|
|
1438
|
+
await channel.consume(queue, consumer)
|
|
1439
|
+
|
|
1440
|
+
expect(asserted()).toStrictEqual(['comq.retry.1000', 'comq.retry.5000'])
|
|
1441
|
+
})
|
|
1442
|
+
|
|
1443
|
+
it('should climb the ladder', async () => {
|
|
1444
|
+
await channel.consume(queue, consumer)
|
|
1445
|
+
|
|
1446
|
+
await deliver(delivery({}))
|
|
1447
|
+
await deliver(delivery({ headers: { 'x-comq-attempt': 2 } }))
|
|
1448
|
+
await deliver(delivery({ headers: { 'x-comq-attempt': 3 } }))
|
|
1449
|
+
await deliver(delivery({ headers: { 'x-comq-attempt': 4 } }))
|
|
1450
|
+
|
|
1451
|
+
expect(publications().map(([exchange]) => exchange)).toStrictEqual([
|
|
1452
|
+
'comq.retry.1000',
|
|
1453
|
+
'comq.retry.5000',
|
|
1454
|
+
'comq.retry.25000',
|
|
1455
|
+
'' // parked
|
|
1456
|
+
])
|
|
1457
|
+
})
|
|
1458
|
+
|
|
1459
|
+
it('should take a number as a ladder of one', async () => {
|
|
1460
|
+
jest.clearAllMocks()
|
|
1461
|
+
|
|
1462
|
+
topology.delay = 1000
|
|
1463
|
+
|
|
1464
|
+
channel = await create(connection, topology)
|
|
1465
|
+
chan = await getCreatedChannel()
|
|
1466
|
+
|
|
1467
|
+
await channel.consume(queue, consumer)
|
|
1468
|
+
|
|
1469
|
+
await deliver(delivery({}))
|
|
1470
|
+
await deliver(delivery({ headers: { 'x-comq-attempt': 2 } }))
|
|
1471
|
+
|
|
1472
|
+
expect(asserted()).toStrictEqual(['comq.retry.1000'])
|
|
1473
|
+
expect(publications().map(([exchange, key]) => exchange || key))
|
|
1474
|
+
.toStrictEqual(['comq.retry.1000', 'comq.parked.' + queue])
|
|
1475
|
+
})
|
|
1476
|
+
})
|
|
1477
|
+
|
|
1478
|
+
describe('parking', () => {
|
|
1479
|
+
const exhausted = () => delivery({ headers: { 'x-comq-attempt': 5 } })
|
|
1480
|
+
|
|
1481
|
+
it('should publish to the parked queue once the attempts are spent', async () => {
|
|
1482
|
+
await channel.consume(queue, consumer)
|
|
1483
|
+
|
|
1484
|
+
await deliver(exhausted())
|
|
1485
|
+
|
|
1486
|
+
const [exchange, key] = publications()[0]
|
|
1487
|
+
|
|
1488
|
+
expect(exchange).toStrictEqual('')
|
|
1489
|
+
expect(key).toStrictEqual('comq.parked.' + queue)
|
|
1490
|
+
})
|
|
1491
|
+
|
|
1492
|
+
it('should not retry once the attempts are spent', async () => {
|
|
1493
|
+
await channel.consume(queue, consumer)
|
|
1494
|
+
|
|
1495
|
+
await deliver(exhausted())
|
|
1496
|
+
|
|
1497
|
+
expect(publications()).toHaveLength(1)
|
|
1498
|
+
expect(chan.nack).not.toHaveBeenCalled()
|
|
1499
|
+
})
|
|
1500
|
+
|
|
1501
|
+
it('should publish before acknowledging', async () => {
|
|
1502
|
+
await channel.consume(queue, consumer)
|
|
1503
|
+
|
|
1504
|
+
await deliver(exhausted())
|
|
1505
|
+
|
|
1506
|
+
expect(chan.publish.mock.invocationCallOrder[0])
|
|
1507
|
+
.toBeLessThan(chan.ack.mock.invocationCallOrder[0])
|
|
1508
|
+
})
|
|
1509
|
+
|
|
1510
|
+
it('should record where the message came from', async () => {
|
|
1511
|
+
await channel.consume(queue, consumer)
|
|
1512
|
+
|
|
1513
|
+
const origin = generate()
|
|
1514
|
+
const message = delivery({ headers: { 'x-comq-attempt': 5, 'x-comq-exchange': origin } })
|
|
1515
|
+
|
|
1516
|
+
await deliver(message)
|
|
1517
|
+
|
|
1518
|
+
const [, , , options] = publications()[0]
|
|
1519
|
+
|
|
1520
|
+
expect(options.headers['x-comq-exchange']).toStrictEqual(origin)
|
|
1521
|
+
expect(options.headers['x-comq-queue']).toStrictEqual(queue)
|
|
1522
|
+
expect(options.headers['x-comq-reason']).toStrictEqual(exception.message)
|
|
1523
|
+
expect(options.headers['x-comq-at']).toStrictEqual(expect.any(Number))
|
|
1524
|
+
})
|
|
1525
|
+
|
|
1526
|
+
it('should keep replyTo and correlationId', async () => {
|
|
1527
|
+
// a parked Request remains answerable while its caller is still waiting
|
|
1528
|
+
await channel.consume(queue, consumer)
|
|
1529
|
+
|
|
1530
|
+
const replyTo = generate()
|
|
1531
|
+
const correlationId = generate()
|
|
1532
|
+
|
|
1533
|
+
await deliver(delivery({ headers: { 'x-comq-attempt': 5 }, replyTo, correlationId }))
|
|
1534
|
+
|
|
1535
|
+
const [, , , options] = publications()[0]
|
|
1536
|
+
|
|
1537
|
+
expect(options.replyTo).toStrictEqual(replyTo)
|
|
1538
|
+
expect(options.correlationId).toStrictEqual(correlationId)
|
|
1539
|
+
})
|
|
1540
|
+
|
|
1541
|
+
it('should publish persistent from a transient topology', async () => {
|
|
1542
|
+
await channel.consume(queue, consumer)
|
|
1543
|
+
|
|
1544
|
+
await deliver(exhausted())
|
|
1545
|
+
|
|
1546
|
+
const [, , , options] = publications()[0]
|
|
1547
|
+
|
|
1548
|
+
expect(options.persistent).toStrictEqual(true)
|
|
1549
|
+
})
|
|
1550
|
+
|
|
1551
|
+
it('should count the first delivery as an attempt', async () => {
|
|
1552
|
+
// a ladder of one rung is a single retry, so the second delivery is the last
|
|
1553
|
+
jest.clearAllMocks()
|
|
1554
|
+
|
|
1555
|
+
topology.delay = 1000
|
|
1556
|
+
channel = await create(connection, topology)
|
|
1557
|
+
chan = await getCreatedChannel()
|
|
1558
|
+
|
|
1559
|
+
await channel.consume(queue, consumer)
|
|
1560
|
+
|
|
1561
|
+
await deliver(delivery({}))
|
|
1562
|
+
await deliver(delivery({ headers: { 'x-comq-attempt': 2 } }))
|
|
1563
|
+
|
|
1564
|
+
const targets = publications().map(([, key]) => key)
|
|
1565
|
+
|
|
1566
|
+
expect(targets).toStrictEqual([queue, 'comq.parked.' + queue])
|
|
1567
|
+
})
|
|
1568
|
+
|
|
1569
|
+
it('should give a message one delivery more than the ladder has rungs', async () => {
|
|
1570
|
+
jest.clearAllMocks()
|
|
1571
|
+
|
|
1572
|
+
topology.delay = [1000, 2000]
|
|
1573
|
+
channel = await create(connection, topology)
|
|
1574
|
+
chan = await getCreatedChannel()
|
|
1575
|
+
|
|
1576
|
+
await channel.consume(queue, consumer)
|
|
1577
|
+
|
|
1578
|
+
await deliver(delivery({}))
|
|
1579
|
+
await deliver(delivery({ headers: { 'x-comq-attempt': 2 } }))
|
|
1580
|
+
await deliver(delivery({ headers: { 'x-comq-attempt': 3 } }))
|
|
1581
|
+
|
|
1582
|
+
const targets = publications().map(([, key]) => key)
|
|
1583
|
+
|
|
1584
|
+
expect(targets).toStrictEqual([queue, queue, 'comq.parked.' + queue])
|
|
1585
|
+
})
|
|
1586
|
+
})
|
|
1587
|
+
|
|
1588
|
+
describe('verdicts', () => {
|
|
1589
|
+
it('should park on the first delivery when the consumer says to', async () => {
|
|
1590
|
+
await channel.consume(queue, consumer)
|
|
1591
|
+
|
|
1592
|
+
consumer.mockImplementationOnce(async () => { throw new Park(generate()) })
|
|
1593
|
+
|
|
1594
|
+
await deliver(delivery({}))
|
|
1595
|
+
|
|
1596
|
+
const [exchange, key] = publications()[0]
|
|
1597
|
+
|
|
1598
|
+
expect(exchange).toStrictEqual('')
|
|
1599
|
+
expect(key).toStrictEqual('comq.parked.' + queue)
|
|
1600
|
+
expect(publications()).toHaveLength(1)
|
|
1601
|
+
})
|
|
1602
|
+
|
|
1603
|
+
it('should not retry a parked message even with the ladder untouched', async () => {
|
|
1604
|
+
await channel.consume(queue, consumer)
|
|
1605
|
+
|
|
1606
|
+
consumer.mockImplementationOnce(async () => { throw new Park(generate()) })
|
|
1607
|
+
|
|
1608
|
+
await deliver(delivery({}))
|
|
1609
|
+
|
|
1610
|
+
const [, , , options] = publications()[0]
|
|
1611
|
+
|
|
1612
|
+
// parked on the first delivery, so it never climbed a rung
|
|
1613
|
+
expect(options.headers['x-comq-attempt']).toBeUndefined()
|
|
1614
|
+
})
|
|
1615
|
+
|
|
1616
|
+
it('should treat Retry exactly as a bare rejection', async () => {
|
|
1617
|
+
await channel.consume(queue, consumer)
|
|
1618
|
+
|
|
1619
|
+
consumer.mockImplementationOnce(async () => { throw new Retry(generate()) })
|
|
1620
|
+
|
|
1621
|
+
await deliver(delivery({}))
|
|
1622
|
+
|
|
1623
|
+
const [exchange] = publications()[0]
|
|
1624
|
+
|
|
1625
|
+
expect(exchange).toStrictEqual('comq.retry.' + DELAY)
|
|
1626
|
+
})
|
|
1627
|
+
|
|
1628
|
+
it('should record the verdict message as the reason', async () => {
|
|
1629
|
+
await channel.consume(queue, consumer)
|
|
1630
|
+
|
|
1631
|
+
const reason = generate()
|
|
1632
|
+
|
|
1633
|
+
consumer.mockImplementationOnce(async () => { throw new Park(reason) })
|
|
1634
|
+
|
|
1635
|
+
await deliver(delivery({}))
|
|
1636
|
+
|
|
1637
|
+
const [, , , options] = publications()[0]
|
|
1638
|
+
|
|
1639
|
+
expect(options.headers['x-comq-reason']).toStrictEqual(reason)
|
|
1640
|
+
})
|
|
1641
|
+
|
|
1642
|
+
it('should record the cause alongside the reason', async () => {
|
|
1643
|
+
await channel.consume(queue, consumer)
|
|
1644
|
+
|
|
1645
|
+
const reason = generate()
|
|
1646
|
+
const cause = new Error(generate())
|
|
1647
|
+
|
|
1648
|
+
consumer.mockImplementationOnce(async () => { throw new Park(reason, { cause }) })
|
|
1649
|
+
|
|
1650
|
+
await deliver(delivery({}))
|
|
1651
|
+
|
|
1652
|
+
const [, , , options] = publications()[0]
|
|
1653
|
+
|
|
1654
|
+
expect(options.headers['x-comq-reason']).toStrictEqual(reason)
|
|
1655
|
+
expect(options.headers['x-comq-cause']).toStrictEqual(cause.message)
|
|
1656
|
+
})
|
|
1657
|
+
|
|
1658
|
+
it('should emit `discard` for a parked verdict', async () => {
|
|
1659
|
+
const listener = jest.fn()
|
|
1660
|
+
|
|
1661
|
+
channel.diagnose('discard', listener)
|
|
1662
|
+
|
|
1663
|
+
await channel.consume(queue, consumer)
|
|
1664
|
+
|
|
1665
|
+
const park = new Park(generate())
|
|
1666
|
+
|
|
1667
|
+
consumer.mockImplementationOnce(async () => { throw park })
|
|
1668
|
+
|
|
1669
|
+
const message = delivery({})
|
|
1670
|
+
|
|
1671
|
+
await deliver(message)
|
|
1672
|
+
|
|
1673
|
+
expect(listener).toHaveBeenCalledWith(message, park)
|
|
1674
|
+
})
|
|
1675
|
+
|
|
1676
|
+
it('should honour a Park thrown by another copy of comq', async () => {
|
|
1677
|
+
await channel.consume(queue, consumer)
|
|
1678
|
+
|
|
1679
|
+
const foreign = new Error(generate())
|
|
1680
|
+
|
|
1681
|
+
foreign[Symbol.for('comq.verdict')] = 'park'
|
|
1682
|
+
|
|
1683
|
+
consumer.mockImplementationOnce(async () => { throw foreign })
|
|
1684
|
+
|
|
1685
|
+
await deliver(delivery({}))
|
|
1686
|
+
|
|
1687
|
+
expect(publications()[0][1]).toStrictEqual('comq.parked.' + queue)
|
|
1688
|
+
})
|
|
1689
|
+
})
|
|
1690
|
+
|
|
1691
|
+
describe('when the message cannot be moved', () => {
|
|
1692
|
+
it('should give the delivery back', async () => {
|
|
1693
|
+
await channel.consume(queue, consumer)
|
|
1694
|
+
|
|
1695
|
+
chan.publish.mockImplementation(() => { throw new Error(generate()) })
|
|
1696
|
+
|
|
1697
|
+
await expect(deliver(delivery())).resolves.not.toThrow()
|
|
1698
|
+
|
|
1699
|
+
expect(chan.nack).toHaveBeenCalledWith(expect.anything(), false, true)
|
|
1700
|
+
expect(chan.ack).not.toHaveBeenCalled()
|
|
1701
|
+
})
|
|
1702
|
+
|
|
1703
|
+
it('should give it back when the confirmation is rejected', async () => {
|
|
1704
|
+
await channel.consume(queue, consumer)
|
|
1705
|
+
|
|
1706
|
+
chan.publish.mockImplementationOnce((_0, _1, _2, _3, callback) => callback(new Error(generate())))
|
|
1707
|
+
|
|
1708
|
+
await expect(deliver(delivery())).resolves.not.toThrow()
|
|
1709
|
+
|
|
1710
|
+
expect(chan.nack).toHaveBeenCalledWith(expect.anything(), false, true)
|
|
1711
|
+
expect(chan.ack).not.toHaveBeenCalled()
|
|
1712
|
+
})
|
|
1713
|
+
|
|
1714
|
+
it('should not throw when giving it back also fails', async () => {
|
|
1715
|
+
await channel.consume(queue, consumer)
|
|
1716
|
+
|
|
1717
|
+
chan.publish.mockImplementation(() => { throw new Error(generate()) })
|
|
1718
|
+
chan.nack.mockImplementation(() => { throw new Error('Channel closed') })
|
|
1719
|
+
|
|
1720
|
+
await expect(deliver(delivery())).resolves.not.toThrow()
|
|
1721
|
+
})
|
|
1722
|
+
})
|
|
1723
|
+
})
|