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.
- package/README.md +175 -0
- package/client-v2/Queen.js +125 -15
- package/client-v2/README.md +69 -0
- package/client-v2/builders/QueueBuilder.js +18 -20
- package/client-v2/builders/TimerBuilder.js +262 -0
- package/client-v2/builders/TransactionBuilder.js +199 -10
- package/client-v2/consumer/ConsumerManager.js +64 -12
- package/client-v2/http/HttpClient.js +382 -32
- package/client-v2/kv/Kv.js +432 -0
- package/client-v2/kv/expiry.js +148 -0
- package/client-v2/streams/runtime/Runner.js +45 -0
- package/client-v2/utils/defaults.js +20 -1
- package/package.json +12 -3
- package/test-v2/_kvtimers.js +71 -0
- package/test-v2/ackwindow.js +265 -0
- package/test-v2/auth.js +65 -149
- package/test-v2/docs.js +204 -0
- package/test-v2/http-unit/hostHeader.test.js +411 -0
- package/test-v2/http-unit/retry429.test.js +319 -0
- package/test-v2/kv-unit/_planServer.js +65 -0
- package/test-v2/kv-unit/kvWire.test.js +377 -0
- package/test-v2/kv-unit/timerWire.test.js +177 -0
- package/test-v2/kv-unit/txnWire.test.js +222 -0
- package/test-v2/kv.js +273 -0
- package/test-v2/load.js +37 -41
- package/test-v2/maintenance.js +2 -2
- package/test-v2/push.js +25 -35
- package/test-v2/run.js +67 -5
- package/test-v2/semantics.js +801 -0
- package/test-v2/stream/_helpers.js +8 -1
- package/test-v2/stream/combined.js +4 -3
- package/test-v2/stream/cron.js +1 -1
- package/test-v2/stream/eventTime.js +8 -5
- package/test-v2/stream/operators.js +5 -5
- package/test-v2/stream/recovery.js +4 -1
- package/test-v2/stream/session.js +3 -3
- package/test-v2/stream/sliding.js +1 -1
- package/test-v2/stream/throughput.js +3 -2
- package/test-v2/stream/tumbling.js +16 -12
- package/test-v2/streams-unit/ack.test.js +203 -0
- package/test-v2/streams-unit/e2e.test.js +4 -1
- package/test-v2/timers.js +209 -0
- package/test-v2/transaction.js +4 -1
- 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
|
-
|
|
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
|
-
.
|
|
53
|
+
.idleMillis(5000)
|
|
30
54
|
.consume(async msgs => {
|
|
31
55
|
for (const msg of msgs) {
|
|
32
|
-
|
|
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
|
-
|
|
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
|
-
.
|
|
102
|
+
.idleMillis(5000)
|
|
86
103
|
.consume(async msgs => {
|
|
87
104
|
for (const msg of msgs) {
|
|
88
|
-
|
|
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
|
-
|
|
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
|
-
.
|
|
147
|
+
.idleMillis(5000)
|
|
139
148
|
.consume(async msgs => {
|
|
140
149
|
for (const msg of msgs) {
|
|
141
|
-
|
|
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
|
-
|
|
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
|
-
.
|
|
165
|
+
.idleMillis(5000)
|
|
164
166
|
.consume(async msgs => {
|
|
165
167
|
for (const msg of msgs) {
|
|
166
|
-
|
|
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
|
}
|
package/test-v2/maintenance.js
CHANGED
|
@@ -6,8 +6,8 @@ export async function test_maintenance_mode(client = null) {
|
|
|
6
6
|
|
|
7
7
|
const queen = client
|
|
8
8
|
|
|
9
|
-
//
|
|
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;
|