queen-mq 2.0.3 → 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.
- package/README.md +100 -0
- package/client-v2/Queen.js +84 -0
- package/client-v2/builders/QueueBuilder.js +8 -0
- package/client-v2/builders/TransactionBuilder.js +105 -17
- package/client-v2/consumer/ConsumerManager.js +8 -0
- package/client-v2/consumer/Supervision.js +78 -0
- package/client-v2/http/HttpClient.js +2 -2
- package/client-v2/index.js +5 -0
- package/client-v2/kv/Kv.js +41 -0
- package/client-v2/locks/Locks.js +544 -0
- package/package.json +2 -2
- package/test-v2/consumer-unit/supervision.test.js +97 -0
- package/test-v2/kv-unit/locksWire.test.js +541 -0
- package/test-v2/locks.js +277 -0
- package/test-v2/run.js +2 -0
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Locks: the wire contract of `POST /api/v1/locks`, the `check` KV op, and
|
|
3
|
+
* what the lock handle does around them.
|
|
4
|
+
*
|
|
5
|
+
* Same method as kvWire.test.js: a scripted plan server and deepEqual on the
|
|
6
|
+
* EXACT request body, because an extra field is as much a contract break as a
|
|
7
|
+
* missing one.
|
|
8
|
+
*
|
|
9
|
+
* What lives in the CLIENT and nowhere else, and is pinned here:
|
|
10
|
+
* * `acquire()` resolves a BOOLEAN (every other verdict of this SDK is an
|
|
11
|
+
* object, and objects are truthy);
|
|
12
|
+
* * the handle always sends its owner, which is what makes a retry of a
|
|
13
|
+
* call whose answer was lost come back with the same permit;
|
|
14
|
+
* * a renew's NEW token replaces the old one for the guard and the release;
|
|
15
|
+
* * a guard that lost to the handle's own renewal is sent again with the
|
|
16
|
+
* new token, and one that lost to another holder is returned and marks
|
|
17
|
+
* the lock lost;
|
|
18
|
+
* * a transaction that asked for a guard never goes out without one.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { describe, it } from 'node:test'
|
|
22
|
+
import assert from 'node:assert/strict'
|
|
23
|
+
|
|
24
|
+
import { Queen, Lock, LOCK_NOT_HELD } from '../../client-v2/index.js'
|
|
25
|
+
import { createServer } from 'node:http'
|
|
26
|
+
|
|
27
|
+
import { withPlanServer, ok, kvResults } from './_planServer.js'
|
|
28
|
+
|
|
29
|
+
const guardOf = (name, slot, token) => ({
|
|
30
|
+
op: 'check', ns: 'queen-locks', key: `${name}#${slot}`, expect: token, required: true
|
|
31
|
+
})
|
|
32
|
+
const granted = (name, token, extra = {}) => ok({
|
|
33
|
+
results: [{
|
|
34
|
+
index: 0, op: 'acquire', name, acquired: true, slot: 0, token, owner: 'o',
|
|
35
|
+
guard: guardOf(name, 0, token), ...extra
|
|
36
|
+
}]
|
|
37
|
+
})
|
|
38
|
+
const refused = (name, reason = 'held') => ok({
|
|
39
|
+
results: [{ index: 0, op: 'acquire', name, acquired: false, reason, holders: [{ slot: 0, owner: 'other' }] }]
|
|
40
|
+
})
|
|
41
|
+
const renewed = (name, token, slot = 0) => ok({
|
|
42
|
+
results: [{ index: 0, op: 'renew', name, renewed: true, slot, token, guard: guardOf(name, slot, token) }]
|
|
43
|
+
})
|
|
44
|
+
const lostRenew = (name) => ok({
|
|
45
|
+
results: [{ index: 0, op: 'renew', name, renewed: false, reason: 'lost', slot: 0, holders: [{ slot: 0, owner: 'other' }] }]
|
|
46
|
+
})
|
|
47
|
+
const released = (name, yes = true) => ok({
|
|
48
|
+
results: [yes
|
|
49
|
+
? { index: 0, op: 'release', name, released: true, slot: 0 }
|
|
50
|
+
: { index: 0, op: 'release', name, released: false, reason: 'lost', slot: 0, holders: [] }]
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
/** Run `fn(queen, hits)` against a plan server, closing the client afterwards. */
|
|
54
|
+
async function withQueen(plan, run) {
|
|
55
|
+
await withPlanServer(plan, ok({ results: [] }), async (url, hits) => {
|
|
56
|
+
const queen = new Queen({ url, handleSignals: false })
|
|
57
|
+
try {
|
|
58
|
+
await run(queen, hits)
|
|
59
|
+
} finally {
|
|
60
|
+
await queen.close()
|
|
61
|
+
}
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const opOf = (hit) => hit.body.operations[0]
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* A server that answers by what it is asked, for the tests where the ORDER of
|
|
69
|
+
* two calls in flight is the point (a background renew beside a release, a
|
|
70
|
+
* commit beside a renew) and a positional plan cannot say it.
|
|
71
|
+
* `answer({ url, body }, reply)` calls `reply(json)` now or later.
|
|
72
|
+
*/
|
|
73
|
+
async function withAnsweringServer(answer, run) {
|
|
74
|
+
const hits = []
|
|
75
|
+
const server = createServer((req, res) => {
|
|
76
|
+
let raw = ''
|
|
77
|
+
req.on('data', chunk => { raw += chunk })
|
|
78
|
+
req.on('end', () => {
|
|
79
|
+
const hit = { method: req.method, url: req.url, body: raw ? JSON.parse(raw) : null }
|
|
80
|
+
hits.push(hit)
|
|
81
|
+
answer(hit, (json) => {
|
|
82
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
83
|
+
res.end(JSON.stringify(json))
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
})
|
|
87
|
+
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve))
|
|
88
|
+
const queen = new Queen({ url: `http://127.0.0.1:${server.address().port}`, handleSignals: false })
|
|
89
|
+
try {
|
|
90
|
+
await run(queen, hits)
|
|
91
|
+
} finally {
|
|
92
|
+
await queen.close()
|
|
93
|
+
await new Promise(resolve => server.close(resolve))
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
describe('KV wire — check', () => {
|
|
98
|
+
it('check sends its expect and nothing else, and answers a WriteResult', async () => {
|
|
99
|
+
const plan = [
|
|
100
|
+
kvResults({ index: 0, op: 'check', applied: true, key: 'k', version: 7 }),
|
|
101
|
+
kvResults({ index: 0, op: 'check', applied: false, reason: 'version', key: 'k', value: { n: 2 }, version: 9 })
|
|
102
|
+
]
|
|
103
|
+
await withQueen(plan, async (queen, hits) => {
|
|
104
|
+
const held = await queen.kv.check('orders', 'k', { expect: 7 })
|
|
105
|
+
assert.deepEqual(hits[0].body, { operations: [{ op: 'check', ns: 'orders', key: 'k', expect: 7 }] })
|
|
106
|
+
assert.equal(hits[0].url, '/api/v1/kv')
|
|
107
|
+
assert.equal(held.applied, true)
|
|
108
|
+
assert.equal('value' in held, false, 'a held check hands back no value')
|
|
109
|
+
|
|
110
|
+
const stale = await queen.kv.check('orders', 'k', { expect: 7, required: true })
|
|
111
|
+
assert.deepEqual(opOf(hits[1]), { op: 'check', ns: 'orders', key: 'k', expect: 7, required: true })
|
|
112
|
+
assert.equal(stale.applied, false)
|
|
113
|
+
assert.equal(stale.reason, 'version')
|
|
114
|
+
assert.equal(stale.version, 9)
|
|
115
|
+
})
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('expect: 0 asks for absence, and a check with no expect is refused before the request', async () => {
|
|
119
|
+
await withQueen([kvResults({ index: 0, op: 'check', applied: true, key: 'k', version: 0 })], async (queen, hits) => {
|
|
120
|
+
await queen.kv.check('orders', 'k', { expect: 0 })
|
|
121
|
+
assert.deepEqual(opOf(hits[0]), { op: 'check', ns: 'orders', key: 'k', expect: 0 })
|
|
122
|
+
await assert.rejects(queen.kv.check('orders', 'k'), /check needs `expect`/)
|
|
123
|
+
await assert.rejects(queen.kv.check('orders', 'k', { expect: null }), /`expect` was written but has no value/)
|
|
124
|
+
// It writes nothing, so it takes no lifetime: none is sent even if given.
|
|
125
|
+
assert.equal(hits.length, 1)
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('check rides a transaction in the kv array, built by the same code', async () => {
|
|
130
|
+
await withQueen([ok({ success: true, results: [] })], async (queen, hits) => {
|
|
131
|
+
await queen.transaction()
|
|
132
|
+
.kv.check('queen-locks', 'job#0', { expect: 41, required: true })
|
|
133
|
+
.kv.put('work', 'state', { n: 1 }, { forever: true })
|
|
134
|
+
.commit()
|
|
135
|
+
assert.equal(hits[0].url, '/api/v1/transaction')
|
|
136
|
+
assert.deepEqual(hits[0].body.kv, [
|
|
137
|
+
{ op: 'check', ns: 'queen-locks', key: 'job#0', expect: 41, required: true },
|
|
138
|
+
{ op: 'put', ns: 'work', key: 'state', value: { n: 1 }, forever: true }
|
|
139
|
+
])
|
|
140
|
+
})
|
|
141
|
+
})
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
describe('Locks wire — the four operations', () => {
|
|
145
|
+
it('acquire, renew, release and get send exactly their fields', async () => {
|
|
146
|
+
const plan = [
|
|
147
|
+
granted('daily-report', 100),
|
|
148
|
+
renewed('daily-report', 101),
|
|
149
|
+
released('daily-report'),
|
|
150
|
+
ok({ results: [{ index: 0, op: 'get', name: 'daily-report', held: false, holders: [] }] })
|
|
151
|
+
]
|
|
152
|
+
await withQueen(plan, async (queen, hits) => {
|
|
153
|
+
const a = await queen.locks.acquire('daily-report', { ttl: '30s', owner: 'o' })
|
|
154
|
+
assert.equal(hits[0].method, 'POST')
|
|
155
|
+
assert.equal(hits[0].url, '/api/v1/locks')
|
|
156
|
+
assert.deepEqual(hits[0].body, {
|
|
157
|
+
operations: [{ op: 'acquire', name: 'daily-report', ttlSeconds: 30, owner: 'o' }]
|
|
158
|
+
})
|
|
159
|
+
assert.equal(a.acquired, true)
|
|
160
|
+
assert.equal(a.token, 100)
|
|
161
|
+
assert.deepEqual(a.guard, guardOf('daily-report', 0, 100))
|
|
162
|
+
|
|
163
|
+
const r = await queen.locks.renew('daily-report', { token: 100, ttlSeconds: 45, owner: 'o' })
|
|
164
|
+
assert.deepEqual(opOf(hits[1]), { op: 'renew', name: 'daily-report', token: 100, ttlSeconds: 45, owner: 'o' })
|
|
165
|
+
assert.equal(r.token, 101, 'a renew answers a NEW token')
|
|
166
|
+
|
|
167
|
+
const d = await queen.locks.release('daily-report', { token: 101 })
|
|
168
|
+
assert.deepEqual(opOf(hits[2]), { op: 'release', name: 'daily-report', token: 101 })
|
|
169
|
+
assert.equal(d.released, true)
|
|
170
|
+
|
|
171
|
+
const g = await queen.locks.get('daily-report')
|
|
172
|
+
assert.deepEqual(opOf(hits[3]), { op: 'get', name: 'daily-report' })
|
|
173
|
+
assert.equal(g.held, false)
|
|
174
|
+
})
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('a semaphore sends its limit and its slot; a lock sends neither', async () => {
|
|
178
|
+
const plan = [
|
|
179
|
+
ok({ results: [{ index: 0, op: 'acquire', name: 'gpu', acquired: true, slot: 2, token: 7, owner: 'o', guard: guardOf('gpu', 2, 7) }] }),
|
|
180
|
+
renewed('gpu', 8, 2),
|
|
181
|
+
ok({ results: [{ index: 0, op: 'release', name: 'gpu', released: true, slot: 2 }] })
|
|
182
|
+
]
|
|
183
|
+
await withQueen(plan, async (queen, hits) => {
|
|
184
|
+
await queen.locks.acquire('gpu', { ttlSeconds: 60, owner: 'o', limit: 4 })
|
|
185
|
+
assert.deepEqual(opOf(hits[0]), { op: 'acquire', name: 'gpu', ttlSeconds: 60, owner: 'o', limit: 4 })
|
|
186
|
+
await queen.locks.renew('gpu', { token: 7, slot: 2, ttlSeconds: 60 })
|
|
187
|
+
assert.deepEqual(opOf(hits[1]), { op: 'renew', name: 'gpu', token: 7, ttlSeconds: 60, slot: 2 })
|
|
188
|
+
await queen.locks.release('gpu', { token: 8, slot: 2 })
|
|
189
|
+
assert.deepEqual(opOf(hits[2]), { op: 'release', name: 'gpu', token: 8, slot: 2 })
|
|
190
|
+
})
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('batch carries several locks in one call and insists on aligned results', async () => {
|
|
194
|
+
const two = ok({
|
|
195
|
+
results: [
|
|
196
|
+
{ index: 0, op: 'get', name: 'a', held: false, holders: [] },
|
|
197
|
+
{ index: 1, op: 'get', name: 'b', held: false, holders: [] }
|
|
198
|
+
]
|
|
199
|
+
})
|
|
200
|
+
await withQueen([two, ok({ results: [{ index: 0, op: 'get', name: 'a', held: false, holders: [] }] })], async (queen, hits) => {
|
|
201
|
+
const ops = [{ op: 'get', name: 'a' }, { op: 'get', name: 'b' }]
|
|
202
|
+
const got = await queen.locks.batch(ops)
|
|
203
|
+
assert.deepEqual(hits[0].body, { operations: ops })
|
|
204
|
+
assert.equal(got.length, 2)
|
|
205
|
+
await assert.rejects(queen.locks.batch(ops), /expected \{"results":\[\.\.\.\]\} with 2 element/)
|
|
206
|
+
})
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
it('what the broker would refuse is refused before the request', async () => {
|
|
210
|
+
await withQueen([], async (queen, hits) => {
|
|
211
|
+
const locks = queen.locks
|
|
212
|
+
await assert.rejects(locks.acquire('a', {}), /a lifetime is required/)
|
|
213
|
+
await assert.rejects(locks.acquire('a', { forever: true }), /there is no `forever`/)
|
|
214
|
+
await assert.rejects(locks.acquire('a', { ttl: '30s', ttlSeconds: 30 }), /declared twice/)
|
|
215
|
+
await assert.rejects(locks.acquire('a', { ttlSeconds: 1.5 }), /a lifetime is required/)
|
|
216
|
+
await assert.rejects(locks.acquire('a#b', { ttl: '1s' }), /without '#'/)
|
|
217
|
+
await assert.rejects(locks.acquire('', { ttl: '1s' }), /a name is a non-empty string/)
|
|
218
|
+
await assert.rejects(locks.acquire('a', { ttl: '1s', limit: 0 }), /limit is a whole number/)
|
|
219
|
+
await assert.rejects(locks.acquire('a', { ttl: '1s', owner: '' }), /owner is a non-empty string/)
|
|
220
|
+
await assert.rejects(locks.renew('a', { ttl: '1s' }), /renew needs the token/)
|
|
221
|
+
await assert.rejects(locks.release('a', {}), /release needs the token/)
|
|
222
|
+
assert.throws(() => queen.lock('a', { ttl: '1s', limit: 3 }), /queen\.semaphore/)
|
|
223
|
+
assert.throws(() => queen.lock('a', {}), /a lifetime is required/)
|
|
224
|
+
assert.throws(() => queen.lock('a', { ttl: '3s', renewEvery: '3s' }), /renewEvery must be shorter/)
|
|
225
|
+
assert.equal(hits.length, 0)
|
|
226
|
+
})
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
it('a ttl is rounded UP to the second, never down', async () => {
|
|
230
|
+
await withQueen([granted('a', 1)], async (queen, hits) => {
|
|
231
|
+
await queen.locks.acquire('a', { ttl: '1500ms' })
|
|
232
|
+
assert.equal(opOf(hits[0]).ttlSeconds, 2)
|
|
233
|
+
})
|
|
234
|
+
})
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
describe('Lock handle', () => {
|
|
238
|
+
it('acquire resolves a boolean, and the handle always names its owner', async () => {
|
|
239
|
+
await withQueen([refused('job'), granted('job', 100), released('job')], async (queen, hits) => {
|
|
240
|
+
const lock = queen.lock('job', { ttl: '30s', autoRenew: false })
|
|
241
|
+
assert.ok(lock instanceof Lock)
|
|
242
|
+
assert.equal(lock.held, false)
|
|
243
|
+
assert.equal(lock.token, null)
|
|
244
|
+
|
|
245
|
+
assert.equal(await lock.acquire(), false, 'held by somebody else is `false`, not a truthy object')
|
|
246
|
+
const sent = opOf(hits[0])
|
|
247
|
+
assert.equal(sent.owner, lock.owner)
|
|
248
|
+
assert.match(sent.owner, /^.+:\d+:[0-9a-f]{12}$/, 'host:pid:random, unique per handle')
|
|
249
|
+
assert.deepEqual(Object.keys(sent).sort(), ['name', 'op', 'owner', 'ttlSeconds'])
|
|
250
|
+
|
|
251
|
+
assert.equal(await lock.acquire(), true)
|
|
252
|
+
assert.equal(opOf(hits[1]).owner, lock.owner, 'the same owner on the retry')
|
|
253
|
+
assert.equal(lock.held, true)
|
|
254
|
+
assert.equal(lock.token, 100)
|
|
255
|
+
assert.equal(lock.slot, 0)
|
|
256
|
+
assert.deepEqual(lock.guard(), guardOf('job', 0, 100))
|
|
257
|
+
assert.ok(lock.validUntil > Date.now() + 25_000 && lock.validUntil <= Date.now() + 30_000)
|
|
258
|
+
assert.equal(await lock.acquire(), true, 'already held: no call')
|
|
259
|
+
assert.equal(hits.length, 2)
|
|
260
|
+
|
|
261
|
+
assert.equal(await lock.release(), true)
|
|
262
|
+
assert.deepEqual(opOf(hits[2]), { op: 'release', name: 'job', token: 100, slot: 0 })
|
|
263
|
+
assert.equal(lock.held, false)
|
|
264
|
+
assert.equal(lock.signal.aborted, false, 'a release is not a loss')
|
|
265
|
+
assert.throws(() => lock.guard(), (e) => e.code === LOCK_NOT_HELD)
|
|
266
|
+
})
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
it('two handles of one lock have two owners', async () => {
|
|
270
|
+
await withQueen([], async (queen) => {
|
|
271
|
+
const a = queen.lock('job', { ttl: '30s' })
|
|
272
|
+
const b = queen.lock('job', { ttl: '30s' })
|
|
273
|
+
assert.notEqual(a.owner, b.owner)
|
|
274
|
+
assert.equal(queen.lock('job', { ttl: '30s', owner: 'cron-7' }).owner, 'cron-7')
|
|
275
|
+
})
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
it('acquire with a wait comes back until the permit is free', async () => {
|
|
279
|
+
await withQueen([refused('job'), refused('job', 'contended'), granted('job', 5), released('job')], async (queen, hits) => {
|
|
280
|
+
const lock = queen.lock('job', { ttl: '30s', autoRenew: false, retryMinMs: 5, retryMaxMs: 10 })
|
|
281
|
+
assert.equal(await lock.acquire({ wait: '5s' }), true)
|
|
282
|
+
assert.equal(hits.length, 3)
|
|
283
|
+
await lock.release()
|
|
284
|
+
})
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
it('a wait that runs out resolves false, and a signal gives it up early', async () => {
|
|
288
|
+
// The plan server's default answer is not a lock result: script every attempt.
|
|
289
|
+
const always = Array.from({ length: 50 }, () => refused('job'))
|
|
290
|
+
await withQueen(always, async (queen, hits) => {
|
|
291
|
+
const lock = queen.lock('job', { ttl: '30s', retryMinMs: 5, retryMaxMs: 10 })
|
|
292
|
+
const t0 = Date.now()
|
|
293
|
+
assert.equal(await lock.acquire({ wait: '60ms' }), false)
|
|
294
|
+
assert.ok(Date.now() - t0 >= 50 && hits.length >= 2)
|
|
295
|
+
|
|
296
|
+
const ac = new AbortController()
|
|
297
|
+
setTimeout(() => ac.abort(), 20)
|
|
298
|
+
const t1 = Date.now()
|
|
299
|
+
assert.equal(await lock.acquire({ wait: '10s', signal: ac.signal }), false)
|
|
300
|
+
assert.ok(Date.now() - t1 < 2000, 'the signal ended the wait')
|
|
301
|
+
})
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
it('a renew puts the NEW token in the guard and in the release', async () => {
|
|
305
|
+
await withQueen([granted('job', 100), renewed('job', 101), released('job')], async (queen, hits) => {
|
|
306
|
+
const lock = queen.lock('job', { ttl: '30s', autoRenew: false })
|
|
307
|
+
await lock.acquire()
|
|
308
|
+
assert.equal(await lock.renew(), true)
|
|
309
|
+
assert.deepEqual(opOf(hits[1]), { op: 'renew', name: 'job', token: 100, ttlSeconds: 30, slot: 0, owner: lock.owner })
|
|
310
|
+
assert.equal(lock.token, 101)
|
|
311
|
+
assert.deepEqual(lock.guard(), guardOf('job', 0, 101))
|
|
312
|
+
await lock.release()
|
|
313
|
+
assert.equal(opOf(hits[2]).token, 101)
|
|
314
|
+
})
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
it('renews in the background, every third of the lifetime unless told otherwise', async () => {
|
|
318
|
+
let token = 1
|
|
319
|
+
const answer = (hit, reply) => {
|
|
320
|
+
const op = opOf(hit)
|
|
321
|
+
if (op.op === 'acquire') reply(granted('job', token).body)
|
|
322
|
+
else if (op.op === 'renew') reply(renewed('job', ++token).body)
|
|
323
|
+
else reply(released('job').body)
|
|
324
|
+
}
|
|
325
|
+
await withAnsweringServer(answer, async (queen, hits) => {
|
|
326
|
+
const lock = queen.lock('job', { ttl: '2s', renewEvery: '40ms' })
|
|
327
|
+
await lock.acquire()
|
|
328
|
+
await new Promise(r => setTimeout(r, 150))
|
|
329
|
+
const renews = hits.map(opOf).filter(op => op.op === 'renew')
|
|
330
|
+
assert.ok(renews.length >= 2, `renewed in the background (${renews.length} renews)`)
|
|
331
|
+
assert.deepEqual(renews.slice(0, 2).map(op => op.token), [1, 2], 'each renew carries the token of the one before')
|
|
332
|
+
assert.equal(lock.held, true)
|
|
333
|
+
assert.equal(lock.token, token)
|
|
334
|
+
await lock.release()
|
|
335
|
+
assert.equal(opOf(hits.at(-1)).op, 'release')
|
|
336
|
+
assert.equal(opOf(hits.at(-1)).token, token, 'released with the last token')
|
|
337
|
+
const calls = hits.length
|
|
338
|
+
await new Promise(r => setTimeout(r, 100))
|
|
339
|
+
assert.equal(hits.length, calls, 'a released lock renews no more')
|
|
340
|
+
})
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
it('a renew the broker refuses is a loss: held false, signal aborted, onLost called', async () => {
|
|
344
|
+
await withQueen([granted('job', 100), lostRenew('job')], async (queen, hits) => {
|
|
345
|
+
const lock = queen.lock('job', { ttl: '30s', autoRenew: false })
|
|
346
|
+
const seen = []
|
|
347
|
+
lock.onLost(reason => seen.push(reason))
|
|
348
|
+
await lock.acquire()
|
|
349
|
+
const signal = lock.signal
|
|
350
|
+
assert.equal(await lock.renew(), false)
|
|
351
|
+
assert.equal(lock.held, false)
|
|
352
|
+
assert.equal(lock.token, null)
|
|
353
|
+
assert.equal(signal.aborted, true)
|
|
354
|
+
assert.equal(signal.reason.code, LOCK_NOT_HELD)
|
|
355
|
+
assert.deepEqual(seen, ['renew'])
|
|
356
|
+
assert.equal(await lock.release(), false, 'nothing to give back, and no call')
|
|
357
|
+
assert.equal(hits.length, 2)
|
|
358
|
+
})
|
|
359
|
+
})
|
|
360
|
+
|
|
361
|
+
it('a lifetime that runs out with no renew is a loss on this machine too', async () => {
|
|
362
|
+
await withQueen([granted('job', 100)], async (queen) => {
|
|
363
|
+
const lock = queen.lock('job', { ttl: '1s', autoRenew: false })
|
|
364
|
+
const seen = []
|
|
365
|
+
lock.onLost(reason => seen.push(reason))
|
|
366
|
+
await lock.acquire()
|
|
367
|
+
assert.equal(lock.held, true)
|
|
368
|
+
await new Promise(r => setTimeout(r, 1100))
|
|
369
|
+
assert.equal(lock.held, false, 'past its deadline a handle does not claim to hold')
|
|
370
|
+
assert.deepEqual(seen, ['expired'])
|
|
371
|
+
assert.equal(lock.signal.aborted, true)
|
|
372
|
+
})
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
it('run acquires, hands the lock to fn, and releases whatever fn does', async () => {
|
|
376
|
+
const plan = [granted('job', 100), released('job'), refused('job'), granted('job', 200), released('job')]
|
|
377
|
+
await withQueen(plan, async (queen, hits) => {
|
|
378
|
+
const lock = queen.lock('job', { ttl: '30s', autoRenew: false })
|
|
379
|
+
const out = await lock.run(async (l) => {
|
|
380
|
+
assert.equal(l.held, true)
|
|
381
|
+
return 42
|
|
382
|
+
})
|
|
383
|
+
assert.deepEqual(out, { acquired: true, value: 42 })
|
|
384
|
+
assert.equal(opOf(hits[1]).op, 'release')
|
|
385
|
+
|
|
386
|
+
assert.deepEqual(await lock.run(() => { throw new Error('never runs') }), { acquired: false })
|
|
387
|
+
|
|
388
|
+
await assert.rejects(lock.run(() => { throw new Error('boom') }), /boom/)
|
|
389
|
+
assert.equal(opOf(hits[4]).op, 'release', 'released although fn threw')
|
|
390
|
+
assert.equal(lock.held, false)
|
|
391
|
+
})
|
|
392
|
+
})
|
|
393
|
+
|
|
394
|
+
it('close() gives back what the handles still hold', async () => {
|
|
395
|
+
await withPlanServer([granted('job', 100), released('job')], ok({ results: [] }), async (url, hits) => {
|
|
396
|
+
const queen = new Queen({ url, handleSignals: false })
|
|
397
|
+
const lock = queen.lock('job', { ttl: '30s' })
|
|
398
|
+
await lock.acquire()
|
|
399
|
+
await queen.close()
|
|
400
|
+
assert.equal(hits.length, 2)
|
|
401
|
+
assert.deepEqual(opOf(hits[1]), { op: 'release', name: 'job', token: 100, slot: 0 })
|
|
402
|
+
assert.equal(lock.held, false)
|
|
403
|
+
})
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
it('a semaphore handle is one permit of several', async () => {
|
|
407
|
+
const plan = [
|
|
408
|
+
ok({ results: [{ index: 0, op: 'acquire', name: 'gpu', acquired: true, slot: 3, token: 9, owner: 'o', guard: guardOf('gpu', 3, 9) }] }),
|
|
409
|
+
ok({ results: [{ index: 0, op: 'release', name: 'gpu', released: true, slot: 3 }] })
|
|
410
|
+
]
|
|
411
|
+
await withQueen(plan, async (queen, hits) => {
|
|
412
|
+
const permit = queen.semaphore('gpu', 4, { ttl: '1m', autoRenew: false })
|
|
413
|
+
assert.equal(permit.limit, 4)
|
|
414
|
+
assert.equal(await permit.acquire(), true)
|
|
415
|
+
assert.equal(opOf(hits[0]).limit, 4)
|
|
416
|
+
assert.equal(permit.slot, 3)
|
|
417
|
+
assert.deepEqual(permit.guard(), guardOf('gpu', 3, 9))
|
|
418
|
+
await permit.release()
|
|
419
|
+
assert.deepEqual(opOf(hits[1]), { op: 'release', name: 'gpu', token: 9, slot: 3 })
|
|
420
|
+
})
|
|
421
|
+
})
|
|
422
|
+
})
|
|
423
|
+
|
|
424
|
+
describe('Transaction wire — guard(lock)', () => {
|
|
425
|
+
const committed = ok({ success: true, results: [] })
|
|
426
|
+
const lostTo = (failedIndex, kvReason, value, version) => ok({
|
|
427
|
+
success: false, reason: 'kv_precondition', error: 'QKV', results: [], ok: false,
|
|
428
|
+
failedIndex, kvReason, value, version
|
|
429
|
+
})
|
|
430
|
+
|
|
431
|
+
it('the guard is the first op of the kv rider, at the token held when commit sends', async () => {
|
|
432
|
+
await withQueen([granted('job', 100), renewed('job', 101), committed, released('job')], async (queen, hits) => {
|
|
433
|
+
const lock = queen.lock('job', { ttl: '30s', autoRenew: false })
|
|
434
|
+
await lock.acquire()
|
|
435
|
+
const txn = queen.transaction()
|
|
436
|
+
.guard(lock)
|
|
437
|
+
.queue('reports').push([{ data: { n: 1 }, transactionId: 't1' }])
|
|
438
|
+
.kv.put('work', 'state', { n: 1 }, { forever: true })
|
|
439
|
+
await lock.renew() // after the guard was asked for, before commit
|
|
440
|
+
const res = await txn.commit()
|
|
441
|
+
assert.equal(res.success, true)
|
|
442
|
+
assert.equal(hits[2].url, '/api/v1/transaction')
|
|
443
|
+
assert.deepEqual(hits[2].body.kv, [
|
|
444
|
+
guardOf('job', 0, 101),
|
|
445
|
+
{ op: 'put', ns: 'work', key: 'state', value: { n: 1 }, forever: true }
|
|
446
|
+
])
|
|
447
|
+
await lock.release()
|
|
448
|
+
})
|
|
449
|
+
})
|
|
450
|
+
|
|
451
|
+
it('a guard that lost to the lock\'s own renewal is sent again with the new token', async () => {
|
|
452
|
+
// The race, in the order that makes it: the commit is sent with token
|
|
453
|
+
// 100; the lock's renewal is sent and APPLIED while the commit is on its
|
|
454
|
+
// way; the broker then judges the commit against token 101. So the first
|
|
455
|
+
// commit is held until the renew has arrived, and answered after it.
|
|
456
|
+
let heldCommit = null
|
|
457
|
+
let commits = 0
|
|
458
|
+
const answer = (hit, reply) => {
|
|
459
|
+
if (hit.url === '/api/v1/transaction') {
|
|
460
|
+
commits++
|
|
461
|
+
if (commits === 1) heldCommit = reply
|
|
462
|
+
else reply({ success: true, results: [] })
|
|
463
|
+
return
|
|
464
|
+
}
|
|
465
|
+
const op = opOf(hit)
|
|
466
|
+
if (op.op === 'acquire') return reply(granted('job', 100).body)
|
|
467
|
+
if (op.op === 'release') return reply(released('job').body)
|
|
468
|
+
// The renew: answered first, then the commit it overtook.
|
|
469
|
+
reply(renewed('job', 101).body)
|
|
470
|
+
// Two pushed items come first in the flat index space: the guard is 2.
|
|
471
|
+
heldCommit(lostTo(2, 'version', { owner: op.owner }, 101).body)
|
|
472
|
+
}
|
|
473
|
+
await withAnsweringServer(answer, async (queen, hits) => {
|
|
474
|
+
const lock = queen.lock('job', { ttl: '30s', autoRenew: false })
|
|
475
|
+
await lock.acquire()
|
|
476
|
+
const commit = queen.transaction()
|
|
477
|
+
.guard(lock)
|
|
478
|
+
.queue('reports').push([{ data: 1, transactionId: 'a' }, { data: 2, transactionId: 'b' }])
|
|
479
|
+
.commit()
|
|
480
|
+
while (!heldCommit) await new Promise(r => setTimeout(r, 2))
|
|
481
|
+
await lock.renew()
|
|
482
|
+
const res = await commit
|
|
483
|
+
assert.equal(res.success, true, 'the step committed on the second send')
|
|
484
|
+
const sends = hits.filter(h => h.url === '/api/v1/transaction')
|
|
485
|
+
assert.equal(sends.length, 2)
|
|
486
|
+
assert.equal(sends[0].body.kv[0].expect, 100)
|
|
487
|
+
assert.equal(sends[1].body.kv[0].expect, 101)
|
|
488
|
+
assert.deepEqual(sends[1].body.operations, sends[0].body.operations, 'the same step, not a second one')
|
|
489
|
+
assert.equal(lock.held, true)
|
|
490
|
+
assert.equal(lock.token, 101)
|
|
491
|
+
})
|
|
492
|
+
})
|
|
493
|
+
|
|
494
|
+
it('a guard that lost to another holder is the verdict, and the lock reports lost', async () => {
|
|
495
|
+
const plan = [granted('job', 100), lostTo(0, 'version', { owner: 'somebody-else' }, 250)]
|
|
496
|
+
await withQueen(plan, async (queen, hits) => {
|
|
497
|
+
const lock = queen.lock('job', { ttl: '30s', autoRenew: false })
|
|
498
|
+
const seen = []
|
|
499
|
+
lock.onLost(r => seen.push(r))
|
|
500
|
+
await lock.acquire()
|
|
501
|
+
const res = await queen.transaction().guard(lock).kv.put('w', 'k', 1, { forever: true }).commit()
|
|
502
|
+
assert.equal(res.success, false, 'returned, not thrown — like once()')
|
|
503
|
+
assert.equal(res.reason, 'kv_precondition')
|
|
504
|
+
assert.equal(hits.filter(h => h.url === '/api/v1/transaction').length, 1, 'not sent again')
|
|
505
|
+
assert.equal(lock.held, false)
|
|
506
|
+
assert.deepEqual(seen, ['guard'])
|
|
507
|
+
})
|
|
508
|
+
})
|
|
509
|
+
|
|
510
|
+
it('an expired permit is a lost guard too', async () => {
|
|
511
|
+
await withQueen([granted('job', 100), lostTo(0, 'absent', null, 0)], async (queen) => {
|
|
512
|
+
const lock = queen.lock('job', { ttl: '30s', autoRenew: false })
|
|
513
|
+
await lock.acquire()
|
|
514
|
+
const res = await queen.transaction().guard(lock).kv.put('w', 'k', 1, { forever: true }).commit()
|
|
515
|
+
assert.equal(res.success, false)
|
|
516
|
+
assert.equal(lock.held, false)
|
|
517
|
+
})
|
|
518
|
+
})
|
|
519
|
+
|
|
520
|
+
it('a precondition that is not the guard\'s leaves the lock alone', async () => {
|
|
521
|
+
// kv = [guard, once]: flat index 1 is the once marker.
|
|
522
|
+
await withQueen([granted('job', 100), lostTo(1, 'exists', true, 77), released('job')], async (queen) => {
|
|
523
|
+
const lock = queen.lock('job', { ttl: '30s', autoRenew: false })
|
|
524
|
+
await lock.acquire()
|
|
525
|
+
const res = await queen.transaction().guard(lock).once('idem', 'order-1', { ttl: '1h' }).commit()
|
|
526
|
+
assert.equal(res.success, false)
|
|
527
|
+
assert.equal(lock.held, true, 'the marker lost, not the lock')
|
|
528
|
+
await lock.release()
|
|
529
|
+
})
|
|
530
|
+
})
|
|
531
|
+
|
|
532
|
+
it('a step that asked for a guard never goes out without one', async () => {
|
|
533
|
+
await withQueen([], async (queen, hits) => {
|
|
534
|
+
const lock = queen.lock('job', { ttl: '30s' })
|
|
535
|
+
const txn = queen.transaction().guard(lock).kv.put('w', 'k', 1, { forever: true })
|
|
536
|
+
await assert.rejects(txn.commit(), (e) => e.code === LOCK_NOT_HELD)
|
|
537
|
+
assert.equal(hits.length, 0)
|
|
538
|
+
assert.throws(() => queen.transaction().guard({}), /guard\(\) takes a lock/)
|
|
539
|
+
})
|
|
540
|
+
})
|
|
541
|
+
})
|