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,222 @@
1
+ /**
2
+ * Transaction wire contract for the kv and timers riders
3
+ * (PLAN_KV_TIMERS.md §6.3, §8.2, §8.3, §10.4).
4
+ *
5
+ * THE ONE SHAPE RULE, AND WHY IT IS NOT NEGOTIABLE. `kv` and `timers` are
6
+ * TOP-LEVEL fields of the request body, beside `operations` and never inside
7
+ * it. The reason is a silent failure in another language that this client's
8
+ * shape has to respect anyway, because the broker parses one wire for all
9
+ * seven: two Go struct fields carrying the same JSON key at the same level are
10
+ * BOTH DROPPED by encoding/json, with no error. A bundle would go out with
11
+ * zero kv ops, the broker would commit a transaction with no gate, and the
12
+ * putIfAbsent the bundle existed for would simply never have happened.
13
+ *
14
+ * BYTE-IDENTITY WHEN THE RIDERS ARE ABSENT. A transaction that carries neither
15
+ * array must produce exactly today's body -- no `kv: []`, no `timers: null`.
16
+ * Anything else is a wire change for every broker that has not been upgraded.
17
+ *
18
+ * AND THE ONE BEHAVIOUR CHANGE THIS FEATURE FORCES ON `commit()`: a lost
19
+ * `required` precondition RETURNS `{success:false, reason:'kv_precondition'}`,
20
+ * it does not throw. It is the expected outcome of every legitimate
21
+ * redelivery -- the idempotency marker doing its job -- and a throw would put
22
+ * the single most frequent outcome of the product inside every caller's
23
+ * catch-and-retry. Everything else that fails still throws.
24
+ */
25
+
26
+ import { describe, it } from 'node:test'
27
+ import assert from 'node:assert/strict'
28
+
29
+ import { Queen } from '../../client-v2/index.js'
30
+ import { withPlanServer, ok } from './_planServer.js'
31
+
32
+ const NS = 'test-txn-ns'
33
+ const QUEUE = 'test-txn-q'
34
+
35
+ const MSG = { transactionId: 'aaaaaaaa-aaaa-7aaa-8aaa-aaaaaaaaaaaa', partitionId: 'pppppppp-pppp-7ppp-8ppp-pppppppppppp', leaseId: 'llllllll-llll-7lll-8lll-llllllllllll' }
36
+
37
+ const success = (results = []) => ok({ transactionId: 'txn-1', success: true, results })
38
+
39
+ async function withQueen(plan, run) {
40
+ await withPlanServer(plan, success(), async (url, hits) => {
41
+ const queen = new Queen({ url, handleSignals: false })
42
+ try {
43
+ await run(queen, hits)
44
+ } finally {
45
+ await queen.close()
46
+ }
47
+ })
48
+ }
49
+
50
+ const b64 = (obj) => Buffer.from(JSON.stringify(obj), 'utf8').toString('base64')
51
+
52
+ describe('Transaction wire — where the riders live', () => {
53
+ it('a bundle with no riders is byte-for-byte what it is today', async () => {
54
+ await withQueen([success()], async (queen, hits) => {
55
+ await queen.transaction().ack(MSG).commit()
56
+ assert.deepEqual(Object.keys(hits[0].body).sort(), ['operations', 'requiredLeases'],
57
+ 'no kv:[] and no timers:null — a bundle without riders must not change shape')
58
+ })
59
+ })
60
+
61
+ it('kv is a TOP-LEVEL array, never an element of operations', async () => {
62
+ await withQueen([success()], async (queen, hits) => {
63
+ await queen.transaction()
64
+ .ack(MSG)
65
+ .kv.putIfAbsent(NS, 'marker', true, { ttlSeconds: 3600 })
66
+ .commit()
67
+
68
+ const body = hits[0].body
69
+ assert.ok(Array.isArray(body.kv))
70
+ assert.deepEqual(body.kv, [{ op: 'putIfAbsent', ns: NS, key: 'marker', value: true, ttlSeconds: 3600 }])
71
+ assert.equal(body.operations.length, 1)
72
+ assert.equal(body.operations[0].type, 'ack')
73
+ for (const op of body.operations) {
74
+ assert.ok(op.type !== 'kv', 'an op typed `kv` inside operations is a named 400 from the broker, by design')
75
+ }
76
+ })
77
+ })
78
+
79
+ it('timers is the other top-level array, and carries the same op shape as the route', async () => {
80
+ await withQueen([success()], async (queen, hits) => {
81
+ await queen.transaction()
82
+ .ack(MSG)
83
+ .timer(QUEUE).key('k').delayMs(60_000).payload({ a: 1 }).txn('tx-9').schedule()
84
+ .commit()
85
+
86
+ assert.deepEqual(hits[0].body.timers, [{
87
+ op: 'schedule', queue: QUEUE, timerKey: 'k', delayMs: 60000, txn: 'tx-9', payload: b64({ a: 1 })
88
+ }])
89
+ })
90
+ })
91
+
92
+ it('a timer cancel inside a bundle is an op in the timers array', async () => {
93
+ await withQueen([success()], async (queen, hits) => {
94
+ await queen.transaction()
95
+ .ack(MSG)
96
+ .timer(QUEUE).key('k').txn('tx-9').cancel()
97
+ .commit()
98
+
99
+ assert.deepEqual(hits[0].body.timers, [{ op: 'cancel', queue: QUEUE, timerKey: 'k', txn: 'tx-9' }])
100
+ })
101
+ })
102
+
103
+ it('once() is the gate: putIfAbsent with required:true', async () => {
104
+ await withQueen([success()], async (queen, hits) => {
105
+ await queen.transaction().ack(MSG).once(NS, 'order-9f1', { ttlSeconds: 86400 }).commit()
106
+ assert.deepEqual(hits[0].body.kv, [{
107
+ op: 'putIfAbsent', ns: NS, key: 'order-9f1', value: true, ttlSeconds: 86400, required: true
108
+ }])
109
+ })
110
+ })
111
+
112
+ it('until on a rider is resolved at COMMIT time, not when the op is queued', async () => {
113
+ await withQueen([success()], async (queen, hits) => {
114
+ const tx = queen.transaction().ack(MSG).kv.put(NS, 'k', 1, { until: new Date(Date.now() + 60_000) })
115
+ await new Promise(r => setTimeout(r, 1100))
116
+ await tx.commit()
117
+ const sent = hits[0].body.kv[0]
118
+ assert.ok(!('until' in sent))
119
+ assert.ok(sent.ttlSeconds <= 59, `a delta computed at build time would still say 60; got ${sent.ttlSeconds}`)
120
+ assert.ok(sent.ttlSeconds >= 58)
121
+ })
122
+ })
123
+
124
+ it('getPrefix is refused before the request, with the surface that does allow it named', async () => {
125
+ await withQueen([], async (queen, hits) => {
126
+ assert.throws(() => queen.transaction().kv.getPrefix(NS, 'p'), (e) => {
127
+ assert.match(e.message, /\/api\/v1\/kv/)
128
+ return true
129
+ })
130
+ assert.equal(hits.length, 0)
131
+ })
132
+ })
133
+
134
+ it('a kv-only bundle is legal and commits without operations', async () => {
135
+ await withQueen([success()], async (queen, hits) => {
136
+ await queen.transaction().kv.put(NS, 'k', 1, { ttlSeconds: 60 }).commit()
137
+ assert.deepEqual(hits[0].body.operations, [])
138
+ assert.equal(hits[0].body.kv.length, 1)
139
+ })
140
+ })
141
+
142
+ it('an entirely empty transaction still refuses to commit', async () => {
143
+ await withQueen([], async (queen, hits) => {
144
+ await assert.rejects(() => queen.transaction().commit(), (e) => {
145
+ assert.match(e.message, /no operations/i)
146
+ return true
147
+ })
148
+ assert.equal(hits.length, 0)
149
+ })
150
+ })
151
+ })
152
+
153
+ describe('Transaction wire — the verdict that must not throw', () => {
154
+ const precondition = ok({
155
+ transactionId: 'txn-1',
156
+ success: false,
157
+ ok: false,
158
+ reason: 'kv_precondition',
159
+ error: 'kv_precondition_failed',
160
+ failedIndex: 1,
161
+ kvReason: 'exists',
162
+ version: 90101,
163
+ value: { owner: 'first-delivery' },
164
+ results: []
165
+ })
166
+
167
+ it('commit() RETURNS on a lost precondition', async () => {
168
+ await withQueen([precondition], async (queen) => {
169
+ const res = await queen.transaction()
170
+ .ack(MSG)
171
+ .once(NS, 'order-9f1', { ttlSeconds: 86400 })
172
+ .commit()
173
+
174
+ assert.equal(res.success, false)
175
+ assert.equal(res.reason, 'kv_precondition')
176
+ assert.equal(res.failedIndex, 1, 'the index is in the FLAT space of results[]')
177
+ assert.equal(res.kvReason, 'exists')
178
+ assert.equal(res.version, 90101)
179
+ assert.deepEqual(res.value, { owner: 'first-delivery' })
180
+ })
181
+ })
182
+
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: [] })
185
+ await withQueen([rejected], async (queen) => {
186
+ await assert.rejects(() => queen.transaction().ack(MSG).commit(), (e) => {
187
+ 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')
189
+ return true
190
+ })
191
+ })
192
+ })
193
+
194
+ it('commit() throws on a duplicate and on a misalignment, with the reason attached', async () => {
195
+ const dup = ok({ transactionId: 'txn-1', success: false, reason: 'duplicate', error: 'QDUP duplicate transaction', results: [] })
196
+ const bad = ok({ transactionId: 'txn-1', success: false, reason: 'misaligned', error: 'QTXN rider results missing', results: [] })
197
+ await withQueen([dup, bad], async (queen) => {
198
+ await assert.rejects(() => queen.transaction().ack(MSG).commit(), (e) => { assert.equal(e.reason, 'duplicate'); return true })
199
+ await assert.rejects(() => queen.transaction().ack(MSG).commit(), (e) => { assert.equal(e.reason, 'misaligned'); return true })
200
+ })
201
+ })
202
+
203
+ it('a committed bundle hands back the flat results, riders included', async () => {
204
+ const results = [
205
+ { index: 0, type: 'ack', success: true },
206
+ { index: 1, type: 'kv', opIndex: 0, op: 'put', applied: true, key: 'k', version: 4 },
207
+ { index: 2, type: 'timer', opIndex: 0, ok: true, status: 'scheduled', timerKey: 'k' }
208
+ ]
209
+ await withQueen([success(results)], async (queen) => {
210
+ const res = await queen.transaction()
211
+ .ack(MSG)
212
+ .kv.put(NS, 'k', 1, { ttlSeconds: 60 })
213
+ .timer(QUEUE).key('k').delayMs(1).payload({}).txn('t').schedule()
214
+ .commit()
215
+
216
+ assert.equal(res.success, true)
217
+ assert.equal(res.results[1].type, 'kv')
218
+ assert.equal(res.results[1].applied, true)
219
+ assert.equal(res.results[2].status, 'scheduled')
220
+ })
221
+ })
222
+ })
package/test-v2/kv.js ADDED
@@ -0,0 +1,273 @@
1
+ /**
2
+ * KV integration suite (PLAN_KV_TIMERS.md §5, §8.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
6
+ * `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.
9
+ *
10
+ * And `forever` is BANNED in this file. A test that goes wrong with a TTL
11
+ * leaves state that expires by itself; a test that goes wrong with `forever`
12
+ * leaves immortal state in a shared database.
13
+ */
14
+
15
+ import { KV_NS, sleep } from './_kvtimers.js'
16
+
17
+ export async function kvPutGetDelete(client) {
18
+ const key = 'basic/put-get-delete'
19
+ const written = await client.kv.put(KV_NS, key, { hello: 'world', n: 1 }, { ttl: '10m' })
20
+ if (written.applied !== true) {
21
+ return { success: false, message: `put did not apply: ${JSON.stringify(written)}` }
22
+ }
23
+
24
+ const row = await client.kv.get(KV_NS, key)
25
+ if (row.found !== true || row.value.hello !== 'world' || row.version !== written.version) {
26
+ return { success: false, message: `get returned ${JSON.stringify(row)}` }
27
+ }
28
+ if (!row.expiresAt) {
29
+ return { success: false, message: 'a key written with a TTL must report expiresAt' }
30
+ }
31
+
32
+ const removed = await client.kv.delete(KV_NS, key)
33
+ const after = await client.kv.get(KV_NS, key)
34
+
35
+ return {
36
+ success: removed.applied === true && after.found === false,
37
+ message: 'put, get (row with version and expiresAt), delete, get-miss'
38
+ }
39
+ }
40
+
41
+ export async function kvNullIsAValueNotAnAbsence(client) {
42
+ const key = 'basic/null-value'
43
+ await client.kv.put(KV_NS, key, null, { ttl: '10m' })
44
+ const row = await client.kv.get(KV_NS, key)
45
+
46
+ return {
47
+ success: row.found === true && row.value === null,
48
+ message: row.found === true && row.value === null
49
+ ? 'a JSON null round-trips as {found:true, value:null}'
50
+ : `found and value were collapsed: ${JSON.stringify(row)}`
51
+ }
52
+ }
53
+
54
+ export async function kvPutIfAbsentIsExactlyOneWinner(client) {
55
+ const key = 'marker/exactly-once'
56
+ const first = await client.kv.putIfAbsent(KV_NS, key, { owner: 'first' }, { ttl: '10m' })
57
+ const second = await client.kv.putIfAbsent(KV_NS, key, { owner: 'second' }, { ttl: '10m' })
58
+
59
+ const ok = first.applied === true
60
+ && second.applied === false
61
+ && second.reason === 'exists'
62
+ && second.value && second.value.owner === 'first'
63
+ && second.version === first.version
64
+
65
+ return {
66
+ success: ok,
67
+ message: ok
68
+ ? 'exactly one winner, and the loser is handed the winner value without a second round trip'
69
+ : `first=${JSON.stringify(first)} second=${JSON.stringify(second)}`
70
+ }
71
+ }
72
+
73
+ /**
74
+ * §5.3's headline repair: `expect: N > 0` on an absent key must be a PURE
75
+ * UPDATE and create NOTHING. In the naive shape it falls into the INSERT
76
+ * branch and creates the row -- which in a saga fires the compensating command
77
+ * that `expect` existed to prevent.
78
+ */
79
+ export async function kvExpectOnAbsentKeyCreatesNothing(client) {
80
+ const key = 'fence/absent-key'
81
+ const res = await client.kv.put(KV_NS, key, { compensate: true }, { ttl: '10m', expect: 90101 })
82
+ const row = await client.kv.get(KV_NS, key)
83
+
84
+ const ok = res.applied === false && res.reason === 'absent' && row.found === false
85
+ return {
86
+ success: ok,
87
+ message: ok
88
+ ? 'expect:N>0 on an absent key applied nothing and created nothing'
89
+ : `res=${JSON.stringify(res)} row=${JSON.stringify(row)}`
90
+ }
91
+ }
92
+
93
+ export async function kvExpectFencesAStaleWriter(client) {
94
+ const key = 'fence/stale-writer'
95
+ const v1 = await client.kv.put(KV_NS, key, { step: 1 }, { ttl: '10m' })
96
+ const v2 = await client.kv.put(KV_NS, key, { step: 2 }, { ttl: '10m', expect: v1.version })
97
+ // The stale holder still carries v1's version: its write must lose.
98
+ const stale = await client.kv.put(KV_NS, key, { step: 'stale' }, { ttl: '10m', expect: v1.version })
99
+ const row = await client.kv.get(KV_NS, key)
100
+
101
+ const ok = v2.applied === true && stale.applied === false && stale.reason === 'version' && row.value.step === 2
102
+ return {
103
+ success: ok,
104
+ message: ok
105
+ ? 'a fenced write from a stale holder loses with reason "version"'
106
+ : `v2=${JSON.stringify(v2)} stale=${JSON.stringify(stale)} row=${JSON.stringify(row)}`
107
+ }
108
+ }
109
+
110
+ /**
111
+ * §5.4: with `max`, `applied` IS the admission decision -- and the first call
112
+ * of a window is guarded too, which is repair 2 (the naive INSERT branch has
113
+ * no WHERE, so `max:5, delta:10` would admit 10 on the first call and blow the
114
+ * quota exactly when a limiter is being attacked).
115
+ */
116
+ export async function kvIncrIsTheAdmissionDecision(client) {
117
+ const key = 'quota/window'
118
+ const a = await client.kv.incr(KV_NS, key, 1, { ttl: '10m', max: 2 })
119
+ const b = await client.kv.incr(KV_NS, key, 1, { ttl: '10m', max: 2 })
120
+ const c = await client.kv.incr(KV_NS, key, 1, { ttl: '10m', max: 2 })
121
+
122
+ const firstCallGuarded = await client.kv.incr(KV_NS, 'quota/first-call', 10, { ttl: '10m', max: 5 })
123
+
124
+ const ok = a.applied === true && a.value === 1
125
+ && b.applied === true && b.value === 2
126
+ && c.applied === false && c.reason === 'limit' && c.value === 2
127
+ && firstCallGuarded.applied === false && firstCallGuarded.reason === 'limit'
128
+
129
+ return {
130
+ success: ok,
131
+ message: ok
132
+ ? 'incr admits up to max, refuses beyond it without saturating, and guards the first call of the window'
133
+ : `a=${JSON.stringify(a)} b=${JSON.stringify(b)} c=${JSON.stringify(c)} first=${JSON.stringify(firstCallGuarded)}`
134
+ }
135
+ }
136
+
137
+ /**
138
+ * §5.4, repair 3: an expired non-numeric row must not poison the counter. Left
139
+ * unrepaired, every request of that customer is refused with `reason:'type'`
140
+ * until the sweeper prunes -- a reason no client handles as "retry".
141
+ */
142
+ export async function kvIncrTreatsAnExpiredRowAsZero(client) {
143
+ const key = 'quota/expired-initializer'
144
+ await client.kv.put(KV_NS, key, { count: 0 }, { ttlSeconds: 1 })
145
+ await sleep(1500)
146
+ const res = await client.kv.incr(KV_NS, key, 1, { ttl: '10m', max: 5 })
147
+
148
+ return {
149
+ success: res.applied === true && res.value === 1,
150
+ message: res.applied === true
151
+ ? 'an expired non-numeric row counts as zero and starts a new window'
152
+ : `incr over an expired row answered ${JSON.stringify(res)}`
153
+ }
154
+ }
155
+
156
+ export async function kvGetManyReportsMissingExplicitly(client) {
157
+ await client.kv.put(KV_NS, 'many/a', 1, { ttl: '10m' })
158
+ await client.kv.put(KV_NS, 'many/b', 2, { ttl: '10m' })
159
+ const res = await client.kv.getMany(KV_NS, ['many/a', 'many/b', 'many/nope'])
160
+
161
+ const keys = res.rows.map(r => r.key).sort()
162
+ const ok = keys.length === 2 && keys[0] === 'many/a' && keys[1] === 'many/b'
163
+ && res.missing.length === 1 && res.missing[0] === 'many/nope'
164
+
165
+ return {
166
+ success: ok,
167
+ message: ok ? 'getMany returns rows plus an explicit missing list' : JSON.stringify(res)
168
+ }
169
+ }
170
+
171
+ export async function kvGetPrefixPagesAndListAllWalksThem(client) {
172
+ for (const i of [1, 2, 3, 4, 5]) {
173
+ await client.kv.put(KV_NS, `scan/item-${i}`, { i }, { ttl: '10m' })
174
+ }
175
+ // A neighbour that must NOT be caught by the prefix.
176
+ await client.kv.put(KV_NS, 'scanX/other', { i: 0 }, { ttl: '10m' })
177
+
178
+ const page = await client.kv.getPrefix(KV_NS, 'scan/', { limit: 2 })
179
+ if (page.rows.length !== 2 || page.truncated !== true || !page.nextAfter) {
180
+ return { success: false, message: `first page: ${JSON.stringify(page)}` }
181
+ }
182
+
183
+ const seen = []
184
+ for await (const row of client.kv.listAll(KV_NS, 'scan/')) seen.push(row.key)
185
+
186
+ const ok = seen.length === 5 && seen.every(k => k.startsWith('scan/'))
187
+ return {
188
+ success: ok,
189
+ message: ok
190
+ ? 'getPrefix pages on a keyset cursor and listAll walks every page'
191
+ : `listAll saw ${JSON.stringify(seen)}`
192
+ }
193
+ }
194
+
195
+ export async function kvExpiredKeyIsNeverReturned(client) {
196
+ const key = 'ttl/short'
197
+ await client.kv.put(KV_NS, key, { v: 1 }, { ttlSeconds: 1 })
198
+ const alive = await client.kv.get(KV_NS, key)
199
+ await sleep(1500)
200
+ const dead = await client.kv.get(KV_NS, key)
201
+ // And it must not count as existing either, well before the sweeper prunes.
202
+ const resurrect = await client.kv.putIfAbsent(KV_NS, key, { v: 2 }, { ttl: '10m' })
203
+
204
+ const ok = alive.found === true && dead.found === false && resurrect.applied === true
205
+ return {
206
+ success: ok,
207
+ message: ok
208
+ ? 'an expired key is never returned and never counts as existing, sweeper or not'
209
+ : `alive=${JSON.stringify(alive)} dead=${JSON.stringify(dead)} resurrect=${JSON.stringify(resurrect)}`
210
+ }
211
+ }
212
+
213
+ export async function kvOnceReportsTheWinner(client) {
214
+ const key = 'once/order-9f1'
215
+ const first = await client.kv.once(KV_NS, key, { ttl: '10m' })
216
+ const second = await client.kv.once(KV_NS, key, { ttl: '10m' })
217
+
218
+ return {
219
+ success: first.won === true && second.won === false,
220
+ message: first.won === true && second.won === false
221
+ ? 'once() wins once and loses forever after, inside the TTL'
222
+ : `first=${JSON.stringify(first)} second=${JSON.stringify(second)}`
223
+ }
224
+ }
225
+
226
+ /**
227
+ * THE IDIOM THE WHOLE FEATURE EXISTS FOR (§8.3, §10.2).
228
+ *
229
+ * A bundle that pushes and marks itself done in one transaction. On a
230
+ * redelivery the marker already exists, the transaction ABORTS, and `commit()`
231
+ * RETURNS `{success:false, reason:'kv_precondition'}` instead of throwing --
232
+ * because a lost precondition is the expected outcome of every legitimate
233
+ * redelivery and must not enter a retry policy. The push must not have landed.
234
+ */
235
+ export async function kvTransactionGateBlocksTheSecondDelivery(client) {
236
+ const queue = await client.queue('test-kv-gate-out').create()
237
+ if (!queue.configured) return { success: false, message: 'Queue not created' }
238
+
239
+ const orderId = 'order-gate-1'
240
+ const first = await client.transaction()
241
+ .queue('test-kv-gate-out')
242
+ .push([{ data: { orderId, attempt: 1 } }])
243
+ .once(KV_NS, `gate/${orderId}`, { ttl: '10m' })
244
+ .commit()
245
+
246
+ if (first.success !== true) {
247
+ return { success: false, message: `the first delivery should commit: ${JSON.stringify(first)}` }
248
+ }
249
+
250
+ const second = await client.transaction()
251
+ .queue('test-kv-gate-out')
252
+ .push([{ data: { orderId, attempt: 2 } }])
253
+ .once(KV_NS, `gate/${orderId}`, { ttl: '10m' })
254
+ .commit()
255
+
256
+ if (second.success !== false || second.reason !== 'kv_precondition') {
257
+ return { success: false, message: `the redelivery should be gated, got ${JSON.stringify(second)}` }
258
+ }
259
+ if (second.kvReason !== 'exists' || typeof second.failedIndex !== 'number') {
260
+ return { success: false, message: `the verdict must carry kvReason and failedIndex: ${JSON.stringify(second)}` }
261
+ }
262
+
263
+ const delivered = await client.queue('test-kv-gate-out').batch(10).wait(false).pop()
264
+ const attempts = delivered.map(m => m.data.attempt)
265
+
266
+ const ok = attempts.length === 1 && attempts[0] === 1
267
+ return {
268
+ success: ok,
269
+ message: ok
270
+ ? 'the gate rolled back the second bundle whole: one message, not two, and commit() returned the verdict'
271
+ : `queue received attempts ${JSON.stringify(attempts)} (expected exactly [1])`
272
+ }
273
+ }
package/test-v2/load.js CHANGED
@@ -1,3 +1,22 @@
1
+ // Ordering assertion that tolerates REDELIVERY (at-least-once is the broker's
2
+ // contract: a lease expiry / ack timeout on a slow machine legally replays a
3
+ // batch). What it still catches is the real bug class — SKIPPED messages: the
4
+ // stream must never jump PAST an id it hasn't delivered yet. Throwing on a
5
+ // redelivered (already-seen) id instead would nack the batch, and four nacks
6
+ // dead-letter the head (v0.16.0 retry semantics) — turning one benign
7
+ // redelivery into message loss and a failed test.
8
+ function makeOrderingTracker() {
9
+ let maxId = -1
10
+ return {
11
+ observe(id) {
12
+ if (id > maxId + 1) {
13
+ throw new Error(`Message ordering violation: jumped from ${maxId} to ${id} (skipped ${id - maxId - 1})`)
14
+ }
15
+ if (id > maxId) maxId = id
16
+ }
17
+ }
18
+ }
19
+
1
20
  export async function testLoad(client) {
2
21
  const queue = await client
3
22
  .queue('test-queue-v2-load')
@@ -19,24 +38,22 @@ export async function testLoad(client) {
19
38
  await client.queue('test-queue-v2-load').push(messages)
20
39
 
21
40
  let uniqueIds = new Set();
22
- let lastId = null;
41
+ const order = makeOrderingTracker();
23
42
 
43
+ // idleMillis instead of limit(10000): a hard per-worker delivery budget
44
+ // counts redeliveries too, so one replayed batch makes workers return
45
+ // "complete" with messages still queued (or spin forever waiting to hit an
46
+ // exact share). Draining until idle is redelivery-proof — same pattern as
47
+ // the bootstrap tests.
24
48
  await client
25
49
  .queue('test-queue-v2-load')
26
50
  .concurrency(10)
27
51
  .batch(100)
28
52
  .wait(false)
29
- .limit(10000)
53
+ .idleMillis(5000)
30
54
  .consume(async msgs => {
31
55
  for (const msg of msgs) {
32
- if (lastId === null) {
33
- lastId = msg.data.id
34
- } else {
35
- if (msg.data.id !== lastId + 1) {
36
- throw new Error('Message ordering violation')
37
- }
38
- lastId = msg.data.id
39
- }
56
+ order.observe(msg.data.id)
40
57
  uniqueIds.add(msg.data.id)
41
58
  }
42
59
  })
@@ -76,24 +93,16 @@ export async function testLoadPartition(client) {
76
93
 
77
94
 
78
95
  for (let i = 0; i < 10; i++) {
79
- let lastId = null;
96
+ const order = makeOrderingTracker();
80
97
  await client
81
98
  .queue('test-queue-v2-load-partition')
82
99
  .partition(i.toString())
83
100
  .batch(100)
84
101
  .wait(false)
85
- .limit(10000)
102
+ .idleMillis(5000)
86
103
  .consume(async msgs => {
87
104
  for (const msg of msgs) {
88
- if (lastId === null) {
89
- lastId = msg.data.id
90
- } else {
91
- if (msg.data.id !== lastId + 1) {
92
- console.log(`Message ordering violation: ${msg.data.id} !== ${lastId + 1}`)
93
- throw new Error('Message ordering violation')
94
- }
95
- lastId = msg.data.id
96
- }
105
+ order.observe(msg.data.id)
97
106
  uniqueIds.add(msg.data.id)
98
107
  }
99
108
  })
@@ -126,7 +135,7 @@ export async function testLoadConsumerGroup(client) {
126
135
  await client.queue('test-queue-v2-load-consumer-group').push(messages)
127
136
 
128
137
  let uniqueIdsA = new Set();
129
- let lastIdA = null;
138
+ const orderA = makeOrderingTracker();
130
139
 
131
140
  await client
132
141
  .queue('test-queue-v2-load-consumer-group')
@@ -135,23 +144,16 @@ export async function testLoadConsumerGroup(client) {
135
144
  .concurrency(10)
136
145
  .batch(100)
137
146
  .wait(false)
138
- .limit(10000)
147
+ .idleMillis(5000)
139
148
  .consume(async msgs => {
140
149
  for (const msg of msgs) {
141
- if (lastIdA === null) {
142
- lastIdA = msg.data.id
143
- } else {
144
- if (msg.data.id !== lastIdA + 1) {
145
- throw new Error('Message ordering violation')
146
- }
147
- lastIdA = msg.data.id
148
- }
150
+ orderA.observe(msg.data.id)
149
151
  uniqueIdsA.add(msg.data.id)
150
152
  }
151
153
  })
152
154
 
153
155
  let uniqueIdsB = new Set();
154
- let lastIdB = null;
156
+ const orderB = makeOrderingTracker();
155
157
 
156
158
  await client
157
159
  .queue('test-queue-v2-load-consumer-group')
@@ -160,22 +162,16 @@ export async function testLoadConsumerGroup(client) {
160
162
  .concurrency(10)
161
163
  .batch(100)
162
164
  .wait(false)
163
- .limit(10000)
165
+ .idleMillis(5000)
164
166
  .consume(async msgs => {
165
167
  for (const msg of msgs) {
166
- if (lastIdB === null) {
167
- lastIdB = msg.data.id
168
- } else {
169
- if (msg.data.id !== lastIdB + 1) {
170
- throw new Error('Message ordering violation')
171
- }
172
- lastIdB = msg.data.id
173
- }
168
+ orderB.observe(msg.data.id)
174
169
  uniqueIdsB.add(msg.data.id)
175
170
  }
176
171
  })
177
172
 
178
173
  const uniqueIdsCountA = uniqueIdsA.size;
179
174
  const uniqueIdsCountB = uniqueIdsB.size;
175
+ console.log(`Unique IDs: groupA=${uniqueIdsCountA} groupB=${uniqueIdsCountB}`)
180
176
  return { success: uniqueIdsCountA === messagesToPush && uniqueIdsCountB === messagesToPush, message: 'Load test completed successfully' }
181
177
  }
@@ -6,8 +6,8 @@ export async function test_maintenance_mode(client = null) {
6
6
 
7
7
  const queen = client
8
8
 
9
- // Use port 6632 (default Queen port) for API calls
10
- const API_URL = 'http://localhost:6632';
9
+ // Default Queen port; override with QUEEN_SERVER_URL (same as run.js).
10
+ const API_URL = process.env.QUEEN_SERVER_URL || 'http://localhost:6632';
11
11
  const QUEUE_NAME = 'test-maintenance-queue';
12
12
  const MESSAGES_PER_SECOND = 10;
13
13
  const MAINTENANCE_DURATION_SECONDS = 10;