queen-mq 0.16.0 → 1.0.3

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 (44) hide show
  1. package/README.md +175 -0
  2. package/client-v2/Queen.js +125 -15
  3. package/client-v2/README.md +69 -0
  4. package/client-v2/builders/QueueBuilder.js +18 -20
  5. package/client-v2/builders/TimerBuilder.js +262 -0
  6. package/client-v2/builders/TransactionBuilder.js +199 -10
  7. package/client-v2/consumer/ConsumerManager.js +64 -12
  8. package/client-v2/http/HttpClient.js +382 -32
  9. package/client-v2/kv/Kv.js +432 -0
  10. package/client-v2/kv/expiry.js +148 -0
  11. package/client-v2/streams/runtime/Runner.js +45 -0
  12. package/client-v2/utils/defaults.js +20 -1
  13. package/package.json +12 -3
  14. package/test-v2/_kvtimers.js +71 -0
  15. package/test-v2/ackwindow.js +265 -0
  16. package/test-v2/auth.js +65 -149
  17. package/test-v2/docs.js +204 -0
  18. package/test-v2/http-unit/hostHeader.test.js +411 -0
  19. package/test-v2/http-unit/retry429.test.js +319 -0
  20. package/test-v2/kv-unit/_planServer.js +65 -0
  21. package/test-v2/kv-unit/kvWire.test.js +377 -0
  22. package/test-v2/kv-unit/timerWire.test.js +177 -0
  23. package/test-v2/kv-unit/txnWire.test.js +222 -0
  24. package/test-v2/kv.js +273 -0
  25. package/test-v2/load.js +37 -41
  26. package/test-v2/maintenance.js +2 -2
  27. package/test-v2/push.js +25 -35
  28. package/test-v2/run.js +67 -5
  29. package/test-v2/semantics.js +801 -0
  30. package/test-v2/stream/_helpers.js +8 -1
  31. package/test-v2/stream/combined.js +4 -3
  32. package/test-v2/stream/cron.js +1 -1
  33. package/test-v2/stream/eventTime.js +8 -5
  34. package/test-v2/stream/operators.js +5 -5
  35. package/test-v2/stream/recovery.js +4 -1
  36. package/test-v2/stream/session.js +3 -3
  37. package/test-v2/stream/sliding.js +1 -1
  38. package/test-v2/stream/throughput.js +3 -2
  39. package/test-v2/stream/tumbling.js +16 -12
  40. package/test-v2/streams-unit/ack.test.js +203 -0
  41. package/test-v2/streams-unit/e2e.test.js +4 -1
  42. package/test-v2/timers.js +209 -0
  43. package/test-v2/transaction.js +4 -1
  44. package/test-v2/watermark.js +78 -62
@@ -0,0 +1,319 @@
1
+ /**
2
+ * Client-side 429/403 handling tests (PLAN_QUEEN_PROXY_CLOUD.md §4/§9,
3
+ * blocker B4 -- "client 429/backoff work ... mandatory pre-enforcement").
4
+ *
5
+ * The proxy error contract under test:
6
+ * 429 Retry-After: <seconds> { "error": "...", "code": "rate_limited" | "quota_exceeded" }
7
+ * 403 { "error": "...", "code": "cluster_suspended" | "storage_quota_exceeded"
8
+ * | "feature_gated" | "forbidden" }
9
+ *
10
+ * These tests run against a real local HTTP server (no broker) that plays
11
+ * back a canned response script, mirroring the style of ack.test.js
12
+ * (withAckServer) and streams-unit/fakeServer.js -- no mocking framework,
13
+ * just node:http.
14
+ */
15
+
16
+ import { describe, it } from 'node:test'
17
+ import assert from 'node:assert/strict'
18
+ import { createServer } from 'node:http'
19
+
20
+ import { Queen } from '../../client-v2/index.js'
21
+ import { HttpClient } from '../../client-v2/http/HttpClient.js'
22
+
23
+ /**
24
+ * Spin up a real HTTP server driven by a canned response `plan` (array of
25
+ * {status, body, retryAfter, headers}, consumed in request order; any
26
+ * request beyond the plan gets `defaultResponse`). Records each hit's
27
+ * arrival time so tests can sanity-check backoff pacing.
28
+ */
29
+ function withPlanServer(plan, defaultResponse, run) {
30
+ const hits = []
31
+ const start = Date.now()
32
+ let index = 0
33
+
34
+ const server = createServer((req, res) => {
35
+ let raw = ''
36
+ req.on('data', chunk => { raw += chunk })
37
+ req.on('end', () => {
38
+ hits.push({ method: req.method, url: req.url, atMs: Date.now() - start })
39
+
40
+ const descriptor = index < plan.length ? plan[index] : defaultResponse
41
+ index++
42
+
43
+ const status = descriptor.status ?? 200
44
+ const headers = { 'Content-Type': 'application/json', ...(descriptor.headers || {}) }
45
+ if (descriptor.retryAfter != null) headers['Retry-After'] = String(descriptor.retryAfter)
46
+
47
+ res.writeHead(status, headers)
48
+ res.end(JSON.stringify(descriptor.body ?? { ok: true }))
49
+ })
50
+ })
51
+
52
+ return new Promise((resolve, reject) => {
53
+ server.listen(0, '127.0.0.1', () => {
54
+ const { port } = server.address()
55
+ const teardown = () => new Promise(r => server.close(r))
56
+ Promise.resolve()
57
+ .then(() => run(`http://127.0.0.1:${port}`, hits))
58
+ .then(
59
+ async (value) => { await teardown(); resolve(value) },
60
+ async (err) => { await teardown(); reject(err) }
61
+ )
62
+ })
63
+ })
64
+ }
65
+
66
+ function rateLimited(retryAfter) {
67
+ const descriptor = { status: 429, body: { error: 'slow down', code: 'rate_limited' } }
68
+ if (retryAfter != null) descriptor.retryAfter = retryAfter
69
+ return descriptor
70
+ }
71
+
72
+ function repeat(descriptor, n) {
73
+ return Array.from({ length: n }, () => ({ ...descriptor }))
74
+ }
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // HttpClient (direct) -- the centralized retry429 mechanism itself.
78
+ // ---------------------------------------------------------------------------
79
+
80
+ describe('HttpClient — 429 retry policy', () => {
81
+ it('honors Retry-After (seconds) and succeeds once the header-specified wait elapses', async () => {
82
+ const plan = [rateLimited('0')]
83
+ await withPlanServer(plan, { status: 200, body: { ok: true } }, async (url, hits) => {
84
+ const client = new HttpClient({ baseUrl: url, retry429: { baseMs: 5, capMs: 50 } })
85
+ try {
86
+ const result = await client.get('/x')
87
+ assert.deepEqual(result, { ok: true })
88
+ assert.equal(hits.length, 2, 'one 429 then one success')
89
+ } finally {
90
+ await client.destroy()
91
+ }
92
+ })
93
+ })
94
+
95
+ it('falls back to exponential backoff when Retry-After is absent, and the gap grows', async () => {
96
+ const plan = repeat(rateLimited(), 2)
97
+ await withPlanServer(plan, { status: 200, body: { ok: true } }, async (url, hits) => {
98
+ // baseMs has to be well above the event loop's scheduling noise. At 20ms
99
+ // a loaded runner added ~27ms to BOTH gaps, which collapses their ratio
100
+ // towards 1 (observed: 47ms then 54ms) and failed a test about growth
101
+ // that was working perfectly. The delay is additive, so the fix is a base
102
+ // big enough to dominate it, not a looser threshold.
103
+ const client = new HttpClient({ baseUrl: url, retry429: { baseMs: 100, capMs: 2000 } })
104
+ try {
105
+ const result = await client.get('/x')
106
+ assert.deepEqual(result, { ok: true })
107
+ assert.equal(hits.length, 3)
108
+ const gap1 = hits[1].atMs - hits[0].atMs
109
+ const gap2 = hits[2].atMs - hits[1].atMs
110
+ // Exponential (base=20ms -> ~20ms then ~40ms), jittered +-20%: allow
111
+ // generous slack for CI timer jitter while still proving growth.
112
+ assert.ok(gap2 > gap1 * 1.2, `expected gap2 (${gap2}ms) to exceed gap1 (${gap1}ms) by backoff growth`)
113
+ } finally {
114
+ await client.destroy()
115
+ }
116
+ })
117
+ })
118
+
119
+ it('gives up after retry429.maxAttempts on a default (push-like) call and throws rate_limited', async () => {
120
+ await withPlanServer([], rateLimited(), async (url, hits) => {
121
+ const client = new HttpClient({ baseUrl: url, retry429: { maxAttempts: 3, baseMs: 1, capMs: 5 } })
122
+ try {
123
+ await assert.rejects(
124
+ client.post('/api/v1/push', { items: [] }),
125
+ (err) => {
126
+ assert.equal(err.status, 429)
127
+ assert.equal(err.code, 'rate_limited')
128
+ return true
129
+ }
130
+ )
131
+ assert.equal(hits.length, 3, 'exactly maxAttempts tries, no more')
132
+ } finally {
133
+ await client.destroy()
134
+ }
135
+ })
136
+ })
137
+
138
+ it('defaults to 10 attempts for a default (non-pop) call when retry429 is not configured', async () => {
139
+ await withPlanServer([], rateLimited(), async (url, hits) => {
140
+ const client = new HttpClient({ baseUrl: url, retry429: { baseMs: 1, capMs: 2 } })
141
+ try {
142
+ await assert.rejects(client.post('/api/v1/push', { items: [] }))
143
+ assert.equal(hits.length, 10)
144
+ } finally {
145
+ await client.destroy()
146
+ }
147
+ })
148
+ })
149
+
150
+ it('retries a long-poll pop (retryKind "pop") past the push default of 10 attempts', async () => {
151
+ const plan = repeat(rateLimited('0'), 14)
152
+ await withPlanServer(plan, { status: 200, body: { messages: [{ id: 'm1' }] } }, async (url, hits) => {
153
+ const client = new HttpClient({ baseUrl: url, retry429: { baseMs: 1, capMs: 5 } })
154
+ try {
155
+ const result = await client.get('/api/v1/pop', null, null, 'pop')
156
+ assert.deepEqual(result, { messages: [{ id: 'm1' }] })
157
+ assert.equal(hits.length, 15, 'pop must not give up at the push default of 10 attempts')
158
+ } finally {
159
+ await client.destroy()
160
+ }
161
+ })
162
+ })
163
+
164
+ it('an explicit maxAttempts override applies to pop too (not just push)', async () => {
165
+ await withPlanServer([], rateLimited(), async (url, hits) => {
166
+ const client = new HttpClient({ baseUrl: url, retry429: { maxAttempts: 2, baseMs: 1, capMs: 5 } })
167
+ try {
168
+ await assert.rejects(client.get('/api/v1/pop', null, null, 'pop'))
169
+ assert.equal(hits.length, 2, 'explicit maxAttempts bounds pop as well as push')
170
+ } finally {
171
+ await client.destroy()
172
+ }
173
+ })
174
+ })
175
+
176
+ it('never retries a 403 and preserves its code (terminal)', async () => {
177
+ await withPlanServer([], { status: 403, body: { error: 'cluster suspended', code: 'cluster_suspended' } }, async (url, hits) => {
178
+ const client = new HttpClient({ baseUrl: url })
179
+ try {
180
+ await assert.rejects(
181
+ client.post('/api/v1/push', { items: [] }),
182
+ (err) => {
183
+ assert.equal(err.status, 403)
184
+ assert.equal(err.code, 'cluster_suspended')
185
+ return true
186
+ }
187
+ )
188
+ assert.equal(hits.length, 1, '403 must not be retried')
189
+ } finally {
190
+ await client.destroy()
191
+ }
192
+ })
193
+ })
194
+
195
+ it('still does not retry an ordinary 4xx like 400 (unaffected by the 429 changes)', async () => {
196
+ await withPlanServer([], { status: 400, body: { error: 'bad request' } }, async (url, hits) => {
197
+ const client = new HttpClient({ baseUrl: url })
198
+ try {
199
+ await assert.rejects(client.get('/x'), (err) => {
200
+ assert.equal(err.status, 400)
201
+ return true
202
+ })
203
+ assert.equal(hits.length, 1)
204
+ } finally {
205
+ await client.destroy()
206
+ }
207
+ })
208
+ })
209
+ })
210
+
211
+ // ---------------------------------------------------------------------------
212
+ // Queen public API wiring: config plumbing (retry429) + call-site marking
213
+ // (push vs. wait=true pop) actually reach HttpClient.
214
+ // ---------------------------------------------------------------------------
215
+
216
+ describe('Queen — push()/pop() wired to retry429', () => {
217
+ it('push() retries a 429 (Retry-After) then succeeds', async () => {
218
+ const plan = [rateLimited('0')]
219
+ await withPlanServer(plan, { status: 200, body: [{ status: 'queued', transactionId: 'tx-1' }] }, async (url, hits) => {
220
+ const queen = new Queen({ url, handleSignals: false, retry429: { baseMs: 5, capMs: 50 } })
221
+ try {
222
+ const result = await queen.queue('q1').push({ hello: 'world' })
223
+ assert.equal(hits.length, 2)
224
+ assert.equal(result[0].status, 'queued')
225
+ } finally {
226
+ await queen.close()
227
+ }
228
+ })
229
+ })
230
+
231
+ it('push() surfaces a terminal 403 without retrying', async () => {
232
+ await withPlanServer([], { status: 403, body: { error: 'over quota', code: 'storage_quota_exceeded' } }, async (url, hits) => {
233
+ const queen = new Queen({ url, handleSignals: false })
234
+ try {
235
+ await assert.rejects(
236
+ async () => { await queen.queue('q1').push({ hello: 'world' }) },
237
+ (err) => { assert.equal(err.code, 'storage_quota_exceeded'); return true }
238
+ )
239
+ assert.equal(hits.length, 1)
240
+ } finally {
241
+ await queen.close()
242
+ }
243
+ })
244
+ })
245
+
246
+ it('a long-poll pop() rides out more 429s than the push default before returning', async () => {
247
+ const plan = repeat(rateLimited('0'), 12)
248
+ await withPlanServer(plan, { status: 200, body: { messages: [{ transactionId: 'tx-1', data: { x: 1 } }] } }, async (url, hits) => {
249
+ const queen = new Queen({ url, handleSignals: false, retry429: { baseMs: 1, capMs: 5 } })
250
+ try {
251
+ const messages = await queen.queue('q1').wait(true).pop()
252
+ assert.equal(messages.length, 1)
253
+ assert.equal(hits.length, 13)
254
+ } finally {
255
+ await queen.close()
256
+ }
257
+ })
258
+ })
259
+
260
+ it('a non-waiting pop() uses the bounded default and swallows an exhausted 429 to []', async () => {
261
+ await withPlanServer([], rateLimited(), async (url, hits) => {
262
+ const queen = new Queen({ url, handleSignals: false, retry429: { maxAttempts: 2, baseMs: 1, capMs: 5 } })
263
+ try {
264
+ const messages = await queen.queue('q1').wait(false).pop()
265
+ assert.deepEqual(messages, [], 'pop() keeps its swallow-errors-to-[] contract')
266
+ assert.equal(hits.length, 2)
267
+ } finally {
268
+ await queen.close()
269
+ }
270
+ })
271
+ })
272
+ })
273
+
274
+ // ---------------------------------------------------------------------------
275
+ // ConsumerManager worker loop: the actual hot-loop/die bug (B4) this task
276
+ // fixes -- consume() must back off through 429s and stop cleanly on a
277
+ // terminal 403 instead of spinning or crashing uncontrolled.
278
+ // ---------------------------------------------------------------------------
279
+
280
+ describe('Queen — consume() loop backs off on 429 and stops on terminal 403', () => {
281
+ it('backs off through repeated 429s and still delivers the message', async () => {
282
+ const plan = repeat(rateLimited('0'), 3)
283
+ await withPlanServer(
284
+ plan,
285
+ { status: 200, body: { messages: [{ transactionId: 'tx-1', partitionId: 'p-1', data: { x: 1 } }] } },
286
+ async (url, hits) => {
287
+ const queen = new Queen({ url, handleSignals: false, retry429: { baseMs: 1, capMs: 5 } })
288
+ try {
289
+ const received = []
290
+ // .each() delivers one message per handler call (default batches
291
+ // the whole array) -- simplest shape to assert on here.
292
+ await queen.queue('q1').wait(true).limit(1).autoAck(false).each().consume(async (msg) => {
293
+ received.push(msg)
294
+ })
295
+ assert.equal(received.length, 1)
296
+ assert.equal(received[0].transactionId, 'tx-1')
297
+ assert.ok(hits.length >= 4, 'the 3 rate-limited attempts plus the final success must all have hit the server')
298
+ } finally {
299
+ await queen.close()
300
+ }
301
+ }
302
+ )
303
+ })
304
+
305
+ it('stops the worker on a terminal 403 (cluster_suspended) instead of hot-looping or hanging', async () => {
306
+ await withPlanServer([], { status: 403, body: { error: 'cluster suspended', code: 'cluster_suspended' } }, async (url, hits) => {
307
+ const queen = new Queen({ url, handleSignals: false })
308
+ try {
309
+ await assert.rejects(
310
+ async () => { await queen.queue('q1').wait(true).consume(async () => {}) },
311
+ (err) => { assert.equal(err.code, 'cluster_suspended'); return true }
312
+ )
313
+ assert.equal(hits.length, 1, 'must stop after the first 403, not hot-loop')
314
+ } finally {
315
+ await queen.close()
316
+ }
317
+ })
318
+ })
319
+ })
@@ -0,0 +1,65 @@
1
+ /**
2
+ * A real node:http server driven by a canned response plan, recording every
3
+ * request it receives (method, url, parsed body).
4
+ *
5
+ * Same shape as test-v2/http-unit/retry429.test.js's withPlanServer and
6
+ * streams-unit/fakeServer.js -- no mocking framework, a real socket, real
7
+ * fetch, real JSON. The difference is that these tests assert the REQUEST,
8
+ * not the retry behaviour: the exact JSON body of every KV and timer
9
+ * operation is the contract towards the broker, and a plan server is the only
10
+ * place that contract can be pinned without a database.
11
+ */
12
+
13
+ import { createServer } from 'node:http'
14
+
15
+ export function withPlanServer(plan, defaultResponse, run) {
16
+ const hits = []
17
+ let index = 0
18
+
19
+ const server = createServer((req, res) => {
20
+ let raw = ''
21
+ req.on('data', chunk => { raw += chunk })
22
+ req.on('end', () => {
23
+ let body = null
24
+ if (raw.length > 0) {
25
+ try {
26
+ body = JSON.parse(raw)
27
+ } catch {
28
+ body = { __unparseable: raw }
29
+ }
30
+ }
31
+ hits.push({ method: req.method, url: req.url, raw, body })
32
+
33
+ const descriptor = index < plan.length ? plan[index] : defaultResponse
34
+ index++
35
+
36
+ const status = descriptor.status ?? 200
37
+ const headers = { 'Content-Type': 'application/json', ...(descriptor.headers || {}) }
38
+ res.writeHead(status, headers)
39
+ res.end(JSON.stringify(descriptor.body ?? { ok: true }))
40
+ })
41
+ })
42
+
43
+ return new Promise((resolve, reject) => {
44
+ server.listen(0, '127.0.0.1', () => {
45
+ const { port } = server.address()
46
+ const teardown = () => new Promise(r => server.close(r))
47
+ Promise.resolve()
48
+ .then(() => run(`http://127.0.0.1:${port}`, hits))
49
+ .then(
50
+ async (value) => { await teardown(); resolve(value) },
51
+ async (err) => { await teardown(); reject(err) }
52
+ )
53
+ })
54
+ })
55
+ }
56
+
57
+ /** One canned 200 with this JSON body. */
58
+ export function ok(body) {
59
+ return { status: 200, body }
60
+ }
61
+
62
+ /** The KV batch envelope: {"results":[...]}, index-aligned to the input. */
63
+ export function kvResults(...results) {
64
+ return ok({ results })
65
+ }