comq 0.17.1 → 0.18.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 +1120 -0
- package/docs/headers.md +36 -0
- package/features/events.poison.feature +32 -12
- package/features/steps/context.js +18 -2
- package/features/steps/events.js +5 -1
- package/features/steps/poison.js +65 -4
- package/package.json +1 -1
- package/readme.md +103 -14
- package/source/channel.js +210 -30
- package/source/connect.js +31 -21
- package/source/connection.js +11 -2
- package/source/events.js +1 -1
- package/source/singleton.js +47 -4
- package/source/topology/event.json +7 -1
- package/source/topology/request.json +7 -1
- 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 +593 -5
- package/test/connect.test.js +31 -2
- package/test/helpers.js +15 -0
- package/test/io.diagnostics.test.js +1 -1
- package/test/presets.test.js +4 -2
- package/test/singleton.test.js +1 -1
- package/types/connection.d.ts +6 -1
- package/types/diagnostic.d.ts +1 -1
- package/types/io.d.ts +3 -1
- package/types/topology.d.ts +16 -0
package/test/channel.test.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
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')
|
|
@@ -347,9 +347,12 @@ describe.each(['group', 'exclusive'])('%s subscribe', (option) => {
|
|
|
347
347
|
})
|
|
348
348
|
|
|
349
349
|
it('should assert fanout exchange', async () => {
|
|
350
|
-
|
|
350
|
+
// the retry exchange is asserted alongside it when the channel acknowledges
|
|
351
|
+
const calls = chan.assertExchange.mock.calls.filter(([name]) => name === exchange)
|
|
351
352
|
|
|
352
|
-
|
|
353
|
+
expect(calls).toHaveLength(1)
|
|
354
|
+
|
|
355
|
+
const [name, type, options] = calls[0]
|
|
353
356
|
|
|
354
357
|
expect(name).toStrictEqual(exchange)
|
|
355
358
|
expect(type).toStrictEqual('fanout')
|
|
@@ -385,7 +388,10 @@ describe.each(['group', 'exclusive'])('%s subscribe', (option) => {
|
|
|
385
388
|
it('should bind queue to exchange', async () => {
|
|
386
389
|
const { queue } = await chan.assertQueue.mock.results[0].value
|
|
387
390
|
|
|
388
|
-
|
|
391
|
+
// comq binds its own retry queue too, when the channel acknowledges
|
|
392
|
+
const bindings = chan.bindQueue.mock.calls.filter(([, name]) => !name.startsWith('comq.'))
|
|
393
|
+
|
|
394
|
+
expect(bindings).toHaveLength(1)
|
|
389
395
|
expect(chan.bindQueue).toHaveBeenCalledWith(queue, exchange, '')
|
|
390
396
|
})
|
|
391
397
|
|
|
@@ -910,7 +916,7 @@ describe('diagnostics', () => {
|
|
|
910
916
|
|
|
911
917
|
const callback = /** @type {Function} */ chan.consume.mock.calls[0][1]
|
|
912
918
|
const content = randomBytes(8)
|
|
913
|
-
const properties = { headers: { 'x-attempt': 5 } }
|
|
919
|
+
const properties = { headers: { 'x-comq-attempt': 5 } }
|
|
914
920
|
const message = /** @type {comq.amqp.Message} */ { content, properties }
|
|
915
921
|
|
|
916
922
|
await callback(message)
|
|
@@ -1029,3 +1035,585 @@ describe('release', () => {
|
|
|
1029
1035
|
expect(release).toHaveBeenCalledWith(channel)
|
|
1030
1036
|
})
|
|
1031
1037
|
})
|
|
1038
|
+
|
|
1039
|
+
describe('failed messages', () => {
|
|
1040
|
+
const DELAY = 1000
|
|
1041
|
+
const RETRY = 'comq.retry.' + DELAY
|
|
1042
|
+
|
|
1043
|
+
let queue
|
|
1044
|
+
let exception
|
|
1045
|
+
let consumer
|
|
1046
|
+
|
|
1047
|
+
/** @returns {comq.amqp.Message} */
|
|
1048
|
+
const delivery = (properties = {}, fields = {}) => ({
|
|
1049
|
+
content: randomBytes(8),
|
|
1050
|
+
properties,
|
|
1051
|
+
fields: { exchange: generate(), routingKey: generate(), ...fields }
|
|
1052
|
+
})
|
|
1053
|
+
|
|
1054
|
+
/** The consumer amqplib was given, which is comq's wrapper rather than the callback. */
|
|
1055
|
+
const deliver = async (message) => {
|
|
1056
|
+
const callback = /** @type {Function} */ chan.consume.mock.calls[0][1]
|
|
1057
|
+
|
|
1058
|
+
return await callback(message)
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
const publications = () => chan.publish.mock.calls
|
|
1062
|
+
|
|
1063
|
+
beforeEach(async () => {
|
|
1064
|
+
jest.clearAllMocks()
|
|
1065
|
+
|
|
1066
|
+
topology.acknowledgments = true
|
|
1067
|
+
topology.durable = true
|
|
1068
|
+
topology.confirms = true
|
|
1069
|
+
topology.persistent = false
|
|
1070
|
+
topology.delay = [DELAY, DELAY, DELAY, DELAY] // four rungs, hence five attempts
|
|
1071
|
+
|
|
1072
|
+
channel = await create(connection, topology)
|
|
1073
|
+
chan = await getCreatedChannel()
|
|
1074
|
+
|
|
1075
|
+
queue = generate()
|
|
1076
|
+
exception = new Error(generate())
|
|
1077
|
+
consumer = jest.fn(async () => { throw exception })
|
|
1078
|
+
})
|
|
1079
|
+
|
|
1080
|
+
describe('topology', () => {
|
|
1081
|
+
it('should assert the retry queue and its exchange', async () => {
|
|
1082
|
+
await channel.consume(queue, consumer)
|
|
1083
|
+
|
|
1084
|
+
expect(chan.assertExchange).toHaveBeenCalledWith(RETRY, 'fanout', expect.objectContaining({ durable: true }))
|
|
1085
|
+
|
|
1086
|
+
expect(chan.assertQueue).toHaveBeenCalledWith(RETRY, expect.objectContaining({
|
|
1087
|
+
durable: true,
|
|
1088
|
+
arguments: {
|
|
1089
|
+
'x-message-ttl': DELAY,
|
|
1090
|
+
'x-dead-letter-exchange': ''
|
|
1091
|
+
}
|
|
1092
|
+
}))
|
|
1093
|
+
|
|
1094
|
+
expect(chan.bindQueue).toHaveBeenCalledWith(RETRY, RETRY, '')
|
|
1095
|
+
})
|
|
1096
|
+
|
|
1097
|
+
it('should not set a dead letter routing key', async () => {
|
|
1098
|
+
// the message keeps its own, which names the queue it came from;
|
|
1099
|
+
// a fixed one would make the queue serve a single source
|
|
1100
|
+
await channel.consume(queue, consumer)
|
|
1101
|
+
|
|
1102
|
+
const [, options] = chan.assertQueue.mock.calls.find(([name]) => name === RETRY)
|
|
1103
|
+
|
|
1104
|
+
expect(options.arguments['x-dead-letter-routing-key']).toBeUndefined()
|
|
1105
|
+
})
|
|
1106
|
+
|
|
1107
|
+
it('should assert the parked queue', async () => {
|
|
1108
|
+
await channel.consume(queue, consumer)
|
|
1109
|
+
|
|
1110
|
+
expect(chan.assertQueue).toHaveBeenCalledWith('comq.parked.' + queue,
|
|
1111
|
+
expect.objectContaining({ durable: true }))
|
|
1112
|
+
})
|
|
1113
|
+
|
|
1114
|
+
it('should assert the source queue first', async () => {
|
|
1115
|
+
await channel.consume(queue, consumer)
|
|
1116
|
+
|
|
1117
|
+
expect(chan.assertQueue.mock.calls[0][0]).toStrictEqual(queue)
|
|
1118
|
+
})
|
|
1119
|
+
|
|
1120
|
+
it('should assert the retry topology once per channel', async () => {
|
|
1121
|
+
await channel.consume(generate(), consumer)
|
|
1122
|
+
await channel.consume(generate(), consumer)
|
|
1123
|
+
await channel.consume(generate(), consumer)
|
|
1124
|
+
|
|
1125
|
+
const exchanges = chan.assertExchange.mock.calls.filter(([name]) => name === RETRY)
|
|
1126
|
+
const queues = chan.assertQueue.mock.calls.filter(([name]) => name === RETRY)
|
|
1127
|
+
|
|
1128
|
+
expect(exchanges).toHaveLength(1)
|
|
1129
|
+
expect(queues).toHaveLength(1)
|
|
1130
|
+
})
|
|
1131
|
+
|
|
1132
|
+
it('should assert a parked queue per consumed queue', async () => {
|
|
1133
|
+
const one = generate()
|
|
1134
|
+
const another = generate()
|
|
1135
|
+
|
|
1136
|
+
await channel.consume(one, consumer)
|
|
1137
|
+
await channel.consume(another, consumer)
|
|
1138
|
+
|
|
1139
|
+
expect(chan.assertQueue).toHaveBeenCalledWith('comq.parked.' + one, expect.anything())
|
|
1140
|
+
expect(chan.assertQueue).toHaveBeenCalledWith('comq.parked.' + another, expect.anything())
|
|
1141
|
+
})
|
|
1142
|
+
|
|
1143
|
+
it('should not assert either without acknowledgments', async () => {
|
|
1144
|
+
jest.clearAllMocks()
|
|
1145
|
+
|
|
1146
|
+
topology.acknowledgments = false
|
|
1147
|
+
channel = await create(connection, topology)
|
|
1148
|
+
chan = await getCreatedChannel()
|
|
1149
|
+
|
|
1150
|
+
await channel.consume(queue, consumer)
|
|
1151
|
+
|
|
1152
|
+
const internal = chan.assertQueue.mock.calls.filter(([name]) => name.startsWith('comq.'))
|
|
1153
|
+
|
|
1154
|
+
expect(internal).toHaveLength(0)
|
|
1155
|
+
expect(chan.assertExchange).not.toHaveBeenCalled()
|
|
1156
|
+
})
|
|
1157
|
+
|
|
1158
|
+
it('should declare the parked queue exclusive for an exclusive queue', async () => {
|
|
1159
|
+
jest.clearAllMocks()
|
|
1160
|
+
|
|
1161
|
+
topology.durable = false
|
|
1162
|
+
channel = await create(connection, topology)
|
|
1163
|
+
chan = await getCreatedChannel()
|
|
1164
|
+
|
|
1165
|
+
await channel.consume(queue, consumer)
|
|
1166
|
+
|
|
1167
|
+
const [, options] = chan.assertQueue.mock.calls
|
|
1168
|
+
.find(([name]) => name === 'comq.parked.' + queue)
|
|
1169
|
+
|
|
1170
|
+
expect(options).toMatchObject({ exclusive: true })
|
|
1171
|
+
})
|
|
1172
|
+
|
|
1173
|
+
it('should re-assert after recovery', async () => {
|
|
1174
|
+
await channel.consume(queue, consumer)
|
|
1175
|
+
|
|
1176
|
+
const replacement = await amqplib.connect()
|
|
1177
|
+
|
|
1178
|
+
await channel.recover(replacement)
|
|
1179
|
+
|
|
1180
|
+
const repl = await getCreatedChannel(replacement)
|
|
1181
|
+
|
|
1182
|
+
expect(repl.assertExchange).toHaveBeenCalledWith(RETRY, 'fanout', expect.anything())
|
|
1183
|
+
expect(repl.assertQueue).toHaveBeenCalledWith('comq.parked.' + queue, expect.anything())
|
|
1184
|
+
})
|
|
1185
|
+
})
|
|
1186
|
+
|
|
1187
|
+
describe('retry', () => {
|
|
1188
|
+
it('should publish to the retry exchange keyed by the source queue', async () => {
|
|
1189
|
+
await channel.consume(queue, consumer)
|
|
1190
|
+
|
|
1191
|
+
const message = delivery()
|
|
1192
|
+
|
|
1193
|
+
await deliver(message)
|
|
1194
|
+
|
|
1195
|
+
const [exchange, key] = publications()[0]
|
|
1196
|
+
|
|
1197
|
+
expect(exchange).toStrictEqual(RETRY)
|
|
1198
|
+
expect(key).toStrictEqual(queue)
|
|
1199
|
+
})
|
|
1200
|
+
|
|
1201
|
+
it('should not republish to the exchange the message came from', async () => {
|
|
1202
|
+
await channel.consume(queue, consumer)
|
|
1203
|
+
|
|
1204
|
+
const message = delivery()
|
|
1205
|
+
|
|
1206
|
+
await deliver(message)
|
|
1207
|
+
|
|
1208
|
+
const [exchange] = publications()[0]
|
|
1209
|
+
|
|
1210
|
+
// a fanout source exchange would otherwise redeliver the retry to every subscriber
|
|
1211
|
+
expect(exchange).not.toStrictEqual(message.fields.exchange)
|
|
1212
|
+
})
|
|
1213
|
+
|
|
1214
|
+
it('should publish before acknowledging', async () => {
|
|
1215
|
+
await channel.consume(queue, consumer)
|
|
1216
|
+
|
|
1217
|
+
await deliver(delivery())
|
|
1218
|
+
|
|
1219
|
+
expect(chan.publish.mock.invocationCallOrder[0])
|
|
1220
|
+
.toBeLessThan(chan.ack.mock.invocationCallOrder[0])
|
|
1221
|
+
})
|
|
1222
|
+
|
|
1223
|
+
it('should wait for the confirmation before acknowledging', async () => {
|
|
1224
|
+
await channel.consume(queue, consumer)
|
|
1225
|
+
|
|
1226
|
+
let confirm
|
|
1227
|
+
|
|
1228
|
+
chan.publish.mockImplementationOnce((_0, _1, _2, _3, callback) => { confirm = callback })
|
|
1229
|
+
|
|
1230
|
+
const pending = deliver(delivery())
|
|
1231
|
+
|
|
1232
|
+
await immediate()
|
|
1233
|
+
|
|
1234
|
+
expect(chan.ack).not.toHaveBeenCalled()
|
|
1235
|
+
|
|
1236
|
+
confirm(null)
|
|
1237
|
+
await pending
|
|
1238
|
+
|
|
1239
|
+
expect(chan.ack).toHaveBeenCalled()
|
|
1240
|
+
})
|
|
1241
|
+
|
|
1242
|
+
it('should increment the attempt', async () => {
|
|
1243
|
+
await channel.consume(queue, consumer)
|
|
1244
|
+
|
|
1245
|
+
await deliver(delivery({ headers: { 'x-comq-attempt': 2 } }))
|
|
1246
|
+
|
|
1247
|
+
const [, , , options] = publications()[0]
|
|
1248
|
+
|
|
1249
|
+
expect(options.headers['x-comq-attempt']).toStrictEqual(3)
|
|
1250
|
+
})
|
|
1251
|
+
|
|
1252
|
+
it('should tolerate a message without headers', async () => {
|
|
1253
|
+
// a message published by something that is not comq carries no field table,
|
|
1254
|
+
// and the first delivery of any message carries no attempt either
|
|
1255
|
+
await channel.consume(queue, consumer)
|
|
1256
|
+
|
|
1257
|
+
await expect(deliver(delivery({}))).resolves.not.toThrow()
|
|
1258
|
+
|
|
1259
|
+
const [, , , options] = publications()[0]
|
|
1260
|
+
|
|
1261
|
+
expect(options.headers['x-comq-attempt']).toStrictEqual(2)
|
|
1262
|
+
})
|
|
1263
|
+
|
|
1264
|
+
it('should record the origin on the first failure', async () => {
|
|
1265
|
+
await channel.consume(queue, consumer)
|
|
1266
|
+
|
|
1267
|
+
const message = delivery()
|
|
1268
|
+
|
|
1269
|
+
await deliver(message)
|
|
1270
|
+
|
|
1271
|
+
const [, , , options] = publications()[0]
|
|
1272
|
+
|
|
1273
|
+
expect(options.headers['x-comq-exchange']).toStrictEqual(message.fields.exchange)
|
|
1274
|
+
expect(options.headers['x-comq-key']).toStrictEqual(message.fields.routingKey)
|
|
1275
|
+
})
|
|
1276
|
+
|
|
1277
|
+
it('should keep the recorded origin on later failures', async () => {
|
|
1278
|
+
// a returned retry arrives through the default exchange, so its own fields
|
|
1279
|
+
// no longer say where it was published
|
|
1280
|
+
await channel.consume(queue, consumer)
|
|
1281
|
+
|
|
1282
|
+
const origin = generate()
|
|
1283
|
+
const message = delivery(
|
|
1284
|
+
{ headers: { 'x-comq-attempt': 1, 'x-comq-exchange': origin, 'x-comq-key': generate() } },
|
|
1285
|
+
{ exchange: '' })
|
|
1286
|
+
|
|
1287
|
+
await deliver(message)
|
|
1288
|
+
|
|
1289
|
+
const [, , , options] = publications()[0]
|
|
1290
|
+
|
|
1291
|
+
expect(options.headers['x-comq-exchange']).toStrictEqual(origin)
|
|
1292
|
+
})
|
|
1293
|
+
|
|
1294
|
+
it('should publish persistent from a transient topology', async () => {
|
|
1295
|
+
await channel.consume(queue, consumer)
|
|
1296
|
+
|
|
1297
|
+
await deliver(delivery())
|
|
1298
|
+
|
|
1299
|
+
const [, , , options] = publications()[0]
|
|
1300
|
+
|
|
1301
|
+
expect(options.persistent).toStrictEqual(true)
|
|
1302
|
+
})
|
|
1303
|
+
|
|
1304
|
+
it('should not mutate the message', async () => {
|
|
1305
|
+
await channel.consume(queue, consumer)
|
|
1306
|
+
|
|
1307
|
+
const message = delivery({})
|
|
1308
|
+
|
|
1309
|
+
await deliver(message)
|
|
1310
|
+
|
|
1311
|
+
expect(message.properties.headers).toBeUndefined()
|
|
1312
|
+
})
|
|
1313
|
+
|
|
1314
|
+
it('should emit the `retry` event', async () => {
|
|
1315
|
+
const listener = jest.fn()
|
|
1316
|
+
|
|
1317
|
+
channel.diagnose('retry', listener)
|
|
1318
|
+
|
|
1319
|
+
await channel.consume(queue, consumer)
|
|
1320
|
+
|
|
1321
|
+
const message = delivery()
|
|
1322
|
+
|
|
1323
|
+
await deliver(message)
|
|
1324
|
+
|
|
1325
|
+
expect(listener).toHaveBeenCalledWith(message, exception, 1)
|
|
1326
|
+
})
|
|
1327
|
+
|
|
1328
|
+
it('should not throw', async () => {
|
|
1329
|
+
// amqplib drops the promise it gets back, so a rejection ends the process
|
|
1330
|
+
await channel.consume(queue, consumer)
|
|
1331
|
+
|
|
1332
|
+
await expect(deliver(delivery())).resolves.not.toThrow()
|
|
1333
|
+
})
|
|
1334
|
+
|
|
1335
|
+
it('should not seal the channel', async () => {
|
|
1336
|
+
await channel.consume(queue, consumer)
|
|
1337
|
+
|
|
1338
|
+
await deliver(delivery())
|
|
1339
|
+
|
|
1340
|
+
expect(chan.cancel).not.toHaveBeenCalled()
|
|
1341
|
+
})
|
|
1342
|
+
|
|
1343
|
+
it('should keep consuming', async () => {
|
|
1344
|
+
const another = generate()
|
|
1345
|
+
|
|
1346
|
+
await channel.consume(queue, consumer)
|
|
1347
|
+
await deliver(delivery())
|
|
1348
|
+
|
|
1349
|
+
await expect(channel.consume(another, jest.fn())).resolves.not.toThrow()
|
|
1350
|
+
|
|
1351
|
+
expect(chan.consume).toHaveBeenCalledWith(another, expect.any(Function), expect.anything())
|
|
1352
|
+
})
|
|
1353
|
+
|
|
1354
|
+
it('should ignore an exception thrown after the channel closed', async () => {
|
|
1355
|
+
await channel.consume(queue, consumer)
|
|
1356
|
+
|
|
1357
|
+
consumer.mockImplementationOnce(async () => { throw new Error('Channel closed') })
|
|
1358
|
+
|
|
1359
|
+
await expect(deliver(delivery())).resolves.not.toThrow()
|
|
1360
|
+
|
|
1361
|
+
expect(chan.publish).not.toHaveBeenCalled()
|
|
1362
|
+
expect(chan.ack).not.toHaveBeenCalled()
|
|
1363
|
+
expect(chan.nack).not.toHaveBeenCalled()
|
|
1364
|
+
})
|
|
1365
|
+
|
|
1366
|
+
it('should tolerate a rejection that is not an Error', async () => {
|
|
1367
|
+
await channel.consume(queue, consumer)
|
|
1368
|
+
|
|
1369
|
+
consumer.mockImplementationOnce(async () => { throw undefined }) // eslint-disable-line
|
|
1370
|
+
|
|
1371
|
+
await expect(deliver(delivery())).resolves.not.toThrow()
|
|
1372
|
+
|
|
1373
|
+
expect(chan.publish).toHaveBeenCalled()
|
|
1374
|
+
})
|
|
1375
|
+
})
|
|
1376
|
+
|
|
1377
|
+
describe('the healthy path', () => {
|
|
1378
|
+
it('should not make ordinary publishing persistent', async () => {
|
|
1379
|
+
// only a message that has already failed is worth a disk write; Requests are
|
|
1380
|
+
// transient for the sake of latency and must stay that way
|
|
1381
|
+
jest.clearAllMocks()
|
|
1382
|
+
|
|
1383
|
+
topology.persistent = false
|
|
1384
|
+
channel = await create(connection, topology)
|
|
1385
|
+
chan = await getCreatedChannel()
|
|
1386
|
+
|
|
1387
|
+
await channel.send(generate(), randomBytes(8))
|
|
1388
|
+
await channel.publish(generate(), randomBytes(8))
|
|
1389
|
+
|
|
1390
|
+
for (const [, , , options] of chan.publish.mock.calls) {
|
|
1391
|
+
expect(options.persistent).toStrictEqual(false)
|
|
1392
|
+
}
|
|
1393
|
+
})
|
|
1394
|
+
})
|
|
1395
|
+
|
|
1396
|
+
describe('backoff', () => {
|
|
1397
|
+
const LADDER = [1000, 5000, 25000]
|
|
1398
|
+
|
|
1399
|
+
/** @returns {string[]} the retry queues asserted, in order */
|
|
1400
|
+
const asserted = () => chan.assertQueue.mock.calls
|
|
1401
|
+
.map(([name]) => name)
|
|
1402
|
+
.filter((name) => name.startsWith('comq.retry.'))
|
|
1403
|
+
|
|
1404
|
+
beforeEach(async () => {
|
|
1405
|
+
jest.clearAllMocks()
|
|
1406
|
+
|
|
1407
|
+
topology.delay = LADDER
|
|
1408
|
+
|
|
1409
|
+
channel = await create(connection, topology)
|
|
1410
|
+
chan = await getCreatedChannel()
|
|
1411
|
+
})
|
|
1412
|
+
|
|
1413
|
+
it('should declare a queue and an exchange per rung', async () => {
|
|
1414
|
+
await channel.consume(queue, consumer)
|
|
1415
|
+
|
|
1416
|
+
expect(asserted()).toStrictEqual(LADDER.map((delay) => 'comq.retry.' + delay))
|
|
1417
|
+
|
|
1418
|
+
for (const delay of LADDER) {
|
|
1419
|
+
const name = 'comq.retry.' + delay
|
|
1420
|
+
|
|
1421
|
+
expect(chan.assertExchange).toHaveBeenCalledWith(name, 'fanout', expect.anything())
|
|
1422
|
+
expect(chan.bindQueue).toHaveBeenCalledWith(name, name, '')
|
|
1423
|
+
|
|
1424
|
+
const [, options] = chan.assertQueue.mock.calls.find(([asserted]) => asserted === name)
|
|
1425
|
+
|
|
1426
|
+
expect(options.arguments['x-message-ttl']).toStrictEqual(delay)
|
|
1427
|
+
}
|
|
1428
|
+
})
|
|
1429
|
+
|
|
1430
|
+
it('should declare a repeated rung once', async () => {
|
|
1431
|
+
jest.clearAllMocks()
|
|
1432
|
+
|
|
1433
|
+
topology.delay = [1000, 5000, 1000]
|
|
1434
|
+
channel = await create(connection, topology)
|
|
1435
|
+
chan = await getCreatedChannel()
|
|
1436
|
+
|
|
1437
|
+
await channel.consume(queue, consumer)
|
|
1438
|
+
|
|
1439
|
+
expect(asserted()).toStrictEqual(['comq.retry.1000', 'comq.retry.5000'])
|
|
1440
|
+
})
|
|
1441
|
+
|
|
1442
|
+
it('should climb the ladder', async () => {
|
|
1443
|
+
await channel.consume(queue, consumer)
|
|
1444
|
+
|
|
1445
|
+
await deliver(delivery({}))
|
|
1446
|
+
await deliver(delivery({ headers: { 'x-comq-attempt': 2 } }))
|
|
1447
|
+
await deliver(delivery({ headers: { 'x-comq-attempt': 3 } }))
|
|
1448
|
+
await deliver(delivery({ headers: { 'x-comq-attempt': 4 } }))
|
|
1449
|
+
|
|
1450
|
+
expect(publications().map(([exchange]) => exchange)).toStrictEqual([
|
|
1451
|
+
'comq.retry.1000',
|
|
1452
|
+
'comq.retry.5000',
|
|
1453
|
+
'comq.retry.25000',
|
|
1454
|
+
'' // parked
|
|
1455
|
+
])
|
|
1456
|
+
})
|
|
1457
|
+
|
|
1458
|
+
it('should take a number as a ladder of one', async () => {
|
|
1459
|
+
jest.clearAllMocks()
|
|
1460
|
+
|
|
1461
|
+
topology.delay = 1000
|
|
1462
|
+
|
|
1463
|
+
channel = await create(connection, topology)
|
|
1464
|
+
chan = await getCreatedChannel()
|
|
1465
|
+
|
|
1466
|
+
await channel.consume(queue, consumer)
|
|
1467
|
+
|
|
1468
|
+
await deliver(delivery({}))
|
|
1469
|
+
await deliver(delivery({ headers: { 'x-comq-attempt': 2 } }))
|
|
1470
|
+
|
|
1471
|
+
expect(asserted()).toStrictEqual(['comq.retry.1000'])
|
|
1472
|
+
expect(publications().map(([exchange, key]) => exchange || key))
|
|
1473
|
+
.toStrictEqual(['comq.retry.1000', 'comq.parked.' + queue])
|
|
1474
|
+
})
|
|
1475
|
+
})
|
|
1476
|
+
|
|
1477
|
+
describe('parking', () => {
|
|
1478
|
+
const exhausted = () => delivery({ headers: { 'x-comq-attempt': 5 } })
|
|
1479
|
+
|
|
1480
|
+
it('should publish to the parked queue once the attempts are spent', async () => {
|
|
1481
|
+
await channel.consume(queue, consumer)
|
|
1482
|
+
|
|
1483
|
+
await deliver(exhausted())
|
|
1484
|
+
|
|
1485
|
+
const [exchange, key] = publications()[0]
|
|
1486
|
+
|
|
1487
|
+
expect(exchange).toStrictEqual('')
|
|
1488
|
+
expect(key).toStrictEqual('comq.parked.' + queue)
|
|
1489
|
+
})
|
|
1490
|
+
|
|
1491
|
+
it('should not retry once the attempts are spent', async () => {
|
|
1492
|
+
await channel.consume(queue, consumer)
|
|
1493
|
+
|
|
1494
|
+
await deliver(exhausted())
|
|
1495
|
+
|
|
1496
|
+
expect(publications()).toHaveLength(1)
|
|
1497
|
+
expect(chan.nack).not.toHaveBeenCalled()
|
|
1498
|
+
})
|
|
1499
|
+
|
|
1500
|
+
it('should publish before acknowledging', async () => {
|
|
1501
|
+
await channel.consume(queue, consumer)
|
|
1502
|
+
|
|
1503
|
+
await deliver(exhausted())
|
|
1504
|
+
|
|
1505
|
+
expect(chan.publish.mock.invocationCallOrder[0])
|
|
1506
|
+
.toBeLessThan(chan.ack.mock.invocationCallOrder[0])
|
|
1507
|
+
})
|
|
1508
|
+
|
|
1509
|
+
it('should record where the message came from', async () => {
|
|
1510
|
+
await channel.consume(queue, consumer)
|
|
1511
|
+
|
|
1512
|
+
const origin = generate()
|
|
1513
|
+
const message = delivery({ headers: { 'x-comq-attempt': 5, 'x-comq-exchange': origin } })
|
|
1514
|
+
|
|
1515
|
+
await deliver(message)
|
|
1516
|
+
|
|
1517
|
+
const [, , , options] = publications()[0]
|
|
1518
|
+
|
|
1519
|
+
expect(options.headers['x-comq-exchange']).toStrictEqual(origin)
|
|
1520
|
+
expect(options.headers['x-comq-queue']).toStrictEqual(queue)
|
|
1521
|
+
expect(options.headers['x-comq-reason']).toStrictEqual(exception.message)
|
|
1522
|
+
expect(options.headers['x-comq-at']).toStrictEqual(expect.any(Number))
|
|
1523
|
+
})
|
|
1524
|
+
|
|
1525
|
+
it('should keep replyTo and correlationId', async () => {
|
|
1526
|
+
// a parked Request remains answerable while its caller is still waiting
|
|
1527
|
+
await channel.consume(queue, consumer)
|
|
1528
|
+
|
|
1529
|
+
const replyTo = generate()
|
|
1530
|
+
const correlationId = generate()
|
|
1531
|
+
|
|
1532
|
+
await deliver(delivery({ headers: { 'x-comq-attempt': 5 }, replyTo, correlationId }))
|
|
1533
|
+
|
|
1534
|
+
const [, , , options] = publications()[0]
|
|
1535
|
+
|
|
1536
|
+
expect(options.replyTo).toStrictEqual(replyTo)
|
|
1537
|
+
expect(options.correlationId).toStrictEqual(correlationId)
|
|
1538
|
+
})
|
|
1539
|
+
|
|
1540
|
+
it('should publish persistent from a transient topology', async () => {
|
|
1541
|
+
await channel.consume(queue, consumer)
|
|
1542
|
+
|
|
1543
|
+
await deliver(exhausted())
|
|
1544
|
+
|
|
1545
|
+
const [, , , options] = publications()[0]
|
|
1546
|
+
|
|
1547
|
+
expect(options.persistent).toStrictEqual(true)
|
|
1548
|
+
})
|
|
1549
|
+
|
|
1550
|
+
it('should count the first delivery as an attempt', async () => {
|
|
1551
|
+
// a ladder of one rung is a single retry, so the second delivery is the last
|
|
1552
|
+
jest.clearAllMocks()
|
|
1553
|
+
|
|
1554
|
+
topology.delay = 1000
|
|
1555
|
+
channel = await create(connection, topology)
|
|
1556
|
+
chan = await getCreatedChannel()
|
|
1557
|
+
|
|
1558
|
+
await channel.consume(queue, consumer)
|
|
1559
|
+
|
|
1560
|
+
await deliver(delivery({}))
|
|
1561
|
+
await deliver(delivery({ headers: { 'x-comq-attempt': 2 } }))
|
|
1562
|
+
|
|
1563
|
+
const targets = publications().map(([, key]) => key)
|
|
1564
|
+
|
|
1565
|
+
expect(targets).toStrictEqual([queue, 'comq.parked.' + queue])
|
|
1566
|
+
})
|
|
1567
|
+
|
|
1568
|
+
it('should give a message one delivery more than the ladder has rungs', async () => {
|
|
1569
|
+
jest.clearAllMocks()
|
|
1570
|
+
|
|
1571
|
+
topology.delay = [1000, 2000]
|
|
1572
|
+
channel = await create(connection, topology)
|
|
1573
|
+
chan = await getCreatedChannel()
|
|
1574
|
+
|
|
1575
|
+
await channel.consume(queue, consumer)
|
|
1576
|
+
|
|
1577
|
+
await deliver(delivery({}))
|
|
1578
|
+
await deliver(delivery({ headers: { 'x-comq-attempt': 2 } }))
|
|
1579
|
+
await deliver(delivery({ headers: { 'x-comq-attempt': 3 } }))
|
|
1580
|
+
|
|
1581
|
+
const targets = publications().map(([, key]) => key)
|
|
1582
|
+
|
|
1583
|
+
expect(targets).toStrictEqual([queue, queue, 'comq.parked.' + queue])
|
|
1584
|
+
})
|
|
1585
|
+
})
|
|
1586
|
+
|
|
1587
|
+
describe('when the message cannot be moved', () => {
|
|
1588
|
+
it('should give the delivery back', async () => {
|
|
1589
|
+
await channel.consume(queue, consumer)
|
|
1590
|
+
|
|
1591
|
+
chan.publish.mockImplementation(() => { throw new Error(generate()) })
|
|
1592
|
+
|
|
1593
|
+
await expect(deliver(delivery())).resolves.not.toThrow()
|
|
1594
|
+
|
|
1595
|
+
expect(chan.nack).toHaveBeenCalledWith(expect.anything(), false, true)
|
|
1596
|
+
expect(chan.ack).not.toHaveBeenCalled()
|
|
1597
|
+
})
|
|
1598
|
+
|
|
1599
|
+
it('should give it back when the confirmation is rejected', async () => {
|
|
1600
|
+
await channel.consume(queue, consumer)
|
|
1601
|
+
|
|
1602
|
+
chan.publish.mockImplementationOnce((_0, _1, _2, _3, callback) => callback(new Error(generate())))
|
|
1603
|
+
|
|
1604
|
+
await expect(deliver(delivery())).resolves.not.toThrow()
|
|
1605
|
+
|
|
1606
|
+
expect(chan.nack).toHaveBeenCalledWith(expect.anything(), false, true)
|
|
1607
|
+
expect(chan.ack).not.toHaveBeenCalled()
|
|
1608
|
+
})
|
|
1609
|
+
|
|
1610
|
+
it('should not throw when giving it back also fails', async () => {
|
|
1611
|
+
await channel.consume(queue, consumer)
|
|
1612
|
+
|
|
1613
|
+
chan.publish.mockImplementation(() => { throw new Error(generate()) })
|
|
1614
|
+
chan.nack.mockImplementation(() => { throw new Error('Channel closed') })
|
|
1615
|
+
|
|
1616
|
+
await expect(deliver(delivery())).resolves.not.toThrow()
|
|
1617
|
+
})
|
|
1618
|
+
})
|
|
1619
|
+
})
|
package/test/connect.test.js
CHANGED
|
@@ -40,7 +40,7 @@ describe('single connection', () => {
|
|
|
40
40
|
})
|
|
41
41
|
|
|
42
42
|
it('should pass active connection', async () => {
|
|
43
|
-
expect(Connection).toHaveBeenCalledWith(url)
|
|
43
|
+
expect(Connection).toHaveBeenCalledWith(url, {})
|
|
44
44
|
|
|
45
45
|
/** @type {jest.MockedObject<comq.Connection>} */
|
|
46
46
|
const instance = Connection.mock.instances[0]
|
|
@@ -62,10 +62,39 @@ describe('sharded connection', () => {
|
|
|
62
62
|
})
|
|
63
63
|
|
|
64
64
|
it('should pass single connection instances', async () => {
|
|
65
|
-
urls.forEach((url) => expect(Connection).toHaveBeenCalledWith(url))
|
|
65
|
+
urls.forEach((url) => expect(Connection).toHaveBeenCalledWith(url, {}))
|
|
66
66
|
|
|
67
67
|
const instances = Connection.mock.instances
|
|
68
68
|
|
|
69
69
|
expect(shards.Connection).toHaveBeenCalledWith(instances)
|
|
70
70
|
})
|
|
71
71
|
})
|
|
72
|
+
|
|
73
|
+
describe('topology overrides', () => {
|
|
74
|
+
it('should pass the trailing argument to the connection', async () => {
|
|
75
|
+
const overrides = { event: { delay: 100 } }
|
|
76
|
+
|
|
77
|
+
await connect(url, overrides)
|
|
78
|
+
|
|
79
|
+
expect(Connection).toHaveBeenCalledWith(url, overrides)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('should pass them to every shard', async () => {
|
|
83
|
+
const urls = [generate(), generate()]
|
|
84
|
+
const overrides = { request: { attempts: 1 } }
|
|
85
|
+
|
|
86
|
+
await connect(...urls, overrides)
|
|
87
|
+
|
|
88
|
+
urls.forEach((url) => expect(Connection).toHaveBeenCalledWith(url, overrides))
|
|
89
|
+
expect(shards.Connection).toHaveBeenCalled()
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('should not take a url as overrides', async () => {
|
|
93
|
+
const urls = [generate(), generate()]
|
|
94
|
+
|
|
95
|
+
await connect(...urls)
|
|
96
|
+
|
|
97
|
+
expect(Connection).toHaveBeenCalledTimes(2)
|
|
98
|
+
urls.forEach((url) => expect(Connection).toHaveBeenCalledWith(url, {}))
|
|
99
|
+
})
|
|
100
|
+
})
|
package/test/helpers.js
CHANGED
|
@@ -4,6 +4,20 @@ const timeout = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
|
4
4
|
|
|
5
5
|
const immediate = () => new Promise((resolve) => setImmediate(resolve))
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* Waits for a condition rather than for a duration.
|
|
9
|
+
*
|
|
10
|
+
* @param {() => boolean} condition
|
|
11
|
+
* @param {number} [ms] how long to keep asking
|
|
12
|
+
*/
|
|
13
|
+
const until = async (condition, ms = 5000) => {
|
|
14
|
+
const deadline = Date.now() + ms
|
|
15
|
+
|
|
16
|
+
while (!condition() && Date.now() < deadline) await timeout(20)
|
|
17
|
+
|
|
18
|
+
return condition()
|
|
19
|
+
}
|
|
20
|
+
|
|
7
21
|
const random = (max = 100) => Math.floor(Math.random() * max)
|
|
8
22
|
|
|
9
23
|
const flip = () => Math.random() < 0.5
|
|
@@ -70,6 +84,7 @@ const MULTIPLIERS = Object.entries({
|
|
|
70
84
|
|
|
71
85
|
exports.timeout = timeout
|
|
72
86
|
exports.immediate = immediate
|
|
87
|
+
exports.until = until
|
|
73
88
|
exports.random = random
|
|
74
89
|
exports.flip = flip
|
|
75
90
|
exports.sample = sample
|