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.
@@ -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
- expect(chan.assertExchange).toHaveBeenCalledTimes(1)
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
- const [name, type, options] = chan.assertExchange.mock.calls[0]
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
- expect(chan.bindQueue).toHaveBeenCalledTimes(1)
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
+ })