queen-mq 2.0.4 → 2.1.0

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,277 @@
1
+ /**
2
+ * Locks integration suite: `queen.lock`, `queen.semaphore`, `queen.locks`,
3
+ * `kv.check` and `transaction().guard(lock)` against a real broker.
4
+ *
5
+ * Repeatable for the reason kv.js gives: every run starts on a fresh broker.
6
+ * Every lock here is named under `test-` and carries a lifetime, so a run that
7
+ * goes wrong leaves nothing that does not expire by itself.
8
+ *
9
+ * What only a real broker can show, and what these are for:
10
+ * * one holder: two handles, one permit;
11
+ * * the retry of a call whose answer was lost comes back with the SAME
12
+ * permit (the owner);
13
+ * * an expired lock goes to the next handle with a HIGHER token, and the
14
+ * old handle's guarded step commits nothing — the message is not pushed;
15
+ * * a guarded step survives the lock's own renewal;
16
+ * * a semaphore never grants more than its limit to a crowd.
17
+ */
18
+
19
+ import { sleep } from './_kvtimers.js'
20
+
21
+ const fail = (message) => ({ success: false, message })
22
+
23
+ /** A broker alone raises its cluster version to 5 on its first tick; `check` needs it. */
24
+ async function checksAreServed(client) {
25
+ for (let i = 0; i < 100; i++) {
26
+ try {
27
+ await client.kv.check('test-locks-probe', 'k', { expect: 0 })
28
+ return
29
+ } catch (error) {
30
+ if (error.status !== 503) throw error
31
+ await sleep(100)
32
+ }
33
+ }
34
+ throw new Error('the broker never served a check (cluster version below 5?)')
35
+ }
36
+
37
+ async function drain(client, queue) {
38
+ const seen = []
39
+ for (;;) {
40
+ const msgs = await client.queue(queue).batch(50).wait(false).pop()
41
+ if (!msgs || msgs.length === 0) return seen
42
+ seen.push(...msgs.map(m => m.data))
43
+ await client.ack(msgs, true)
44
+ }
45
+ }
46
+
47
+ export async function lockHasOneHolderAndAGuardedStepCommits(client) {
48
+ await checksAreServed(client)
49
+ const name = 'test-lock/one-holder'
50
+ const a = client.lock(name, { ttl: '30s' })
51
+ const b = client.lock(name, { ttl: '30s' })
52
+ try {
53
+ if ((await a.acquire()) !== true) return fail('the first handle did not acquire')
54
+ if ((await b.acquire()) !== false) return fail('a second handle acquired a held lock')
55
+ if (a.token === null || a.slot !== 0) return fail(`the holder has no token or slot: ${a.token} ${a.slot}`)
56
+
57
+ const who = await client.locks.get(name)
58
+ if (who.held !== true || who.holders.length !== 1 || who.holders[0].owner !== a.owner ||
59
+ who.holders[0].token !== a.token || !who.holders[0].expiresAt) {
60
+ return fail(`get does not show the holder: ${JSON.stringify(who)}`)
61
+ }
62
+
63
+ // The permit is a KV row and nothing else.
64
+ const row = await client.kv.get('queen-locks', `${name}#0`)
65
+ if (row.found !== true || row.version !== a.token || row.value.owner !== a.owner) {
66
+ return fail(`the permit's row is not what the lock says: ${JSON.stringify(row)}`)
67
+ }
68
+
69
+ const queue = 'test-lock-one-holder'
70
+ const res = await client.transaction()
71
+ .guard(a)
72
+ .queue(queue).push([{ data: { step: 1 } }])
73
+ .commit()
74
+ if (res.success !== true) return fail(`the holder's guarded step did not commit: ${JSON.stringify(res)}`)
75
+ const got = await drain(client, queue)
76
+ if (got.length !== 1 || got[0].step !== 1) return fail(`expected the one guarded message, got ${JSON.stringify(got)}`)
77
+
78
+ if ((await a.release()) !== true) return fail('release did not remove the permit')
79
+ if ((await b.acquire()) !== true) return fail('the lock was not free after its release')
80
+ return { success: true, message: 'one holder, get and the KV row agree, a guarded step commits, release frees it' }
81
+ } finally {
82
+ await a.release().catch(() => {})
83
+ await b.release().catch(() => {})
84
+ }
85
+ }
86
+
87
+ export async function lockRetryWithTheSameOwnerIsTheSamePermit(client) {
88
+ const name = 'test-lock/retry-owner'
89
+ const owner = `test-owner-${Date.now()}`
90
+ // What a client does when the answer to its acquire never arrived: the same
91
+ // call again.
92
+ const first = await client.locks.acquire(name, { ttl: '30s', owner })
93
+ const again = await client.locks.acquire(name, { ttl: '30s', owner })
94
+ if (first.acquired !== true || again.acquired !== true) {
95
+ return fail(`both attempts must answer the permit: ${JSON.stringify([first, again])}`)
96
+ }
97
+ if (again.already !== true || again.token !== first.token) {
98
+ return fail(`the retry must answer the SAME permit, marked already: ${JSON.stringify(again)}`)
99
+ }
100
+ // The same for a renew: its answer is lost, the old token is sent again.
101
+ const renewed = await client.locks.renew(name, { token: first.token, ttl: '30s', owner })
102
+ const resent = await client.locks.renew(name, { token: first.token, ttl: '30s', owner })
103
+ if (renewed.renewed !== true || resent.renewed !== true || !(resent.token > renewed.token)) {
104
+ return fail(`a renew sent again by its owner must be carried through: ${JSON.stringify([renewed, resent])}`)
105
+ }
106
+ // Somebody else's stale token is not.
107
+ const stranger = await client.locks.renew(name, { token: first.token, ttl: '30s', owner: 'somebody-else' })
108
+ if (stranger.renewed !== false || stranger.reason !== 'lost' || stranger.holders[0].owner !== owner) {
109
+ return fail(`a stranger's stale token must be lost: ${JSON.stringify(stranger)}`)
110
+ }
111
+ const done = await client.locks.release(name, { token: resent.token })
112
+ return {
113
+ success: done.released === true,
114
+ message: 'acquire and renew sent again by their owner answer the permit; a stranger\'s stale token is lost'
115
+ }
116
+ }
117
+
118
+ export async function lockExpiredIsTakenOverAndTheOldHolderCommitsNothing(client) {
119
+ await checksAreServed(client)
120
+ const name = 'test-lock/expiry'
121
+ const queue = 'test-lock-expiry'
122
+ // The old holder does not renew: it is "paused" for longer than its lease.
123
+ const old = client.lock(name, { ttl: '1s', autoRenew: false })
124
+ const next = client.lock(name, { ttl: '30s' })
125
+ try {
126
+ if (!(await old.acquire())) return fail('the first holder did not acquire')
127
+ const oldToken = old.token
128
+ const staleGuard = old.guard()
129
+ await sleep(1300)
130
+
131
+ if (!(await next.acquire())) return fail('an expired lock was not free for the next holder')
132
+ if (!(next.token > oldToken)) return fail(`the next holder's token ${next.token} is not above ${oldToken}`)
133
+
134
+ // The old holder wakes up and sends the step it was about to send.
135
+ const stale = await client.transaction()
136
+ .kv.check(staleGuard.ns, staleGuard.key, { expect: staleGuard.expect, required: true })
137
+ .queue(queue).push([{ data: { from: 'old' } }])
138
+ .commit()
139
+ if (stale.success !== false || stale.reason !== 'kv_precondition') {
140
+ return fail(`the old holder's step must roll back: ${JSON.stringify(stale)}`)
141
+ }
142
+ if (old.held !== false) return fail('a handle past its lifetime must not claim to hold')
143
+
144
+ const ok = await client.transaction().guard(next).queue(queue).push([{ data: { from: 'next' } }]).commit()
145
+ if (ok.success !== true) return fail(`the new holder's step did not commit: ${JSON.stringify(ok)}`)
146
+
147
+ const got = await drain(client, queue)
148
+ if (got.length !== 1 || got[0].from !== 'next') {
149
+ return fail(`only the new holder's message may exist, got ${JSON.stringify(got)}`)
150
+ }
151
+ return { success: true, message: 'takeover with a higher token; the old holder\'s guarded step pushed nothing' }
152
+ } finally {
153
+ await next.release().catch(() => {})
154
+ }
155
+ }
156
+
157
+ export async function lockGuardedStepSurvivesItsOwnRenewal(client) {
158
+ await checksAreServed(client)
159
+ const name = 'test-lock/renew-under-load'
160
+ const queue = 'test-lock-renew'
161
+ // A short lease renewed every 100 ms, and steps committed as fast as they
162
+ // go: some of them are judged after a renew moved the token.
163
+ const lock = client.lock(name, { ttl: '2s', renewEvery: '100ms' })
164
+ try {
165
+ if (!(await lock.acquire())) return fail('did not acquire')
166
+ const first = lock.token
167
+ let committed = 0
168
+ const end = Date.now() + 1500
169
+ while (Date.now() < end) {
170
+ const res = await client.transaction()
171
+ .guard(lock)
172
+ .queue(queue).push([{ data: { n: committed } }])
173
+ .commit()
174
+ if (res.success !== true) return fail(`step ${committed} did not commit while the lock was held: ${JSON.stringify(res)}`)
175
+ committed++
176
+ }
177
+ if (!(lock.token > first)) return fail('the lock never renewed during the run; the test proved nothing')
178
+ if (lock.held !== true) return fail('the lock was lost under its own renewal')
179
+ const got = await drain(client, queue)
180
+ if (got.length !== committed) return fail(`${committed} steps committed, ${got.length} messages exist`)
181
+ return { success: true, message: `${committed} guarded steps across ${lock.token - first > 0 ? 'several' : 'no'} renewals, each committed once` }
182
+ } finally {
183
+ await lock.release().catch(() => {})
184
+ }
185
+ }
186
+
187
+ export async function lockRunHoldsForTheWorkAndWaitsItsTurn(client) {
188
+ const name = 'test-lock/run'
189
+ const order = []
190
+ const work = (id, ms) => async () => {
191
+ order.push(`${id}:in`)
192
+ await sleep(ms)
193
+ order.push(`${id}:out`)
194
+ return id
195
+ }
196
+ const a = client.lock(name, { ttl: '10s' })
197
+ const b = client.lock(name, { ttl: '10s' })
198
+ const [ra, rb] = await Promise.all([
199
+ a.run(work('a', 300), { wait: '10s' }),
200
+ b.run(work('b', 50), { wait: '10s' })
201
+ ])
202
+ if (!ra.acquired || !rb.acquired) return fail(`both must get their turn: ${JSON.stringify([ra, rb])}`)
203
+ // Never interleaved: in, out, in, out.
204
+ const interleaved = order[0].split(':')[0] !== order[1].split(':')[0]
205
+ if (interleaved || order.length !== 4) return fail(`the two runs overlapped: ${order.join(' ')}`)
206
+ const none = await client.lock(name, { ttl: '10s' }).run(async () => {
207
+ return client.lock(name, { ttl: '10s' }).run(async () => 'inner')
208
+ })
209
+ if (none.value.acquired !== false) return fail('a second handle ran inside the first one\'s hold')
210
+ const free = await client.locks.get(name)
211
+ return {
212
+ success: free.held === false,
213
+ message: `two runs took turns (${order.join(' ')}), and the lock is free afterwards`
214
+ }
215
+ }
216
+
217
+ export async function semaphoreNeverGrantsMoreThanItsLimit(client) {
218
+ const name = 'test-sem/crowd'
219
+ const LIMIT = 3
220
+ const permits = Array.from({ length: 12 }, () => client.semaphore(name, LIMIT, { ttl: '30s' }))
221
+ try {
222
+ const got = await Promise.all(permits.map(p => p.acquire()))
223
+ let holders = permits.filter((_, i) => got[i])
224
+ if (holders.length < 1 || holders.length > LIMIT) return fail(`${holders.length} permits granted of ${LIMIT}`)
225
+ // A crowd can leave a permit free (each gives up after a few lost races):
226
+ // one at a time, it fills.
227
+ for (const p of permits) {
228
+ if (holders.length === LIMIT) break
229
+ if (!p.held && await p.acquire()) holders.push(p)
230
+ }
231
+ if (holders.length !== LIMIT) return fail(`the semaphore did not fill: ${holders.length} of ${LIMIT}`)
232
+ const slots = new Set(holders.map(p => p.slot))
233
+ if (slots.size !== LIMIT) return fail(`two holders share a slot: ${[...slots]}`)
234
+
235
+ const extra = client.semaphore(name, LIMIT, { ttl: '30s' })
236
+ if (await extra.acquire()) return fail('a permit past the limit was granted')
237
+ const who = await client.locks.get(name)
238
+ if (who.holders.length !== LIMIT) return fail(`get shows ${who.holders.length} holders of ${LIMIT}`)
239
+
240
+ // One leaves, the waiter gets exactly that slot.
241
+ const freed = holders[0].slot
242
+ const waiting = extra.acquire({ wait: '5s' })
243
+ await sleep(200)
244
+ await holders[0].release()
245
+ if (!(await waiting)) return fail('a waiter did not get the freed permit')
246
+ if (extra.slot !== freed) return fail(`the waiter got slot ${extra.slot}, not the freed ${freed}`)
247
+ await extra.release()
248
+ return { success: true, message: `a crowd of 12 on ${LIMIT} permits: never more than ${LIMIT}, a freed slot goes to the waiter` }
249
+ } finally {
250
+ await Promise.all(permits.map(p => p.release().catch(() => {})))
251
+ }
252
+ }
253
+
254
+ export async function kvCheckLooksAndGatesWithoutWriting(client) {
255
+ await checksAreServed(client)
256
+ const ns = 'test-kv-check'
257
+ const put = await client.kv.put(ns, 'state', { n: 1 }, { ttl: '10m' })
258
+ const held = await client.kv.check(ns, 'state', { expect: put.version })
259
+ if (held.applied !== true || held.version !== put.version || 'value' in held) {
260
+ return fail(`a held check answers its version and no value: ${JSON.stringify(held)}`)
261
+ }
262
+ const stale = await client.kv.check(ns, 'state', { expect: put.version + 1000 })
263
+ if (stale.applied !== false || stale.reason !== 'version' || stale.value.n !== 1) {
264
+ return fail(`a lost check answers what a reader sees: ${JSON.stringify(stale)}`)
265
+ }
266
+ const absent = await client.kv.check(ns, 'nobody', { expect: 0 })
267
+ if (absent.applied !== true) return fail(`expect: 0 on an absent key must hold: ${JSON.stringify(absent)}`)
268
+ const row = await client.kv.get(ns, 'state')
269
+ if (row.version !== put.version) return fail('a check moved the version it read')
270
+
271
+ // The gate: a batch-wide precondition on a key the batch does not write.
272
+ const gated = await client.kv.check(ns, 'state', { expect: put.version + 1000, required: true })
273
+ if (gated.applied !== false || gated.precondition !== true) {
274
+ return fail(`a required check that loses is a precondition verdict: ${JSON.stringify(gated)}`)
275
+ }
276
+ return { success: true, message: 'check holds, loses with the current row, takes expect:0, and never writes' }
277
+ }
package/test-v2/run.js CHANGED
@@ -17,6 +17,7 @@ import * as semanticsTests from './semantics.js'
17
17
  import * as ackWindowTests from './ackwindow.js'
18
18
  import * as kvTests from './kv.js'
19
19
  import * as timerTests from './timers.js'
20
+ import * as lockTests from './locks.js'
20
21
  import * as streamTests from './stream/index.js'
21
22
  import * as docsTests from './docs.js'
22
23
  import { LoadBalancer } from '../client-v2/http/LoadBalancer.js';
@@ -115,6 +116,7 @@ async function main() {
115
116
  ackWindowTests,
116
117
  kvTests,
117
118
  timerTests,
119
+ lockTests,
118
120
  docsTests
119
121
  ]
120
122