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.
@@ -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
- const patterns = ['test-%', 'edge-%', 'pattern-%', 'workflow-%'];
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
- log(true, 'Test data cleaned up (rows + segments)');
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 = [
@@ -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
- /** Raw pop via fetch — used where the client builder has no knob (leaseSeconds). */
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
- const a = await popRetry(client, queue, { group })
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({ namespace: ns, batch: '1', wait: 'false', consumerGroup: `${ns}-cg` })
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' }