queen-mq 1.2.0 → 2.0.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.
Files changed (52) hide show
  1. package/README.md +71 -18
  2. package/client-v2/Queen.js +45 -13
  3. package/client-v2/README.md +26 -11
  4. package/client-v2/admin/Admin.js +0 -47
  5. package/client-v2/builders/QueueBuilder.js +33 -10
  6. package/client-v2/builders/TimerBuilder.js +2 -2
  7. package/client-v2/builders/TransactionBuilder.js +35 -9
  8. package/client-v2/consumer/ConsumerManager.js +67 -46
  9. package/client-v2/ephemeral/Ephemeral.js +7 -9
  10. package/client-v2/kv/Kv.js +4 -4
  11. package/client-v2/kv/expiry.js +1 -1
  12. package/client-v2/streams/Stream.js +8 -3
  13. package/client-v2/streams/helpers/rateLimiter.js +4 -4
  14. package/client-v2/streams/operators/GateOperator.js +9 -2
  15. package/client-v2/streams/operators/ReduceOperator.js +2 -2
  16. package/client-v2/streams/operators/WindowSessionOperator.js +3 -3
  17. package/client-v2/streams/runtime/Runner.js +111 -54
  18. package/client-v2/streams/runtime/cycle.js +4 -4
  19. package/client-v2/streams/runtime/register.js +1 -1
  20. package/client-v2/utils/conflation.js +0 -6
  21. package/client-v2/utils/consumerGroup.js +54 -0
  22. package/package.json +5 -8
  23. package/test-v2/_kvtimers.js +12 -13
  24. package/test-v2/ackwindow.js +12 -192
  25. package/test-v2/bootstrap.js +2 -2
  26. package/test-v2/conflation-unit/conflationWire.test.js +0 -12
  27. package/test-v2/consume.js +41 -1
  28. package/test-v2/consumer-unit/handlerError.test.js +161 -0
  29. package/test-v2/docs.js +5 -4
  30. package/test-v2/http-unit/pushStatus.test.js +142 -0
  31. package/test-v2/http-unit/renew.test.js +96 -0
  32. package/test-v2/kv-unit/timerWire.test.js +1 -1
  33. package/test-v2/kv-unit/txnWire.test.js +101 -2
  34. package/test-v2/kv.js +6 -5
  35. package/test-v2/pop.js +28 -1
  36. package/test-v2/retention.js +28 -7
  37. package/test-v2/run.js +50 -114
  38. package/test-v2/runner-unit/fatalExit.test.js +64 -0
  39. package/test-v2/semantics.js +20 -34
  40. package/test-v2/stream/_helpers.js +10 -19
  41. package/test-v2/stream/cron.js +1 -1
  42. package/test-v2/stream/gate.js +54 -0
  43. package/test-v2/stream/index.js +2 -0
  44. package/test-v2/stream/tumbling.js +13 -9
  45. package/test-v2/streams-unit/ack.test.js +61 -0
  46. package/test-v2/streams-unit/cycle.test.js +1 -1
  47. package/test-v2/streams-unit/e2e.test.js +6 -10
  48. package/test-v2/streams-unit/gate.test.js +189 -0
  49. package/test-v2/timers.js +6 -5
  50. package/test-v2/transaction.js +169 -0
  51. package/test-v2/watermark.js +38 -176
  52. package/test-v2/maintenance.js +0 -277
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Push per-item statuses.
3
+ *
4
+ * POST /api/v1/push answers 201 with one result per item, in input order, and
5
+ * a refused item fails INSIDE a successful request, so the per-item status is
6
+ * the only signal that a message was not stored. The statuses brokers send:
7
+ *
8
+ * queued stored (every broker)
9
+ * duplicate its transactionId is already stored (every broker)
10
+ * error refused, no per-item message (2.x)
11
+ * failed refused, with `error` (1.x)
12
+ * buffered written to the broker's failover file, replayed (1.x)
13
+ *
14
+ * The client used to look for `failed` only, so a 2.x refusal (`error`) was
15
+ * neither reported nor thrown: push() resolved as if the message were stored.
16
+ * Found 2026-10-02 against 2.0.0-beta.6, which answers `error` when a
17
+ * partition's planned append exceeds QUEEN_RAFT_ENTRY_MAX_BYTES.
18
+ */
19
+
20
+ import { describe, it } from 'node:test'
21
+ import assert from 'node:assert/strict'
22
+
23
+ import { Queen } from '../../client-v2/index.js'
24
+ import { withPlanServer } from '../kv-unit/_planServer.js'
25
+
26
+ const QUEUE = 'test-push-status'
27
+
28
+ const item = (index, status, extra = {}) => ({
29
+ index,
30
+ message_id: `0199aaaa-0000-7000-8000-00000000000${index}`,
31
+ transaction_id: `tx-${index}`,
32
+ queueName: QUEUE,
33
+ status,
34
+ ...extra
35
+ })
36
+
37
+ // The exact shape 2.0.0-beta.6 answered for an item it refused (no offset,
38
+ // no error text).
39
+ const created = (results) => ({ status: 201, body: results })
40
+
41
+ async function withQueen(response, run) {
42
+ await withPlanServer([response], response, async (url, hits) => {
43
+ const queen = new Queen({ url, handleSignals: false, retryAttempts: 1 })
44
+ try {
45
+ await run(queen, hits)
46
+ } finally {
47
+ await queen.close()
48
+ }
49
+ })
50
+ }
51
+
52
+ describe('push — a refused item is reported, whatever the broker calls it', () => {
53
+ it('a 2.x `error` item makes push() reject, carrying the per-item results', async () => {
54
+ await withQueen(created([item(0, 'error')]), async (queen) => {
55
+ await assert.rejects(
56
+ async () => { await queen.queue(QUEUE).push([{ data: { n: 1 } }]) },
57
+ (err) => {
58
+ assert.match(err.message, /rejected by the broker/)
59
+ assert.match(err.message, /"error"/, 'the status the broker answered is named')
60
+ assert.deepEqual(err.results.map(r => r.status), ['error'])
61
+ return true
62
+ }
63
+ )
64
+ })
65
+ })
66
+
67
+ it('a 2.x `error` item goes to onError, and the stored ones still go to onSuccess', async () => {
68
+ await withQueen(created([item(0, 'queued', { offset: 0 }), item(1, 'error')]), async (queen) => {
69
+ let failedItems = null
70
+ let failure = null
71
+ let succeeded = null
72
+ const res = await queen.queue(QUEUE)
73
+ .push([{ data: { n: 1 } }, { data: { n: 2 } }])
74
+ .onError(async (items, err) => { failedItems = items; failure = err })
75
+ .onSuccess(async (items) => { succeeded = items })
76
+
77
+ assert.ok(Array.isArray(res), 'with onError the call resolves with the broker results')
78
+ assert.equal(failedItems.length, 1)
79
+ assert.deepEqual(failedItems[0].payload, { n: 2 })
80
+ assert.equal(failedItems[0].result.status, 'error')
81
+ assert.match(failure.message, /rejected by the broker/)
82
+ assert.equal(succeeded.length, 1)
83
+ assert.deepEqual(succeeded[0].payload, { n: 1 })
84
+ })
85
+ })
86
+
87
+ it('a 1.x `failed` item is reported with the broker\'s own text', async () => {
88
+ await withQueen(created([item(0, 'failed', { error: 'File buffer write failed' })]), async (queen) => {
89
+ await assert.rejects(
90
+ async () => { await queen.queue(QUEUE).push([{ data: { n: 1 } }]) },
91
+ (err) => { assert.equal(err.message, 'File buffer write failed'); return true }
92
+ )
93
+ })
94
+ })
95
+
96
+ it('a status this client does not know is a failure, not a silent success', async () => {
97
+ await withQueen(created([item(0, 'refused-by-something-new')]), async (queen) => {
98
+ await assert.rejects(
99
+ async () => { await queen.queue(QUEUE).push([{ data: { n: 1 } }]) },
100
+ (err) => { assert.match(err.message, /refused-by-something-new/); return true }
101
+ )
102
+ })
103
+ })
104
+
105
+ it('several refused items: one error for the call, counting them', async () => {
106
+ await withQueen(created([item(0, 'error'), item(1, 'queued', { offset: 0 }), item(2, 'error')]), async (queen) => {
107
+ await assert.rejects(
108
+ async () => { await queen.queue(QUEUE).push([{ data: 1 }, { data: 2 }, { data: 3 }]) },
109
+ (err) => { assert.match(err.message, /^2 of 3 pushed items rejected/); return true }
110
+ )
111
+ })
112
+ })
113
+ })
114
+
115
+ describe('push — accepted statuses stay accepted', () => {
116
+ it('`queued` resolves with the broker results and reaches onSuccess', async () => {
117
+ await withQueen(created([item(0, 'queued', { offset: 7 })]), async (queen) => {
118
+ let succeeded = null
119
+ const res = await queen.queue(QUEUE).push([{ data: { n: 1 } }]).onSuccess(async (items) => { succeeded = items })
120
+ assert.equal(res[0].status, 'queued')
121
+ assert.equal(succeeded.length, 1)
122
+ })
123
+ })
124
+
125
+ it('a 1.x `buffered` item is accepted: the broker owns its delivery', async () => {
126
+ await withQueen(created([item(0, 'buffered')]), async (queen) => {
127
+ let succeeded = null
128
+ const res = await queen.queue(QUEUE).push([{ data: { n: 1 } }]).onSuccess(async (items) => { succeeded = items })
129
+ assert.equal(res[0].status, 'buffered')
130
+ assert.equal(succeeded.length, 1)
131
+ })
132
+ })
133
+
134
+ it('`duplicate` goes to onDuplicate and is not a failure', async () => {
135
+ await withQueen(created([item(0, 'duplicate', { offset: 3 })]), async (queen) => {
136
+ let dups = null
137
+ const res = await queen.queue(QUEUE).push([{ data: { n: 1 } }]).onDuplicate(async (items) => { dups = items })
138
+ assert.equal(res[0].status, 'duplicate')
139
+ assert.equal(dups.length, 1)
140
+ })
141
+ })
142
+ })
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Queen.renew() outcome.
3
+ *
4
+ * POST /api/v1/lease/:leaseId/extend answers HTTP 200 whether or not anything
5
+ * was renewed. `success` in the body is the only signal, and the client used
6
+ * to ignore it: renewing a lease that had expired, or that an ack had already
7
+ * released, reported `success: true` with `newExpiresAt: null` (found
8
+ * 2026-10-02 against 2.0.0-beta.6). The bodies below are what brokers send:
9
+ *
10
+ * 1.x and 2.x: {leaseId, success, renewed, newExpiresAt, expiresAt, lease_expires_at}
11
+ * C++: [{index, leaseId, success, error, expiresAt}]
12
+ */
13
+
14
+ import { describe, it } from 'node:test'
15
+ import assert from 'node:assert/strict'
16
+
17
+ import { Queen } from '../../client-v2/index.js'
18
+ import { withPlanServer } from '../kv-unit/_planServer.js'
19
+
20
+ const LEASE = '01a0fcf2-1a28-7001-8810-d74deda1fd42'
21
+ const EXPIRES = '2026-10-02T14:09:40.273Z'
22
+
23
+ // What 2.0.0-beta.6 answered for a live lease covering two partitions, and
24
+ // for a lease that no longer exists.
25
+ const renewed = (leaseId = LEASE) => ({
26
+ leaseId, success: true, renewed: 2, newExpiresAt: EXPIRES, expiresAt: EXPIRES, lease_expires_at: EXPIRES
27
+ })
28
+ const notRenewed = (leaseId = LEASE) => ({
29
+ leaseId, success: false, renewed: 0, newExpiresAt: null, expiresAt: null, lease_expires_at: null
30
+ })
31
+
32
+ async function withQueen(plan, run) {
33
+ await withPlanServer(plan, { status: 200, body: renewed() }, async (url, hits) => {
34
+ const queen = new Queen({ url, handleSignals: false, retryAttempts: 1 })
35
+ try {
36
+ await run(queen, hits)
37
+ } finally {
38
+ await queen.close()
39
+ }
40
+ })
41
+ }
42
+
43
+ describe('Queen.renew — the broker\'s verdict, not the HTTP status', () => {
44
+ it('a renewed lease reports success, its new expiry, and how many partitions it covers', async () => {
45
+ await withQueen([{ body: renewed() }], async (queen, hits) => {
46
+ const res = await queen.renew(LEASE)
47
+ assert.deepEqual(res, { leaseId: LEASE, success: true, newExpiresAt: EXPIRES, renewed: 2 })
48
+ assert.equal(hits[0].url, `/api/v1/lease/${LEASE}/extend`)
49
+ })
50
+ })
51
+
52
+ it('a lease that is gone reports failure, with a reason', async () => {
53
+ await withQueen([{ body: notRenewed() }], async (queen) => {
54
+ const res = await queen.renew({ leaseId: LEASE, transactionId: 'tx-1' })
55
+ assert.equal(res.success, false)
56
+ assert.equal(res.renewed, 0)
57
+ assert.equal(res.newExpiresAt, null)
58
+ assert.match(res.error, /not renewed/)
59
+ })
60
+ })
61
+
62
+ it('an array of messages answers one outcome per distinct lease', async () => {
63
+ const other = '01a0fcf2-0000-7000-8000-000000000000'
64
+ await withQueen([{ body: renewed() }, { body: notRenewed(other) }], async (queen, hits) => {
65
+ const res = await queen.renew([
66
+ { leaseId: LEASE, transactionId: 'tx-1' },
67
+ { leaseId: LEASE, transactionId: 'tx-2' },
68
+ { leaseId: other, transactionId: 'tx-3' }
69
+ ])
70
+ assert.equal(hits.length, 2, 'one call per lease, not per message')
71
+ assert.deepEqual(res.map(r => [r.leaseId, r.success]), [[LEASE, true], [other, false]])
72
+ })
73
+ })
74
+
75
+ it('reads the C++ broker\'s array, including its error text', async () => {
76
+ await withQueen([
77
+ { body: [{ index: 0, leaseId: LEASE, success: true, error: null, expiresAt: EXPIRES }] },
78
+ { body: [{ index: 0, leaseId: LEASE, success: false, error: 'Lease already expired', expiresAt: null }] }
79
+ ], async (queen) => {
80
+ const ok = await queen.renew(LEASE)
81
+ assert.equal(ok.success, true)
82
+ assert.equal(ok.newExpiresAt, EXPIRES)
83
+ const gone = await queen.renew(LEASE)
84
+ assert.equal(gone.success, false)
85
+ assert.equal(gone.error, 'Lease already expired')
86
+ })
87
+ })
88
+
89
+ it('an HTTP failure is still a failed renewal, not a throw', async () => {
90
+ await withQueen([{ status: 500, body: { error: 'boom' } }], async (queen) => {
91
+ const res = await queen.renew(LEASE)
92
+ assert.equal(res.success, false)
93
+ assert.ok(res.error)
94
+ })
95
+ })
96
+ })
@@ -10,7 +10,7 @@
10
10
  * a tenant that is over quota -- while the fire never stops by itself, so
11
11
  * that tenant keeps producing messages it cannot stop.
12
12
  * 2. delayMs, never delaySeconds and never an absolute instant (§4.2,
13
- * §20.6). One clock, Postgres's. `deliverAt` in an op is a 22023.
13
+ * §20.6). One clock, the broker's. `deliverAt` in an op is a 22023.
14
14
  * 3. The payload is base64 (§6.2), and the SDK encodes it, so a caller never
15
15
  * has to know.
16
16
  * 4. `txn` is mandatory on a schedule and is echoed on `absent`, which is
@@ -181,11 +181,19 @@ describe('Transaction wire — the verdict that must not throw', () => {
181
181
  })
182
182
 
183
183
  it('commit() still THROWS on every other failure', async () => {
184
- const rejected = ok({ transactionId: 'txn-1', success: false, reason: 'ack_rejected', error: 'QTXN invalid or expired lease', results: [] })
184
+ // The body a 2.0 broker answers for an ack whose lease is gone (a 1.x
185
+ // broker spelled the reason ack_rejected).
186
+ const rejected = ok({
187
+ transactionId: 'txn-1',
188
+ success: false,
189
+ reason: 'rejected_ack',
190
+ error: 'QTXN 1 acked message(s) on partition 3 are not leased by this worker or were already acked; the transaction rolled back',
191
+ results: []
192
+ })
185
193
  await withQueen([rejected], async (queen) => {
186
194
  await assert.rejects(() => queen.transaction().ack(MSG).commit(), (e) => {
187
195
  assert.match(e.message, /QTXN/)
188
- assert.equal(e.reason, 'ack_rejected', 'the closed-taxonomy reason travels on the error, so nobody matches the message')
196
+ assert.equal(e.reason, 'rejected_ack', 'the broker\'s reason travels on the error, so nobody matches the message')
189
197
  return true
190
198
  })
191
199
  })
@@ -220,3 +228,94 @@ describe('Transaction wire — the verdict that must not throw', () => {
220
228
  })
221
229
  })
222
230
  })
231
+
232
+ // ---------------------------------------------------------------------------
233
+ // The consumer group of an ack op. The lease an ack has to match belongs to
234
+ // the group the message was popped under, and an op that names no group is
235
+ // judged in queue mode: a message popped by a group and acked without one
236
+ // rolled the whole bundle back with `rejected_ack` (found 2026-10-02 against
237
+ // 2.0.0-beta.6). Every pop answers each message with its `consumerGroup`.
238
+ // ---------------------------------------------------------------------------
239
+
240
+ describe('Transaction wire — the ack op carries the group the message was popped under', () => {
241
+ const fromGroup = (group, n = 1) => ({
242
+ ...MSG,
243
+ transactionId: `aaaaaaaa-aaaa-7aaa-8aaa-00000000000${n}`,
244
+ consumerGroup: group
245
+ })
246
+
247
+ it('takes consumerGroup from the message when the caller names none', async () => {
248
+ await withQueen([success()], async (queen, hits) => {
249
+ await queen.transaction().ack(fromGroup('workers')).commit()
250
+ assert.equal(hits[0].body.operations[0].consumerGroup, 'workers')
251
+ })
252
+ })
253
+
254
+ it('a queue-mode message adds no consumerGroup key, as before', async () => {
255
+ await withQueen([success(), success()], async (queen, hits) => {
256
+ await queen.transaction().ack(fromGroup('__QUEUE_MODE__')).commit()
257
+ await queen.transaction().ack(MSG).commit() // hand-built, no group at all
258
+ for (const hit of hits) {
259
+ assert.deepEqual(Object.keys(hit.body.operations[0]).sort(), ['leaseId', 'partitionId', 'status', 'transactionId', 'type'])
260
+ }
261
+ })
262
+ })
263
+
264
+ it('an explicit group wins, as { consumerGroup } or as { group }', async () => {
265
+ await withQueen([success(), success()], async (queen, hits) => {
266
+ await queen.transaction().ack(fromGroup('workers'), 'completed', { consumerGroup: 'auditors' }).commit()
267
+ await queen.transaction().ack(fromGroup('workers'), 'completed', { group: 'auditors' }).commit()
268
+ assert.equal(hits[0].body.operations[0].consumerGroup, 'auditors')
269
+ assert.equal(hits[1].body.operations[0].consumerGroup, 'auditors')
270
+ })
271
+ })
272
+
273
+ it('messages from different groups each keep their own', async () => {
274
+ await withQueen([success()], async (queen, hits) => {
275
+ await queen.transaction().ack([fromGroup('a', 1), fromGroup('b', 2)]).commit()
276
+ assert.deepEqual(hits[0].body.operations.map(op => op.consumerGroup), ['a', 'b'])
277
+ })
278
+ })
279
+ })
280
+
281
+ // The lease of an ack op. A Queen 2 broker fences each ack with the leaseId
282
+ // the operation carries, and lends it one from requiredLeases only when every
283
+ // lease in the bundle is the same lease. An ack whose lease travelled in
284
+ // requiredLeases alone was applied unfenced in a bundle of two leases, even
285
+ // after that lease expired and another consumer took the message (found
286
+ // 2026-10-03 against 2.0.0-beta.6).
287
+ // ---------------------------------------------------------------------------
288
+
289
+ describe('Transaction wire — each ack op carries its own lease', () => {
290
+ const leased = (n, leaseId) => ({
291
+ transactionId: `aaaaaaaa-aaaa-7aaa-8aaa-00000000000${n}`,
292
+ partitionId: `pppppppp-pppp-7ppp-8ppp-00000000000${n}`,
293
+ ...(leaseId === undefined ? {} : { leaseId })
294
+ })
295
+
296
+ it('a bundle spanning two leases names each lease on its own ack', async () => {
297
+ await withQueen([success()], async (queen, hits) => {
298
+ await queen.transaction()
299
+ .ack(leased(1, 'lease-1'))
300
+ .ack(leased(2, 'lease-2'))
301
+ .ack(leased(3))
302
+ .commit()
303
+
304
+ const [first, second, third] = hits[0].body.operations
305
+ assert.equal(first.leaseId, 'lease-1')
306
+ assert.equal(second.leaseId, 'lease-2')
307
+ assert.ok(!('leaseId' in third), 'a message without a lease adds no leaseId key')
308
+ assert.deepEqual(hits[0].body.requiredLeases, ['lease-1', 'lease-2'])
309
+ })
310
+ })
311
+
312
+ it('a null or empty lease adds no leaseId key either', async () => {
313
+ await withQueen([success()], async (queen, hits) => {
314
+ await queen.transaction().ack([leased(1, null), leased(2, '')]).commit()
315
+ for (const op of hits[0].body.operations) {
316
+ assert.ok(!('leaseId' in op))
317
+ }
318
+ assert.deepEqual(hits[0].body.requiredLeases, [])
319
+ })
320
+ })
321
+ })
package/test-v2/kv.js CHANGED
@@ -1,15 +1,16 @@
1
1
  /**
2
2
  * KV integration suite (PLAN_KV_TIMERS.md §5, §8.3).
3
3
  *
4
- * Every test here is repeatable ONLY because run.js purges `queen.kv` for the
5
- * `test-%` namespaces before the run (§10.4): without that purge a
4
+ * Every test here is repeatable ONLY because every run starts on a fresh broker
5
+ * (test/run.sh creates the lane's data volume empty and destroys it with
6
+ * `down -v`): against a broker that kept the previous run's keys, a
6
7
  * `putIfAbsent` test is green on its first execution and red forever after,
7
- * and an `incr` test accumulates between runs. If you add a test here, its
8
- * namespace must start with `test-` or it will be the one that rots.
8
+ * and an `incr` test accumulates between runs. Keep the namespaces under
9
+ * `test-`, like every other fixture of the suite.
9
10
  *
10
11
  * And `forever` is BANNED in this file. A test that goes wrong with a TTL
11
12
  * leaves state that expires by itself; a test that goes wrong with `forever`
12
- * leaves immortal state in a shared database.
13
+ * leaves immortal state in a shared broker.
13
14
  */
14
15
 
15
16
  import { KV_NS, sleep } from './_kvtimers.js'
package/test-v2/pop.js CHANGED
@@ -226,11 +226,14 @@ export async function popV4DefaultOne(client) {
226
226
 
227
227
  await new Promise(r => setTimeout(r, 500))
228
228
 
229
- // No .partitions() → max_partitions defaults to 1 → single partition.
229
+ // No .partitions() and autopilot off → the pre-1.2 default of one
230
+ // partition. (With autopilot on, the SDK default, an unset width is the
231
+ // broker's to pick: one consumer and four ready partitions take all four.)
230
232
  const res = await client
231
233
  .queue(queueName)
232
234
  .batch(100)
233
235
  .wait(false)
236
+ .autopilot(false)
234
237
  .pop()
235
238
 
236
239
  if (res.length !== 5) {
@@ -334,3 +337,27 @@ export async function popV4SharedLeaseRenew(client) {
334
337
  return { success: true, message: 'Shared leaseId renewed once and dedup confirmed' }
335
338
  }
336
339
 
340
+
341
+ // renew() reports the broker's verdict (2026-10-02, 2.0.0-beta.6): the extend
342
+ // route answers HTTP 200 whether or not anything was renewed, and a lease an
343
+ // ack had already released came back success:true.
344
+ export async function renewReportsAReleasedLease(client) {
345
+ const queueName = 'test-queue-v2-renew-released'
346
+ const queue = await client.queue(queueName).config({ leaseTime: 60 }).create()
347
+ if (!queue.configured) {
348
+ return { success: false, message: 'Queue not created' }
349
+ }
350
+ await client.queue(queueName).push([{ data: { n: 1 } }])
351
+
352
+ const [message] = await client.queue(queueName).batch(1).wait(false).pop()
353
+ if (!message) {
354
+ return { success: false, message: 'Nothing popped' }
355
+ }
356
+ const live = await client.renew(message)
357
+ await client.ack(message, true)
358
+ const released = await client.renew(message)
359
+
360
+ const success = live.success === true && typeof live.newExpiresAt === 'string' &&
361
+ released.success === false && released.newExpiresAt === null && typeof released.error === 'string'
362
+ return { success, message: `live lease: ${JSON.stringify(live)}; after the ack: ${JSON.stringify(released)}` }
363
+ }
@@ -1,6 +1,27 @@
1
1
 
2
- // To pass this test the server needs to
3
- // be started with RETENTION_INTERVAL=2000
2
+ // The sweep is periodic AND backs off, so the wait below is a bound, not a guess.
3
+ //
4
+ // retention.rs gives a partition a strike for every visit that deletes nothing
5
+ // and skips it for BACKOFF_BASE_CYCLES (8) cycles after the first one. The
6
+ // sweep that runs while these 100 messages are still younger than
7
+ // `retentionSeconds` is exactly such a fruitless visit, so the partition is
8
+ // parked for 8 * RETENTION_INTERVAL before anything can be deleted:
9
+ //
10
+ // worst case = retentionSeconds + BACKOFF_BASE_CYCLES * RETENTION_INTERVAL
11
+ // = 10s + 8 * 5s (the default)
12
+ // = 50s
13
+ //
14
+ // The old wait was 15s, taken from a comment that assumed the broker ran with
15
+ // RETENTION_INTERVAL=2000 -- which no stack in test/compose has ever set. It
16
+ // could not pass once the backoff landed (751435f5), and it is why `suites (js)`
17
+ // was 172/173 red on master for weeks. Measured on the single stack: still
18
+ // present at 15s/25s/35s, deleted by 50s.
19
+ //
20
+ // Do NOT turn this into a poll loop: pop() CONSUMES, so a poll that pops early
21
+ // empties the queue itself and the next read returns 0 whether retention ran or
22
+ // not -- a test that passes for the wrong reason.
23
+ const RETENTION_SWEEP_WAIT_MS = 65000
24
+
4
25
  export async function retentionTest(client) {
5
26
  const queueName = 'test-queue-retention-01';
6
27
  const queue = await client
@@ -20,7 +41,7 @@ export async function retentionTest(client) {
20
41
  .push([{ data: { message: i } }])
21
42
  }
22
43
 
23
- await new Promise(resolve => setTimeout(resolve, 15000))
44
+ await new Promise(resolve => setTimeout(resolve, RETENTION_SWEEP_WAIT_MS))
24
45
 
25
46
  const messages = await client
26
47
  .queue(queueName)
@@ -32,12 +53,12 @@ export async function retentionTest(client) {
32
53
  return { success: true, message: 'Messages cleaned up' }
33
54
  }
34
55
 
35
- return { success: false }
56
+ return { success: false, message: `retention left ${messages.length}/100 messages after ${RETENTION_SWEEP_WAIT_MS / 1000}s` }
36
57
  }
37
58
 
38
59
 
39
- // To pass this test the server needs to
40
- // be started with RETENTION_INTERVAL=2000
60
+ // maxWaitTimeSeconds eviction, which is a different retention phase with its own
61
+ // backoff map -- so it is not subject to the bound above and 15s has held.
41
62
  export async function retentionTestMaxTime(client) {
42
63
  const queueName = 'test-queue-retention-02';
43
64
  const queue = await client
@@ -65,5 +86,5 @@ export async function retentionTestMaxTime(client) {
65
86
  return { success: true, message: 'Messages cleaned up' }
66
87
  }
67
88
 
68
- return { success: false }
89
+ return { success: false, message: `max-wait eviction left ${messages.length}/100 messages` }
69
90
  }