queen-mq 1.0.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 +54 -0
- package/client-v2/README.md +32 -0
- package/client-v2/builders/QueueBuilder.js +8 -16
- package/client-v2/builders/TimerBuilder.js +262 -0
- package/client-v2/builders/TransactionBuilder.js +185 -8
- package/client-v2/kv/Kv.js +432 -0
- package/client-v2/kv/expiry.js +148 -0
- package/package.json +11 -3
- package/test-v2/_kvtimers.js +71 -0
- package/test-v2/ackwindow.js +10 -3
- package/test-v2/consume.js +0 -2
- package/test-v2/docs.js +204 -0
- package/test-v2/http-unit/retry429.test.js +6 -1
- 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/pop.js +0 -2
- package/test-v2/push.js +0 -4
- package/test-v2/run.js +34 -4
- package/test-v2/semantics.js +16 -7
- package/test-v2/stream/_helpers.js +7 -0
- 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 +6 -6
- package/test-v2/timers.js +209 -0
- package/test-v2/transaction.js +4 -3
|
@@ -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/pop.js
CHANGED
|
@@ -25,13 +25,11 @@ export async function popNonEmptyQueue(client) {
|
|
|
25
25
|
// wait(false) wildcard pop can race past the not-yet-committed lookup
|
|
26
26
|
// row. Long-poll re-runs the candidate scan and picks up the row
|
|
27
27
|
// once it commits (typically within ms).
|
|
28
|
-
// docs:start(js-pop)
|
|
29
28
|
const res = await client
|
|
30
29
|
.queue('test-queue-v2-pop-non-empty')
|
|
31
30
|
.batch(1)
|
|
32
31
|
.wait(true)
|
|
33
32
|
.pop()
|
|
34
|
-
// docs:end
|
|
35
33
|
return { success: res.length === 1 }
|
|
36
34
|
}
|
|
37
35
|
|
package/test-v2/push.js
CHANGED
|
@@ -5,11 +5,9 @@ export async function pushMessage(client) {
|
|
|
5
5
|
if (!queue.configured) {
|
|
6
6
|
return { success: false, message: 'Queue not created' }
|
|
7
7
|
}
|
|
8
|
-
// docs:start(js-push)
|
|
9
8
|
const res = await client
|
|
10
9
|
.queue('test-queue-v2')
|
|
11
10
|
.push([{ data: { message: 'Hello, world!' } }])
|
|
12
|
-
// docs:end
|
|
13
11
|
|
|
14
12
|
return { success: res[0].status === 'queued' }
|
|
15
13
|
}
|
|
@@ -19,7 +17,6 @@ export async function pushDuplicateMessage(client) {
|
|
|
19
17
|
if (!queue.configured) {
|
|
20
18
|
return { success: false, message: 'Queue not created' }
|
|
21
19
|
}
|
|
22
|
-
// docs:start(js-push-dedup)
|
|
23
20
|
const res1 = await client
|
|
24
21
|
.queue('test-queue-v2')
|
|
25
22
|
.push([{ transactionId: 'test-transaction-id', data: { message: 'Hello, world!' } }])
|
|
@@ -28,7 +25,6 @@ export async function pushDuplicateMessage(client) {
|
|
|
28
25
|
.queue('test-queue-v2')
|
|
29
26
|
.push([{ transactionId: 'test-transaction-id', data: { message: 'Hello, world!' } }])
|
|
30
27
|
// res1[0].status === 'queued', res2[0].status === 'duplicate'
|
|
31
|
-
// docs:end
|
|
32
28
|
|
|
33
29
|
return { success: res1[0].status === 'queued' && res2[0].status === 'duplicate' }
|
|
34
30
|
}
|
package/test-v2/run.js
CHANGED
|
@@ -17,7 +17,10 @@ import * as watermarkTests from './watermark.js'
|
|
|
17
17
|
import * as authTests from './auth.js'
|
|
18
18
|
import * as semanticsTests from './semantics.js'
|
|
19
19
|
import * as ackWindowTests from './ackwindow.js'
|
|
20
|
+
import * as kvTests from './kv.js'
|
|
21
|
+
import * as timerTests from './timers.js'
|
|
20
22
|
import * as streamTests from './stream/index.js'
|
|
23
|
+
import * as docsTests from './docs.js'
|
|
21
24
|
import { LoadBalancer } from '../client-v2/http/LoadBalancer.js';
|
|
22
25
|
|
|
23
26
|
|
|
@@ -90,8 +93,10 @@ function printResults() {
|
|
|
90
93
|
}
|
|
91
94
|
|
|
92
95
|
export const cleanupTestData = async () => {
|
|
93
|
-
// All the LIKE patterns test queues use.
|
|
94
|
-
|
|
96
|
+
// All the LIKE patterns test queues use. The three exact names are the
|
|
97
|
+
// documentation queues (test-v2/docs.js): purging them here is what lets
|
|
98
|
+
// the published dedup snippet keep a fixed transactionId across runs.
|
|
99
|
+
const patterns = ['test-%', 'edge-%', 'pattern-%', 'workflow-%', 'orders', 'payments', 'invoices'];
|
|
95
100
|
try {
|
|
96
101
|
// Drop streaming queries first (CASCADE removes their state rows).
|
|
97
102
|
// Safe even when queen_streams isn't installed yet — we swallow the
|
|
@@ -125,7 +130,29 @@ export const cleanupTestData = async () => {
|
|
|
125
130
|
|
|
126
131
|
await dbPool.query(`DELETE FROM queen.queues WHERE name LIKE ANY($1::text[])`, [patterns]);
|
|
127
132
|
|
|
128
|
-
|
|
133
|
+
// KV keys and pending timers (PLAN_KV_TIMERS.md §10.4). NOT cosmetic:
|
|
134
|
+
// without this purge a putIfAbsent test is green on its first run and red
|
|
135
|
+
// forever after, an incr test accumulates between runs, and a timer left
|
|
136
|
+
// pending by an earlier run fires into a later one and shows up as a
|
|
137
|
+
// phantom message in an unrelated test. Neither table has a foreign key
|
|
138
|
+
// to queen.queues -- log_timers is keyed by NAMES on purpose -- so the
|
|
139
|
+
// queue delete above does not reach them.
|
|
140
|
+
//
|
|
141
|
+
// Both are deleted across every tenant: a test rig may run with
|
|
142
|
+
// QUEEN_TENANCY_HEADER on, and the rows to purge are identified by the
|
|
143
|
+
// test naming convention, never by tenant.
|
|
144
|
+
//
|
|
145
|
+
// These two used to be wrapped in a swallowing try/catch, on the grounds
|
|
146
|
+
// that a broker booted with the kv/timer flags off had never applied
|
|
147
|
+
// 024_kv.sql / 025_timers.sql. There are no such flags: schema.rs applies
|
|
148
|
+
// both on every boot, so a missing `queen.kv` or `queen.log_timers` is a
|
|
149
|
+
// broken rig and must be loud. Swallowing it would leave the purge silently
|
|
150
|
+
// undone, which is exactly the failure the purge exists to prevent -- a
|
|
151
|
+
// putIfAbsent test green on its first run and red forever after.
|
|
152
|
+
await dbPool.query(`DELETE FROM queen.kv WHERE namespace LIKE ANY($1::text[])`, [patterns]);
|
|
153
|
+
await dbPool.query(`DELETE FROM queen.log_timers WHERE queue LIKE ANY($1::text[])`, [patterns]);
|
|
154
|
+
|
|
155
|
+
log(true, 'Test data cleaned up (rows + segments + kv + timers)');
|
|
129
156
|
} catch (error) {
|
|
130
157
|
log(false, `Cleanup error: ${error.message}`);
|
|
131
158
|
}
|
|
@@ -156,7 +183,10 @@ async function main() {
|
|
|
156
183
|
watermarkTests,
|
|
157
184
|
authTests,
|
|
158
185
|
semanticsTests,
|
|
159
|
-
ackWindowTests
|
|
186
|
+
ackWindowTests,
|
|
187
|
+
kvTests,
|
|
188
|
+
timerTests,
|
|
189
|
+
docsTests
|
|
160
190
|
]
|
|
161
191
|
|
|
162
192
|
const aiTests = [
|
package/test-v2/semantics.js
CHANGED
|
@@ -47,7 +47,11 @@ const baseUrl = () => TEST_CONFIG.baseUrls[0]
|
|
|
47
47
|
const sleep = ms => new Promise(r => setTimeout(r, ms))
|
|
48
48
|
const uniq = prefix => `test-sem-${prefix}-${Date.now()}`
|
|
49
49
|
|
|
50
|
-
/**
|
|
50
|
+
/**
|
|
51
|
+
* Raw pop via fetch — used where the client builder has no knob (leaseSeconds).
|
|
52
|
+
* Callers that name a consumer group and expect the backlog they pushed must
|
|
53
|
+
* pass subscriptionMode: 'all' (the broker default seeds new groups at the tail).
|
|
54
|
+
*/
|
|
51
55
|
async function rawPop(queue, params = {}) {
|
|
52
56
|
const qs = new URLSearchParams({ batch: '1', wait: 'false', ...params })
|
|
53
57
|
const res = await fetch(`${baseUrl()}/api/v1/pop/queue/${queue}?${qs}`)
|
|
@@ -58,10 +62,11 @@ async function rawPop(queue, params = {}) {
|
|
|
58
62
|
}
|
|
59
63
|
|
|
60
64
|
/** Pop retrying briefly — rides out push→visibility latency (fusion hold). */
|
|
61
|
-
async function popRetry(client, queue, { group = null, batch = 1, tries = 20 } = {}) {
|
|
65
|
+
async function popRetry(client, queue, { group = null, batch = 1, tries = 20, mode = null } = {}) {
|
|
62
66
|
for (let i = 0; i < tries; i++) {
|
|
63
67
|
let b = client.queue(queue).batch(batch).wait(false)
|
|
64
68
|
if (group) b = b.group(group)
|
|
69
|
+
if (mode) b = b.subscriptionMode(mode)
|
|
65
70
|
const msgs = await b.pop()
|
|
66
71
|
if (msgs.length > 0) return msgs
|
|
67
72
|
await sleep(150)
|
|
@@ -374,8 +379,9 @@ export async function leasedBacklogNotStrandedByEmptyPolls(client) {
|
|
|
374
379
|
await client.queue(queue).partition('Default')
|
|
375
380
|
.push([{ data: { n: 1 }, transactionId: `${queue}-tx` }])
|
|
376
381
|
|
|
377
|
-
// Worker A claims the only partition and never acks.
|
|
378
|
-
|
|
382
|
+
// Worker A claims the only partition and never acks. It is the group's
|
|
383
|
+
// first contact and the message is already there, so it must ask for 'all'.
|
|
384
|
+
const a = await popRetry(client, queue, { group, mode: 'all' })
|
|
379
385
|
if (a.length !== 1) return { success: false, message: 'Worker A did not get the message' }
|
|
380
386
|
|
|
381
387
|
// Worker B (same group) polls empty repeatedly while A holds the lease.
|
|
@@ -437,7 +443,10 @@ export async function pushOnlyQueueIsDiscoverable(client) {
|
|
|
437
443
|
// (b) Namespace-discovery pop must find it.
|
|
438
444
|
let found = false
|
|
439
445
|
for (let i = 0; i < 10 && !found; i++) {
|
|
440
|
-
const qs = new URLSearchParams({
|
|
446
|
+
const qs = new URLSearchParams({
|
|
447
|
+
namespace: ns, batch: '1', wait: 'false',
|
|
448
|
+
consumerGroup: `${ns}-cg`, subscriptionMode: 'all'
|
|
449
|
+
})
|
|
441
450
|
const popRes = await fetch(`${baseUrl()}/api/v1/pop?${qs}`)
|
|
442
451
|
if (popRes.status === 200) {
|
|
443
452
|
const pb = await popRes.json()
|
|
@@ -478,7 +487,7 @@ export async function messageDetailKeepsFullShape(client) {
|
|
|
478
487
|
.push([{ data: { hello: 'world' }, transactionId: tx }])
|
|
479
488
|
|
|
480
489
|
// Pop (don't ack) so a consumer row exists for consumerGroups[].
|
|
481
|
-
const msgs = await popRetry(client, queue, { group })
|
|
490
|
+
const msgs = await popRetry(client, queue, { group, mode: 'all' })
|
|
482
491
|
if (msgs.length !== 1) return { success: false, message: 'Message not delivered' }
|
|
483
492
|
const pid = msgs[0].partitionId
|
|
484
493
|
|
|
@@ -773,7 +782,7 @@ export async function popLeaseSecondsOverride(client) {
|
|
|
773
782
|
// Claim with a 1-second per-request override.
|
|
774
783
|
let got = []
|
|
775
784
|
for (let i = 0; i < 20 && got.length === 0; i++) {
|
|
776
|
-
got = await rawPop(queue, { consumerGroup: group, leaseSeconds: '1' })
|
|
785
|
+
got = await rawPop(queue, { consumerGroup: group, leaseSeconds: '1', subscriptionMode: 'all' })
|
|
777
786
|
if (got.length === 0) await sleep(150)
|
|
778
787
|
}
|
|
779
788
|
if (got.length !== 1) return { success: false, message: 'Message not delivered on override pop' }
|