queen-mq 2.0.4 → 2.3.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 +135 -2
- package/client-v2/Queen.js +93 -3
- package/client-v2/README.md +66 -1
- package/client-v2/builders/TransactionBuilder.js +105 -17
- package/client-v2/http/HttpClient.js +67 -3
- package/client-v2/http/popFrames.js +211 -0
- package/client-v2/http/pushBinary.js +367 -0
- package/client-v2/http/zstd.js +41 -0
- package/client-v2/index.js +5 -0
- package/client-v2/kv/Kv.js +41 -0
- package/client-v2/locks/Locks.js +544 -0
- package/client-v2/utils/defaults.js +17 -1
- 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
- package/test-v2/wire-unit/_wireServer.js +47 -0
- package/test-v2/wire-unit/popFrames.test.js +264 -0
- package/test-v2/wire-unit/pushBinary.test.js +235 -0
- package/test-v2/wire-unit/transactionFrames.test.js +222 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* zstd for the binary push body and the framed pop answer.
|
|
3
|
+
*
|
|
4
|
+
* It comes from node:zlib (Node 22.15 and later), loaded on first use through
|
|
5
|
+
* a guarded dynamic import, as HttpClient loads undici: a bundle for a runtime
|
|
6
|
+
* without it still loads, and fails only when a push is compressed or a framed
|
|
7
|
+
* pop holds a compressed record.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
let loading = null
|
|
11
|
+
|
|
12
|
+
async function load() {
|
|
13
|
+
if (loading === null) {
|
|
14
|
+
loading = import('node:zlib').then(
|
|
15
|
+
(zlib) => (typeof zlib.zstdCompressSync === 'function' && typeof zlib.zstdDecompressSync === 'function' ? zlib : null),
|
|
16
|
+
() => null
|
|
17
|
+
)
|
|
18
|
+
}
|
|
19
|
+
const zlib = await loading
|
|
20
|
+
if (!zlib) {
|
|
21
|
+
throw new Error('zstd is not available in this runtime (node:zlib of Node 22.15 or later): pushCompression and framedPop need it')
|
|
22
|
+
}
|
|
23
|
+
return zlib
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* One zstd frame of `raw`, at the fastest level. The frame carries its content
|
|
28
|
+
* size, which the broker asks of a frame it stores as it came.
|
|
29
|
+
*/
|
|
30
|
+
export async function zstdCompress(raw) {
|
|
31
|
+
const zlib = await load()
|
|
32
|
+
return zlib.zstdCompressSync(raw, {
|
|
33
|
+
params: { [zlib.constants.ZSTD_c_compressionLevel]: 1 },
|
|
34
|
+
pledgedSrcSize: raw.length
|
|
35
|
+
})
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function zstdDecompress(frame) {
|
|
39
|
+
const zlib = await load()
|
|
40
|
+
return zlib.zstdDecompressSync(frame)
|
|
41
|
+
}
|
package/client-v2/index.js
CHANGED
|
@@ -16,6 +16,11 @@
|
|
|
16
16
|
|
|
17
17
|
export { Queen } from './Queen.js'
|
|
18
18
|
export { Admin } from './admin/Admin.js'
|
|
19
|
+
// Locks and semaphores. Reached as `queen.lock()`, `queen.semaphore()` and
|
|
20
|
+
// `queen.locks`; the classes are exported for typing. `LOCK_NOT_HELD` is the
|
|
21
|
+
// `.code` of the error a guard throws for a handle that holds nothing, and of
|
|
22
|
+
// the reason `lock.signal` aborts with.
|
|
23
|
+
export { Lock, Locks, LOCK_NOT_HELD } from './locks/Locks.js'
|
|
19
24
|
// RAM-class queues (EPHEMERAL_QUEUES.md §4). Reached as `queen.ephemeral`; the
|
|
20
25
|
// class is exported for typing and for embedding it on a client of your own.
|
|
21
26
|
// The two `.code`s the family's 404s carry, which are NOT the same fact:
|
package/client-v2/kv/Kv.js
CHANGED
|
@@ -152,6 +152,21 @@ export const kvOp = {
|
|
|
152
152
|
if (opts.min !== undefined) base.min = opts.min
|
|
153
153
|
if (opts.max !== undefined) base.max = opts.max
|
|
154
154
|
return { base: withRequired(base, opts), expiry: opts }
|
|
155
|
+
},
|
|
156
|
+
|
|
157
|
+
check(ns, key, opts = {}) {
|
|
158
|
+
requireName(ns, 'ns')
|
|
159
|
+
requireName(key, 'key')
|
|
160
|
+
// A check IS its expect. One without says nothing, and the broker's
|
|
161
|
+
// `applied:true` to it would read as a guard that held.
|
|
162
|
+
if (!('expect' in opts)) {
|
|
163
|
+
throw new Error(
|
|
164
|
+
'kv: check needs `expect` — the version the key must be at, or 0 for a key that must not exist'
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
const base = { op: 'check', ns, key }
|
|
168
|
+
// No expiry: it writes nothing.
|
|
169
|
+
return { base: withRequired(withExpect(base, opts), opts), expiry: null }
|
|
155
170
|
}
|
|
156
171
|
}
|
|
157
172
|
|
|
@@ -408,6 +423,32 @@ export class Kv {
|
|
|
408
423
|
return res
|
|
409
424
|
}
|
|
410
425
|
|
|
426
|
+
/**
|
|
427
|
+
* Is the key still at this version? Writes nothing.
|
|
428
|
+
*
|
|
429
|
+
* const r = await kv.check('orders', '9137', { expect: row.version })
|
|
430
|
+
* if (!r.applied) console.log(r.reason, r.value) // somebody wrote since
|
|
431
|
+
*
|
|
432
|
+
* `expect: 0` asks the opposite: the key must not exist. A WriteResult like
|
|
433
|
+
* every write's, `applied` being "the precondition held"; when it did not,
|
|
434
|
+
* `reason` (`version`, `absent` or `exists`), `value` and `version` are what
|
|
435
|
+
* a reader would see.
|
|
436
|
+
*
|
|
437
|
+
* On its own it is a linearizable look. Where it earns its place is beside
|
|
438
|
+
* writes, with `required: true`: in a batch, or in a transaction's
|
|
439
|
+
* `.kv.check(...)`, it makes everything else commit only if a key the call
|
|
440
|
+
* does not write is unchanged — which is how a step is tied to a lock
|
|
441
|
+
* (`queen.lock`).
|
|
442
|
+
*
|
|
443
|
+
* Needs a broker at cluster version 5, the first with this operation. While
|
|
444
|
+
* a cluster is being upgraded to it the call answers 503
|
|
445
|
+
* `kv_check_needs_cluster_version_5`; an older broker answers 400
|
|
446
|
+
* `kv_unknown_op`.
|
|
447
|
+
*/
|
|
448
|
+
async check(ns, key, opts = {}) {
|
|
449
|
+
return this.#write(kvOp.check(ns, key, opts))
|
|
450
|
+
}
|
|
451
|
+
|
|
411
452
|
/**
|
|
412
453
|
* The idempotency marker, written the way it is actually used:
|
|
413
454
|
*
|
|
@@ -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
|
+
}
|
|
@@ -36,7 +36,23 @@ export const CLIENT_DEFAULTS = {
|
|
|
36
36
|
// retries unboundedly (paced by backoff) unless maxAttempts is set here, in
|
|
37
37
|
// which case it applies to both. baseMs (500) / capMs (30000) size the
|
|
38
38
|
// exponential backoff used when the server doesn't send Retry-After.
|
|
39
|
-
retry429: undefined
|
|
39
|
+
retry429: undefined,
|
|
40
|
+
// The three below need a broker that has the binary push body and the framed
|
|
41
|
+
// pop answer. Off, the client sends and reads the JSON every broker knows.
|
|
42
|
+
//
|
|
43
|
+
// binaryPush: a push goes out as the binary push body (http/pushBinary.js)
|
|
44
|
+
// instead of JSON. Items with a trace id stay JSON.
|
|
45
|
+
binaryPush: false,
|
|
46
|
+
// pushCompression (with binaryPush): the client frames the messages as the
|
|
47
|
+
// broker stores them, mints their ids and zstd-compresses each partition's
|
|
48
|
+
// batch once; the broker keeps those bytes as they are. A transaction that
|
|
49
|
+
// pushes sends its pushes the same way, and the rest of the bundle as JSON
|
|
50
|
+
// in front of them.
|
|
51
|
+
pushCompression: false,
|
|
52
|
+
// framedPop: pops ask for the messages as the stored records (frames=true)
|
|
53
|
+
// and unpack them here (http/popFrames.js). A broker that does not know the
|
|
54
|
+
// parameter answers JSON, which is read as always.
|
|
55
|
+
framedPop: false
|
|
40
56
|
}
|
|
41
57
|
|
|
42
58
|
export const QUEUE_DEFAULTS = {
|