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.
- package/README.md +78 -0
- package/client-v2/Queen.js +84 -0
- package/client-v2/builders/TransactionBuilder.js +105 -17
- 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/kv-unit/locksWire.test.js +541 -0
- package/test-v2/locks.js +277 -0
- package/test-v2/run.js +2 -0
|
@@ -0,0 +1,544 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Locks: a lock and a semaphore, as leases with a fencing token.
|
|
3
|
+
*
|
|
4
|
+
* const lock = queen.lock('daily-report', { ttl: '30s' })
|
|
5
|
+
* if (!(await lock.acquire())) return // somebody else has it
|
|
6
|
+
* try {
|
|
7
|
+
* await queen.transaction()
|
|
8
|
+
* .guard(lock) // commits only while the lock is ours
|
|
9
|
+
* .queue('reports').push([{ data: report }])
|
|
10
|
+
* .commit()
|
|
11
|
+
* } finally {
|
|
12
|
+
* await lock.release()
|
|
13
|
+
* }
|
|
14
|
+
*
|
|
15
|
+
* WHAT IT IS. A permit is one KV row in the namespace `queen-locks`, written
|
|
16
|
+
* with a lifetime: `acquire` is a `putIfAbsent`, `renew` a `put` with `expect`,
|
|
17
|
+
* `release` a `delete` with `expect`. The broker's `POST /api/v1/locks` does
|
|
18
|
+
* that turning, so there is one implementation of it for every client. A lock
|
|
19
|
+
* is the semaphore of one permit; `queen.semaphore(name, n)` is the same thing
|
|
20
|
+
* with n.
|
|
21
|
+
*
|
|
22
|
+
* WHAT IT IS NOT: A MUTEX. A permit EXPIRES, and nobody tells its holder. A
|
|
23
|
+
* process that is paused, partitioned or slow keeps running past its lifetime
|
|
24
|
+
* while somebody else acquires. So the lock alone never makes two holders
|
|
25
|
+
* impossible; what makes their WORK exclusive is the token:
|
|
26
|
+
*
|
|
27
|
+
* * inside Queen, `.guard(lock)` on a transaction: the acks, pushes, KV
|
|
28
|
+
* writes and timers of the step commit only if the permit is still this
|
|
29
|
+
* holder's, in the same log entry. A holder that was replaced commits
|
|
30
|
+
* nothing.
|
|
31
|
+
* * outside Queen, `lock.token`: a number that only rises on a lock. A
|
|
32
|
+
* resource that remembers the highest token it has accepted and refuses a
|
|
33
|
+
* lower one (`WHERE fence <= $token`) refuses the holder that was
|
|
34
|
+
* replaced. Accept an EQUAL one: a holder writes many times with one token.
|
|
35
|
+
*
|
|
36
|
+
* Work that goes through neither is protected only by the lifetime being
|
|
37
|
+
* longer than the work, which is a hope, not a guarantee.
|
|
38
|
+
*
|
|
39
|
+
* THE TOKEN CHANGES AT EVERY RENEW. A renew rewrites the row, so the broker
|
|
40
|
+
* answers a new token and the one before stops working. This handle keeps the
|
|
41
|
+
* current one: read `lock.token` and `lock.guard()` when you use them, never
|
|
42
|
+
* hold a copy across an `await`.
|
|
43
|
+
*
|
|
44
|
+
* THE OWNER is the holder's identity, minted here per handle. It is what makes
|
|
45
|
+
* a call safe to send again when its answer was lost: the broker answers the
|
|
46
|
+
* permit the first attempt took. Two handles with one owner are one holder —
|
|
47
|
+
* pass your own only if that is what you mean.
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
import os from 'node:os'
|
|
51
|
+
import { randomBytes } from 'node:crypto'
|
|
52
|
+
|
|
53
|
+
import * as logger from '../utils/logger.js'
|
|
54
|
+
import { parseDurationMs } from '../kv/expiry.js'
|
|
55
|
+
|
|
56
|
+
/** `.code` of the error a guarded step throws when its lock is not held. */
|
|
57
|
+
export const LOCK_NOT_HELD = 'LOCK_NOT_HELD'
|
|
58
|
+
|
|
59
|
+
const NAME_MAX_BYTES = 256
|
|
60
|
+
const OWNER_MAX_BYTES = 256
|
|
61
|
+
const LIMIT_MAX = 1024
|
|
62
|
+
|
|
63
|
+
function requireName(name) {
|
|
64
|
+
// The broker's rule, checked here so the mistake surfaces at the call: no
|
|
65
|
+
// '#', which sits between a name and its slot in the row's key.
|
|
66
|
+
if (typeof name !== 'string' || name.length === 0 || Buffer.byteLength(name) > NAME_MAX_BYTES ||
|
|
67
|
+
/[\u0000-\u001f\u007f-\u009f#]/.test(name)) {
|
|
68
|
+
throw new Error(
|
|
69
|
+
`lock: a name is a non-empty string of at most ${NAME_MAX_BYTES} bytes, without control characters ` +
|
|
70
|
+
`and without '#' — got ${JSON.stringify(name)}`
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
return name
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The lifetime, in whole seconds, rounded UP. `ttl` is a duration string
|
|
78
|
+
* ('30s', '5m'), `ttlSeconds` a number. There is no `forever` and no default:
|
|
79
|
+
* a lock that never expires is one nobody can take back from a dead holder.
|
|
80
|
+
*/
|
|
81
|
+
export function lockTtlSeconds(opts = {}) {
|
|
82
|
+
if (opts.forever !== undefined) {
|
|
83
|
+
throw new Error('lock: there is no `forever`. A lock takes a lifetime, and a holder that needs longer renews')
|
|
84
|
+
}
|
|
85
|
+
if (opts.ttl !== undefined && opts.ttlSeconds !== undefined) {
|
|
86
|
+
throw new Error('lock: lifetime declared twice (ttl and ttlSeconds) — pick one')
|
|
87
|
+
}
|
|
88
|
+
const seconds = opts.ttl !== undefined ? Math.ceil(parseDurationMs(opts.ttl) / 1000) : opts.ttlSeconds
|
|
89
|
+
if (!Number.isInteger(seconds) || seconds <= 0) {
|
|
90
|
+
throw new Error(
|
|
91
|
+
"lock: a lifetime is required — ttl: '30s' or ttlSeconds: 30 (a whole number of seconds above zero)"
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
return seconds
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function mintOwner() {
|
|
98
|
+
const host = os.hostname().slice(0, 128)
|
|
99
|
+
return `${host}:${process.pid}:${randomBytes(6).toString('hex')}`
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function requireOwner(owner) {
|
|
103
|
+
if (typeof owner !== 'string' || owner.length === 0 || Buffer.byteLength(owner) > OWNER_MAX_BYTES ||
|
|
104
|
+
/[\u0000-\u001f\u007f-\u009f]/.test(owner)) {
|
|
105
|
+
throw new Error(`lock: owner is a non-empty string of at most ${OWNER_MAX_BYTES} bytes, without control characters`)
|
|
106
|
+
}
|
|
107
|
+
return owner
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const sleep = (ms, signal) => new Promise((resolve) => {
|
|
111
|
+
if (signal?.aborted) return resolve()
|
|
112
|
+
const t = setTimeout(done, ms)
|
|
113
|
+
function done() {
|
|
114
|
+
signal?.removeEventListener('abort', done)
|
|
115
|
+
clearTimeout(t)
|
|
116
|
+
resolve()
|
|
117
|
+
}
|
|
118
|
+
signal?.addEventListener('abort', done, { once: true })
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
// The wire: POST /api/v1/locks
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The four operations as the broker speaks them, with no state kept here.
|
|
127
|
+
* `queen.lock()` is what most code wants; this is for a caller that keeps the
|
|
128
|
+
* token itself, and for `get`.
|
|
129
|
+
*
|
|
130
|
+
* As on the KV routes, the HTTP status says how the CALL went and never what
|
|
131
|
+
* an operation answered: a lock held by somebody else is a 200 with
|
|
132
|
+
* `acquired: false`. Every result is an OBJECT and objects are truthy — read
|
|
133
|
+
* the field.
|
|
134
|
+
*/
|
|
135
|
+
export class Locks {
|
|
136
|
+
#httpClient
|
|
137
|
+
#held = new Set()
|
|
138
|
+
|
|
139
|
+
constructor(httpClient) {
|
|
140
|
+
this.#httpClient = httpClient
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Several operations in one call, each on a different lock: one result per
|
|
145
|
+
* operation, in order. They are independent — nothing here is
|
|
146
|
+
* all-or-nothing.
|
|
147
|
+
*/
|
|
148
|
+
async batch(operations) {
|
|
149
|
+
if (!Array.isArray(operations) || operations.length === 0) {
|
|
150
|
+
throw new Error('locks: batch needs a non-empty array of operations')
|
|
151
|
+
}
|
|
152
|
+
logger.log('Locks.batch', { count: operations.length, ops: operations.map(o => o.op) })
|
|
153
|
+
let body
|
|
154
|
+
try {
|
|
155
|
+
body = await this.#httpClient.post('/api/v1/locks', { operations })
|
|
156
|
+
} catch (error) {
|
|
157
|
+
// These routes put the code in `error`, which HttpClient maps onto the
|
|
158
|
+
// message: mirror it on `.code` so nobody branches on prose (as Kv does).
|
|
159
|
+
if (error && !error.code && typeof error.message === 'string') error.code = error.message
|
|
160
|
+
logger.error('Locks.batch', { error: error.message, status: error.status, code: error.code })
|
|
161
|
+
throw error
|
|
162
|
+
}
|
|
163
|
+
const results = body && Array.isArray(body.results) ? body.results : null
|
|
164
|
+
if (!results || results.length !== operations.length) {
|
|
165
|
+
throw new Error(
|
|
166
|
+
`locks: expected {"results":[...]} with ${operations.length} element(s), got ` +
|
|
167
|
+
(results ? `${results.length}` : 'another envelope')
|
|
168
|
+
)
|
|
169
|
+
}
|
|
170
|
+
return results
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async #one(operation, flag) {
|
|
174
|
+
const [result] = await this.batch([operation])
|
|
175
|
+
if (flag && typeof result[flag] !== 'boolean') {
|
|
176
|
+
throw new Error(`locks: ${operation.op} result carries no \`${flag}\` field; refusing to guess the verdict`)
|
|
177
|
+
}
|
|
178
|
+
return result
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Take a permit: `{acquired, slot, token, owner, guard, already?}`, or
|
|
183
|
+
* `{acquired: false, reason: 'held' | 'contended', holders}`.
|
|
184
|
+
*
|
|
185
|
+
* `limit` above 1 makes it a semaphore of that many permits. Every caller
|
|
186
|
+
* of one name passes the same limit; it is stored nowhere.
|
|
187
|
+
*/
|
|
188
|
+
async acquire(name, opts = {}) {
|
|
189
|
+
const op = { op: 'acquire', name: requireName(name), ttlSeconds: lockTtlSeconds(opts) }
|
|
190
|
+
if (opts.owner !== undefined && opts.owner !== null) op.owner = requireOwner(opts.owner)
|
|
191
|
+
if (opts.limit !== undefined) {
|
|
192
|
+
if (!Number.isInteger(opts.limit) || opts.limit < 1 || opts.limit > LIMIT_MAX) {
|
|
193
|
+
throw new Error(`lock: limit is a whole number from 1 (a lock) to ${LIMIT_MAX}`)
|
|
194
|
+
}
|
|
195
|
+
op.limit = opts.limit
|
|
196
|
+
}
|
|
197
|
+
return this.#one(op, 'acquired')
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Extend a permit: `{renewed, slot, token, guard}` with a NEW token, or
|
|
202
|
+
* `{renewed: false, reason: 'lost', holders}`.
|
|
203
|
+
*/
|
|
204
|
+
async renew(name, opts = {}) {
|
|
205
|
+
const op = { op: 'renew', name: requireName(name), token: opts.token, ttlSeconds: lockTtlSeconds(opts) }
|
|
206
|
+
if (!Number.isSafeInteger(op.token) || op.token <= 0) {
|
|
207
|
+
throw new Error('lock: renew needs the token of the permit (the one the last acquire or renew answered)')
|
|
208
|
+
}
|
|
209
|
+
if (opts.slot !== undefined) op.slot = opts.slot
|
|
210
|
+
if (opts.owner !== undefined && opts.owner !== null) op.owner = requireOwner(opts.owner)
|
|
211
|
+
return this.#one(op, 'renewed')
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Give a permit back: `{released}`; `false` with `reason: 'lost'` when the token is no longer the row's. */
|
|
215
|
+
async release(name, opts = {}) {
|
|
216
|
+
const op = { op: 'release', name: requireName(name), token: opts.token }
|
|
217
|
+
if (!Number.isSafeInteger(op.token) || op.token <= 0) {
|
|
218
|
+
throw new Error('lock: release needs the token of the permit (the one the last acquire or renew answered)')
|
|
219
|
+
}
|
|
220
|
+
if (opts.slot !== undefined) op.slot = opts.slot
|
|
221
|
+
return this.#one(op, 'released')
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Who holds it: `{held, holders: [{slot, owner, token, since, expiresAt, renewedAt}]}`. */
|
|
225
|
+
async get(name) {
|
|
226
|
+
return this.#one({ op: 'get', name: requireName(name) }, 'held')
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ---- the handles of this client, for close() -------------------------------
|
|
230
|
+
|
|
231
|
+
/** @internal */
|
|
232
|
+
_track(lock, held) {
|
|
233
|
+
if (held) this.#held.add(lock)
|
|
234
|
+
else this.#held.delete(lock)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Give back every permit this client's handles hold, best effort. Called by
|
|
239
|
+
* `queen.close()`: a permit left behind is only a wait of one lifetime for
|
|
240
|
+
* the next holder, never a leak.
|
|
241
|
+
* @internal
|
|
242
|
+
*/
|
|
243
|
+
async releaseAll() {
|
|
244
|
+
const held = [...this.#held]
|
|
245
|
+
await Promise.all(held.map(lock => lock.release().catch(() => false)))
|
|
246
|
+
return held.length
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ---------------------------------------------------------------------------
|
|
251
|
+
// The handle
|
|
252
|
+
// ---------------------------------------------------------------------------
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* One holder's hold on one lock (or one permit of a semaphore).
|
|
256
|
+
*
|
|
257
|
+
* It keeps the token current, renews in the background (every third of the
|
|
258
|
+
* lifetime, unless `autoRenew: false`), and says when the permit is gone:
|
|
259
|
+
* `lock.signal` aborts and `onLost` handlers run. "Gone" is the broker saying
|
|
260
|
+
* so, or the lifetime passing on THIS machine's clock with no renew having
|
|
261
|
+
* succeeded — a client that cannot reach the broker must assume the worst.
|
|
262
|
+
*/
|
|
263
|
+
export class Lock {
|
|
264
|
+
#locks
|
|
265
|
+
#name
|
|
266
|
+
#limit
|
|
267
|
+
#owner
|
|
268
|
+
#ttlSeconds
|
|
269
|
+
#autoRenew
|
|
270
|
+
#renewEveryMs
|
|
271
|
+
#retry
|
|
272
|
+
|
|
273
|
+
#token = null
|
|
274
|
+
#slot = null
|
|
275
|
+
// The broker's own `guard` of the current lease period, kept as answered:
|
|
276
|
+
// where a permit's row lives is the broker's rule and is written once, there.
|
|
277
|
+
#guard = null
|
|
278
|
+
#validUntil = 0
|
|
279
|
+
#abort = new AbortController()
|
|
280
|
+
#lostHandlers = []
|
|
281
|
+
#timer = null
|
|
282
|
+
#renewing = null
|
|
283
|
+
|
|
284
|
+
constructor(locks, name, opts = {}) {
|
|
285
|
+
this.#locks = locks
|
|
286
|
+
this.#name = requireName(name)
|
|
287
|
+
this.#ttlSeconds = lockTtlSeconds(opts)
|
|
288
|
+
this.#limit = opts.limit ?? 1
|
|
289
|
+
if (!Number.isInteger(this.#limit) || this.#limit < 1 || this.#limit > LIMIT_MAX) {
|
|
290
|
+
throw new Error(`lock: limit is a whole number from 1 (a lock) to ${LIMIT_MAX}`)
|
|
291
|
+
}
|
|
292
|
+
this.#owner = opts.owner !== undefined && opts.owner !== null ? requireOwner(opts.owner) : mintOwner()
|
|
293
|
+
this.#autoRenew = opts.autoRenew !== false
|
|
294
|
+
const every = opts.renewEvery !== undefined ? parseDurationMs(opts.renewEvery) : (this.#ttlSeconds * 1000) / 3
|
|
295
|
+
if (!(every > 0) || every >= this.#ttlSeconds * 1000) {
|
|
296
|
+
throw new Error('lock: renewEvery must be shorter than the lifetime, or the permit expires between renews')
|
|
297
|
+
}
|
|
298
|
+
this.#renewEveryMs = every
|
|
299
|
+
// How a waiting acquire comes back: first after `min`, then 1.5x each time
|
|
300
|
+
// up to `max`, each wait shortened by a random quarter so a crowd spreads.
|
|
301
|
+
this.#retry = { min: opts.retryMinMs ?? 100, max: opts.retryMaxMs ?? 1000 }
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
get name() { return this.#name }
|
|
305
|
+
get owner() { return this.#owner }
|
|
306
|
+
get limit() { return this.#limit }
|
|
307
|
+
get ttlSeconds() { return this.#ttlSeconds }
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Whether this handle holds a permit, as far as it can know: the broker
|
|
311
|
+
* granted or renewed it, and its lifetime has not run out on this machine's
|
|
312
|
+
* clock. `true` here is a belief with a deadline, not a proof — the proof
|
|
313
|
+
* is the guard on the transaction.
|
|
314
|
+
*/
|
|
315
|
+
get held() {
|
|
316
|
+
return this.#token !== null && Date.now() < this.#validUntil
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** The fencing token of the current lease period; `null` when not held. */
|
|
320
|
+
get token() { return this.held ? this.#token : null }
|
|
321
|
+
|
|
322
|
+
/** The semaphore slot this handle holds (0 for a lock); `null` when not held. */
|
|
323
|
+
get slot() { return this.held ? this.#slot : null }
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Epoch milliseconds, on this machine's clock, past which the permit must
|
|
327
|
+
* be considered gone unless a renew succeeds first. Counted from when the
|
|
328
|
+
* request was SENT, so it is never later than the broker's own deadline.
|
|
329
|
+
*/
|
|
330
|
+
get validUntil() { return this.held ? this.#validUntil : 0 }
|
|
331
|
+
|
|
332
|
+
/** Aborts when the permit is lost. A release is not a loss and does not abort it. */
|
|
333
|
+
get signal() { return this.#abort.signal }
|
|
334
|
+
|
|
335
|
+
/** Run `fn(reason)` when the permit is lost: 'renew', 'guard', 'expired'. */
|
|
336
|
+
onLost(fn) {
|
|
337
|
+
this.#lostHandlers.push(fn)
|
|
338
|
+
return this
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* The KV operation that holds while the permit is this handle's: a `check`
|
|
343
|
+
* of the permit's row at the current token, `required`. `.guard(lock)` on a
|
|
344
|
+
* transaction adds it for you and follows a renew; use this one to put it in
|
|
345
|
+
* a KV batch yourself, and take it at the moment you send.
|
|
346
|
+
*/
|
|
347
|
+
guard() {
|
|
348
|
+
if (!this.held) {
|
|
349
|
+
const error = new Error(`lock: '${this.#name}' is not held; there is nothing to guard with`)
|
|
350
|
+
error.code = LOCK_NOT_HELD
|
|
351
|
+
throw error
|
|
352
|
+
}
|
|
353
|
+
return { ...this.#guard }
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Take the permit. Resolves `true` or `false` — a boolean, on purpose, so
|
|
358
|
+
* `if (await lock.acquire())` means what it reads as.
|
|
359
|
+
*
|
|
360
|
+
* With `wait` (a duration string) it keeps trying until the permit is free
|
|
361
|
+
* or the wait is over, coming back every 100 ms to 1 s with jitter. Without
|
|
362
|
+
* it there is one attempt. `signal` gives the wait up early.
|
|
363
|
+
*/
|
|
364
|
+
async acquire(opts = {}) {
|
|
365
|
+
if (this.held) return true
|
|
366
|
+
const waitMs = opts.wait !== undefined ? parseDurationMs(opts.wait) : 0
|
|
367
|
+
const deadline = Date.now() + waitMs
|
|
368
|
+
let pause = this.#retry.min
|
|
369
|
+
for (;;) {
|
|
370
|
+
const sentAt = Date.now()
|
|
371
|
+
const r = await this.#locks.acquire(this.#name, {
|
|
372
|
+
ttlSeconds: this.#ttlSeconds,
|
|
373
|
+
owner: this.#owner,
|
|
374
|
+
...(this.#limit > 1 ? { limit: this.#limit } : {})
|
|
375
|
+
})
|
|
376
|
+
if (r.acquired) {
|
|
377
|
+
this.#take(r, sentAt)
|
|
378
|
+
logger.log('Lock.acquire', { name: this.#name, slot: r.slot, already: r.already === true })
|
|
379
|
+
return true
|
|
380
|
+
}
|
|
381
|
+
const left = deadline - Date.now()
|
|
382
|
+
if (left <= 0 || opts.signal?.aborted) return false
|
|
383
|
+
const jittered = pause * (0.75 + Math.random() * 0.25)
|
|
384
|
+
await sleep(Math.min(jittered, left), opts.signal)
|
|
385
|
+
pause = Math.min(pause * 1.5, this.#retry.max)
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Extend the lease now. `true`, with a new token in place, or `false`: the
|
|
391
|
+
* permit is gone and the handle says so (`signal`, `onLost`). Rejects when
|
|
392
|
+
* the broker could not be asked; the permit is then neither renewed nor
|
|
393
|
+
* known lost, and its deadline stands.
|
|
394
|
+
*
|
|
395
|
+
* The background renewal calls this; call it yourself with
|
|
396
|
+
* `autoRenew: false`, or before a long step.
|
|
397
|
+
*/
|
|
398
|
+
async renew() {
|
|
399
|
+
if (this.#renewing) return this.#renewing
|
|
400
|
+
if (this.#token === null) return false
|
|
401
|
+
this.#renewing = this.#renewOnce().finally(() => { this.#renewing = null })
|
|
402
|
+
return this.#renewing
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
async #renewOnce() {
|
|
406
|
+
const token = this.#token
|
|
407
|
+
const sentAt = Date.now()
|
|
408
|
+
const r = await this.#locks.renew(this.#name, {
|
|
409
|
+
token,
|
|
410
|
+
slot: this.#slot,
|
|
411
|
+
ttlSeconds: this.#ttlSeconds,
|
|
412
|
+
owner: this.#owner
|
|
413
|
+
})
|
|
414
|
+
// Released, or lost, while the renew was in flight: its answer is about a
|
|
415
|
+
// permit this handle no longer has.
|
|
416
|
+
if (this.#token !== token) return false
|
|
417
|
+
if (!r.renewed) {
|
|
418
|
+
this.#lose('renew')
|
|
419
|
+
return false
|
|
420
|
+
}
|
|
421
|
+
this.#token = r.token
|
|
422
|
+
this.#guard = r.guard
|
|
423
|
+
this.#validUntil = sentAt + this.#ttlSeconds * 1000
|
|
424
|
+
return true
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Give the permit back. `true` when the broker removed it; `false` when it
|
|
429
|
+
* was not this handle's any more (expired, or taken over) or was never
|
|
430
|
+
* held. Either way the handle holds nothing afterwards and can acquire again.
|
|
431
|
+
*/
|
|
432
|
+
async release() {
|
|
433
|
+
// A renew in flight owns the token until it answers.
|
|
434
|
+
if (this.#renewing) await this.#renewing.catch(() => {})
|
|
435
|
+
if (this.#token === null) return false
|
|
436
|
+
const token = this.#token
|
|
437
|
+
const slot = this.#slot
|
|
438
|
+
this.#drop()
|
|
439
|
+
const r = await this.#locks.release(this.#name, { token, slot })
|
|
440
|
+
logger.log('Lock.release', { name: this.#name, released: r.released })
|
|
441
|
+
return r.released === true
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Acquire, run `fn(lock)`, release — whatever `fn` does. Resolves
|
|
446
|
+
* `{acquired: false}` when the permit could not be had (after `wait`, if
|
|
447
|
+
* given), else `{acquired: true, value}` with what `fn` returned.
|
|
448
|
+
*
|
|
449
|
+
* It does not stop `fn` when the permit is lost; nothing can. Pass
|
|
450
|
+
* `lock.signal` to whatever `fn` awaits, and guard what it commits.
|
|
451
|
+
*/
|
|
452
|
+
async run(fn, opts = {}) {
|
|
453
|
+
if (!(await this.acquire(opts))) return { acquired: false }
|
|
454
|
+
try {
|
|
455
|
+
return { acquired: true, value: await fn(this) }
|
|
456
|
+
} finally {
|
|
457
|
+
await this.release().catch((error) => {
|
|
458
|
+
logger.error('Lock.run', { name: this.#name, error: error.message, note: 'release failed; the permit expires by itself' })
|
|
459
|
+
})
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// ---- used by TransactionBuilder.guard --------------------------------------
|
|
464
|
+
|
|
465
|
+
/** Resolves once no renew is in flight: the token is then the current one. @internal */
|
|
466
|
+
async _settled() {
|
|
467
|
+
if (this.#renewing) await this.#renewing.catch(() => {})
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** The broker said the permit is not this handle's. @internal */
|
|
471
|
+
_lost(reason) {
|
|
472
|
+
if (this.#token !== null) this.#lose(reason)
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// ---- state ------------------------------------------------------------------
|
|
476
|
+
|
|
477
|
+
#take(result, sentAt) {
|
|
478
|
+
this.#token = result.token
|
|
479
|
+
this.#slot = result.slot
|
|
480
|
+
this.#guard = result.guard
|
|
481
|
+
this.#validUntil = sentAt + this.#ttlSeconds * 1000
|
|
482
|
+
// A handle that lost a permit earlier gets a fresh signal with the new one.
|
|
483
|
+
if (this.#abort.signal.aborted) this.#abort = new AbortController()
|
|
484
|
+
this.#locks._track(this, true)
|
|
485
|
+
this.#schedule()
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
#drop() {
|
|
489
|
+
this.#token = null
|
|
490
|
+
this.#slot = null
|
|
491
|
+
this.#guard = null
|
|
492
|
+
this.#validUntil = 0
|
|
493
|
+
if (this.#timer) clearTimeout(this.#timer)
|
|
494
|
+
this.#timer = null
|
|
495
|
+
this.#locks._track(this, false)
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
#lose(reason) {
|
|
499
|
+
logger.warn('Lock.lost', { name: this.#name, owner: this.#owner, reason })
|
|
500
|
+
this.#drop()
|
|
501
|
+
this.#abort.abort(Object.assign(new Error(`lock '${this.#name}' was lost (${reason})`), { code: LOCK_NOT_HELD }))
|
|
502
|
+
for (const fn of this.#lostHandlers) {
|
|
503
|
+
try { fn(reason) } catch (error) { logger.error('Lock.onLost', { error: error.message }) }
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* The background renewal, and the deadline. One timer: it fires when the
|
|
509
|
+
* next renew is due, or — with `autoRenew: false`, or after renews that
|
|
510
|
+
* could not reach the broker — when the lifetime runs out.
|
|
511
|
+
*/
|
|
512
|
+
#schedule() {
|
|
513
|
+
if (this.#timer) clearTimeout(this.#timer)
|
|
514
|
+
const untilDead = this.#validUntil - Date.now()
|
|
515
|
+
const delay = this.#autoRenew ? Math.min(this.#renewEveryMs, Math.max(untilDead, 0)) : Math.max(untilDead, 0)
|
|
516
|
+
this.#timer = setTimeout(() => this.#tick(), delay)
|
|
517
|
+
// A held lock must not keep a finished process alive.
|
|
518
|
+
this.#timer.unref?.()
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
async #tick() {
|
|
522
|
+
if (this.#token === null) return
|
|
523
|
+
if (Date.now() >= this.#validUntil) {
|
|
524
|
+
this.#lose('expired')
|
|
525
|
+
return
|
|
526
|
+
}
|
|
527
|
+
if (this.#autoRenew) {
|
|
528
|
+
try {
|
|
529
|
+
await this.renew()
|
|
530
|
+
} catch (error) {
|
|
531
|
+
// Could not ask. Not a loss yet: try again sooner, until the deadline.
|
|
532
|
+
logger.warn('Lock.renew', { name: this.#name, error: error.message, status: error.status })
|
|
533
|
+
if (this.#token !== null) {
|
|
534
|
+
if (this.#timer) clearTimeout(this.#timer)
|
|
535
|
+
const left = this.#validUntil - Date.now()
|
|
536
|
+
this.#timer = setTimeout(() => this.#tick(), Math.max(Math.min(left / 4, this.#renewEveryMs), 50))
|
|
537
|
+
this.#timer.unref?.()
|
|
538
|
+
}
|
|
539
|
+
return
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
if (this.#token !== null) this.#schedule()
|
|
543
|
+
}
|
|
544
|
+
}
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "queen-mq",
|
|
3
|
-
"version": "2.0
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Partitioned message queue on a replicated broker log — broker client + fluent streaming SDK (windows, joins, gates) in one package",
|
|
6
6
|
"main": "client-v2/index.js",
|
|
7
7
|
"scripts": {
|
|
8
8
|
"test": "npm run test:unit && node test-v2/run.js human",
|
|
9
|
-
"test:unit": "node --test test-v2/streams-unit/configHash.test.js test-v2/streams-unit/operators.test.js test-v2/streams-unit/cycle.test.js test-v2/streams-unit/eventTime.test.js test-v2/streams-unit/ack.test.js test-v2/streams-unit/gate.test.js test-v2/http-unit/retry429.test.js test-v2/http-unit/hostHeader.test.js test-v2/http-unit/pushStatus.test.js test-v2/http-unit/renew.test.js test-v2/consumer-unit/handlerError.test.js test-v2/consumer-unit/nackScope.test.js test-v2/consumer-unit/stopOnAbort.test.js test-v2/kv-unit/kvWire.test.js test-v2/kv-unit/timerWire.test.js test-v2/kv-unit/txnWire.test.js test-v2/buffer-unit/buffer.test.js test-v2/conflation-unit/conflationWire.test.js test-v2/autopilot-unit/autopilotWire.test.js test-v2/ephemeral-unit/ephemeralWire.test.js test-v2/ephemeral-unit/ephemeralBuffer.test.js test-v2/ephemeral-unit/durableSinkPin.test.js test-v2/runner-unit/fatalExit.test.js test-v2/pop-unit/popDefaults.test.js test-v2/admin-unit/removedRoutes.test.js test-v2/consumer-unit/supervision.test.js",
|
|
9
|
+
"test:unit": "node --test test-v2/streams-unit/configHash.test.js test-v2/streams-unit/operators.test.js test-v2/streams-unit/cycle.test.js test-v2/streams-unit/eventTime.test.js test-v2/streams-unit/ack.test.js test-v2/streams-unit/gate.test.js test-v2/http-unit/retry429.test.js test-v2/http-unit/hostHeader.test.js test-v2/http-unit/pushStatus.test.js test-v2/http-unit/renew.test.js test-v2/consumer-unit/handlerError.test.js test-v2/consumer-unit/nackScope.test.js test-v2/consumer-unit/stopOnAbort.test.js test-v2/kv-unit/kvWire.test.js test-v2/kv-unit/timerWire.test.js test-v2/kv-unit/txnWire.test.js test-v2/kv-unit/locksWire.test.js test-v2/buffer-unit/buffer.test.js test-v2/conflation-unit/conflationWire.test.js test-v2/autopilot-unit/autopilotWire.test.js test-v2/ephemeral-unit/ephemeralWire.test.js test-v2/ephemeral-unit/ephemeralBuffer.test.js test-v2/ephemeral-unit/durableSinkPin.test.js test-v2/runner-unit/fatalExit.test.js test-v2/pop-unit/popDefaults.test.js test-v2/admin-unit/removedRoutes.test.js test-v2/consumer-unit/supervision.test.js",
|
|
10
10
|
"test:unit:e2e": "node --test test-v2/streams-unit/e2e.test.js",
|
|
11
11
|
"test:integration": "node test-v2/run.js human",
|
|
12
12
|
"test:streams": "node test-v2/run.js stream",
|