velocious 1.0.709 → 1.0.711
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 +1 -1
- package/build/configuration-types.js +18 -2
- package/build/configuration.js +88 -2
- package/build/database/advisory-lock-errors.js +59 -0
- package/build/database/advisory-lock-lock-server-backend.js +557 -0
- package/build/database/advisory-lock-runner.js +175 -2
- package/build/database/advisory-lock-telemetry.js +20 -19
- package/build/database/record/index.js +16 -2
- package/build/src/configuration-types.d.ts +75 -4
- package/build/src/configuration-types.d.ts.map +1 -1
- package/build/src/configuration-types.js +18 -3
- package/build/src/configuration.d.ts +43 -2
- package/build/src/configuration.d.ts.map +1 -1
- package/build/src/configuration.js +85 -3
- package/build/src/database/advisory-lock-errors.d.ts +54 -0
- package/build/src/database/advisory-lock-errors.d.ts.map +1 -0
- package/build/src/database/advisory-lock-errors.js +52 -0
- package/build/src/database/advisory-lock-lock-server-backend.d.ts +279 -0
- package/build/src/database/advisory-lock-lock-server-backend.d.ts.map +1 -0
- package/build/src/database/advisory-lock-lock-server-backend.js +474 -0
- package/build/src/database/advisory-lock-runner.d.ts +69 -1
- package/build/src/database/advisory-lock-runner.d.ts.map +1 -1
- package/build/src/database/advisory-lock-runner.js +150 -3
- package/build/src/database/advisory-lock-telemetry.d.ts +14 -11
- package/build/src/database/advisory-lock-telemetry.d.ts.map +1 -1
- package/build/src/database/advisory-lock-telemetry.js +21 -20
- package/build/src/database/record/index.d.ts +2 -2
- package/build/src/database/record/index.d.ts.map +1 -1
- package/build/src/database/record/index.js +3 -3
- package/package.json +1 -1
- package/src/configuration-types.js +18 -2
- package/src/configuration.js +88 -2
- package/src/database/advisory-lock-errors.js +59 -0
- package/src/database/advisory-lock-lock-server-backend.js +557 -0
- package/src/database/advisory-lock-runner.js +175 -2
- package/src/database/advisory-lock-telemetry.js +20 -19
- package/src/database/record/index.js +16 -2
|
@@ -0,0 +1,557 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import WebSocket from "ws"
|
|
4
|
+
|
|
5
|
+
import VelociousWebsocketClient from "../http-client/websocket-client.js"
|
|
6
|
+
import {AdvisoryLockServiceUnavailableError} from "./advisory-lock-errors.js"
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @typedef {object} LockServerConnectionLike
|
|
10
|
+
* @property {Promise<void>} ready - Resolves when the server accepted the 1:1 connection.
|
|
11
|
+
* @property {(body: Record<string, unknown>) => void} sendMessage - Sends one connection message.
|
|
12
|
+
* @property {() => void} [close] - Closes the connection.
|
|
13
|
+
* @typedef {object} LockServerClientLike
|
|
14
|
+
* @property {() => Promise<void>} connect - Opens the transport.
|
|
15
|
+
* @property {(type: string, args: {onMessage: (body: Record<string, unknown>) => void, params: Record<string, unknown>, timeoutMs: number}) => LockServerConnectionLike} openConnection - Opens the 1:1 connection.
|
|
16
|
+
* @property {() => Promise<void> | void} [close] - Closes the transport.
|
|
17
|
+
* @typedef {{epoch: number, name: string, timer: ReturnType<typeof setInterval>, leaseExpiresAt: number}} LockServerHold
|
|
18
|
+
* @typedef {{status: "acquired", epoch: number, leaseExpiresAt: number} | {status: "busy"} | {status: "timeout"}} LockServerAcquireResult
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** Protocol version spoken by the centralized lock server (consens-lock v2). */
|
|
22
|
+
export const LOCK_SERVER_PROTOCOL_VERSION = 2
|
|
23
|
+
|
|
24
|
+
/** Connection role registered on the lock server. */
|
|
25
|
+
const LOCK_SERVER_CONNECTION_TYPE = "lock"
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Tier (b) backend: the centralized lock server (`consens-lock`, protocol v2).
|
|
29
|
+
*
|
|
30
|
+
* The server holds every lock in its own process memory, so a held lock never
|
|
31
|
+
* pins a database connection — that is the whole point of this tier. Ownership
|
|
32
|
+
* is bound to this process's connection: the server mints the `holderId`, so a
|
|
33
|
+
* client can only renew or release its own leases, and a dropped connection
|
|
34
|
+
* releases all of them immediately.
|
|
35
|
+
*
|
|
36
|
+
* Holds are leases renewed every third of the lease window. A fenced lease
|
|
37
|
+
* (the server granted the name to another holder after an expiry or partition)
|
|
38
|
+
* is recorded per lock name and surfaced by the runner as
|
|
39
|
+
* `AdvisoryLockLostError` after the critical section settles.
|
|
40
|
+
*
|
|
41
|
+
* Unavailability is cached for `reprobeCooldownMs` so a flapping server cannot
|
|
42
|
+
* turn every waiting caller into a connection attempt (design §2).
|
|
43
|
+
*/
|
|
44
|
+
export default class AdvisoryLockLockServerBackend {
|
|
45
|
+
/**
|
|
46
|
+
* Creates the lock-server backend. The transport is created on first use, so
|
|
47
|
+
* constructing this object never performs I/O.
|
|
48
|
+
* @param {object} args - Backend options.
|
|
49
|
+
* @param {string} [args.authToken] - Shared secret; the server fails closed without a matching token.
|
|
50
|
+
* @param {(args: {authToken: string, url: string}) => LockServerClientLike} [args.clientFactory] - Transport factory (tests inject a fake).
|
|
51
|
+
* @param {number} [args.connectTimeoutMs] - Handshake timeout.
|
|
52
|
+
* @param {number} [args.leaseMs] - Lease duration used until the server advertises its own.
|
|
53
|
+
* @param {(error: Error) => void} [args.onError] - Async error reporter.
|
|
54
|
+
* @param {number} [args.reprobeCooldownMs] - How long an unavailable server is not re-probed.
|
|
55
|
+
* @param {string} args.url - Lock-server websocket URL.
|
|
56
|
+
*/
|
|
57
|
+
constructor({authToken = "", clientFactory = defaultClientFactory, connectTimeoutMs = 3000, leaseMs = 30_000, onError, reprobeCooldownMs = 5000, url}) {
|
|
58
|
+
this.url = url
|
|
59
|
+
this.authToken = authToken
|
|
60
|
+
this.clientFactory = clientFactory
|
|
61
|
+
this.connectTimeoutMs = connectTimeoutMs
|
|
62
|
+
this.leaseMs = leaseMs
|
|
63
|
+
this.onError = onError
|
|
64
|
+
this.reprobeCooldownMs = reprobeCooldownMs
|
|
65
|
+
/** @type {LockServerClientLike | null} */
|
|
66
|
+
this.client = null
|
|
67
|
+
/** @type {LockServerConnectionLike | null} */
|
|
68
|
+
this.connection = null
|
|
69
|
+
/** @type {Promise<void> | null} */
|
|
70
|
+
this.connectPromise = null
|
|
71
|
+
/** @type {{reject: (error: Error) => void, resolve: () => void, timer: ReturnType<typeof setTimeout> | null} | null} */
|
|
72
|
+
this.helloWaiter = null
|
|
73
|
+
/** @type {Map<string, {epoch: number, name: string, timer: ReturnType<typeof setInterval>, leaseExpiresAt: number}>} */
|
|
74
|
+
this.holds = new Map()
|
|
75
|
+
/** @type {Map<string, {currentEpoch: number | null, epoch: number | null}>} */
|
|
76
|
+
this.fencedHolds = new Map()
|
|
77
|
+
/** @type {Map<string, {reject: (error: Error) => void, resolve: (message: Record<string, unknown>) => void, timer: ReturnType<typeof setTimeout> | null}>} */
|
|
78
|
+
this.pending = new Map()
|
|
79
|
+
/** @type {{lastError: string | null, lastProbeAt: string | null, unavailableUntil: number}} */
|
|
80
|
+
this.status = {lastError: null, lastProbeAt: null, unavailableUntil: 0}
|
|
81
|
+
this.requestCounter = 0
|
|
82
|
+
this.closed = false
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Opens the connection if needed, honoring the re-probe cooldown.
|
|
87
|
+
* @param {string} lockName - Lock name, for error messages.
|
|
88
|
+
* @returns {Promise<void>} - Resolves when connected.
|
|
89
|
+
*/
|
|
90
|
+
async ensureConnected(lockName) {
|
|
91
|
+
if (this.closed) throw new AdvisoryLockServiceUnavailableError("Lock server backend is closed", {lockName, reason: "closed"})
|
|
92
|
+
if (this.connection) return
|
|
93
|
+
if (this.connectPromise) return await this.connectPromise
|
|
94
|
+
|
|
95
|
+
if (Date.now() < this.status.unavailableUntil) {
|
|
96
|
+
throw new AdvisoryLockServiceUnavailableError(
|
|
97
|
+
`Lock server ${this.url} is marked unavailable: ${String(this.status.lastError)}`,
|
|
98
|
+
{lockName, reason: "cooldown"}
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
this.connectPromise = this.openConnection(lockName)
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
await this.connectPromise
|
|
106
|
+
} finally {
|
|
107
|
+
this.connectPromise = null
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Opens the transport and completes the hello handshake.
|
|
113
|
+
* @param {string} lockName - Lock name, for error messages.
|
|
114
|
+
* @returns {Promise<void>} - Resolves when the handshake completed.
|
|
115
|
+
*/
|
|
116
|
+
async openConnection(lockName) {
|
|
117
|
+
this.status.lastProbeAt = new Date().toISOString()
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
const client = this.clientFactory({authToken: this.authToken, url: this.url})
|
|
121
|
+
|
|
122
|
+
this.client = client
|
|
123
|
+
await client.connect()
|
|
124
|
+
|
|
125
|
+
/** @type {Promise<void>} */
|
|
126
|
+
const helloPromise = new Promise((resolve, reject) => {
|
|
127
|
+
const timer = setTimeout(() => {
|
|
128
|
+
reject(new AdvisoryLockServiceUnavailableError(
|
|
129
|
+
`Lock server ${this.url} did not answer the handshake within ${this.connectTimeoutMs}ms`,
|
|
130
|
+
{lockName, reason: "handshake-timeout"}
|
|
131
|
+
))
|
|
132
|
+
}, this.connectTimeoutMs)
|
|
133
|
+
|
|
134
|
+
timer.unref?.()
|
|
135
|
+
this.helloWaiter = {reject, resolve, timer}
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
const connection = client.openConnection(LOCK_SERVER_CONNECTION_TYPE, {
|
|
139
|
+
onMessage: (body) => this.handleMessage(body),
|
|
140
|
+
params: {authToken: this.authToken, protocolVersion: LOCK_SERVER_PROTOCOL_VERSION},
|
|
141
|
+
timeoutMs: this.connectTimeoutMs
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
this.connection = connection
|
|
145
|
+
await connection.ready
|
|
146
|
+
await helloPromise
|
|
147
|
+
|
|
148
|
+
this.status.lastError = null
|
|
149
|
+
this.status.unavailableUntil = 0
|
|
150
|
+
} catch (error) {
|
|
151
|
+
const normalized = error instanceof Error ? error : new Error(String(error))
|
|
152
|
+
|
|
153
|
+
this.markUnavailable(normalized)
|
|
154
|
+
this.teardown()
|
|
155
|
+
|
|
156
|
+
throw new AdvisoryLockServiceUnavailableError(
|
|
157
|
+
`Lock server ${this.url} is unavailable: ${normalized.message}`,
|
|
158
|
+
{lockName, reason: "connect-failed"}
|
|
159
|
+
)
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Waits for a lock through the server's FIFO queue.
|
|
165
|
+
* @param {string} name - Lock name.
|
|
166
|
+
* @param {{timeoutMs?: number | null}} [args] - Bounded wait; the server defaults to 30s.
|
|
167
|
+
* @returns {Promise<boolean>} - True when the lease was granted, false on timeout.
|
|
168
|
+
*/
|
|
169
|
+
async acquire(name, {timeoutMs} = {}) {
|
|
170
|
+
await this.ensureConnected(name)
|
|
171
|
+
|
|
172
|
+
/** @type {LockServerAcquireResult} */
|
|
173
|
+
const result = /** @type {LockServerAcquireResult} */ (await this.request("acquire", {
|
|
174
|
+
name,
|
|
175
|
+
timeoutMs: typeof timeoutMs === "number" && timeoutMs > 0 ? timeoutMs : undefined
|
|
176
|
+
}, typeof timeoutMs === "number" ? timeoutMs + this.connectTimeoutMs : null))
|
|
177
|
+
|
|
178
|
+
if (result.status === "acquired") {
|
|
179
|
+
this.startRenewal(name, result.epoch)
|
|
180
|
+
|
|
181
|
+
return true
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (result.status === "busy") return false
|
|
185
|
+
|
|
186
|
+
return false
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Takes a lock only if the server grants it immediately.
|
|
191
|
+
* @param {string} name - Lock name.
|
|
192
|
+
* @returns {Promise<boolean>} - True when granted.
|
|
193
|
+
*/
|
|
194
|
+
async tryAcquire(name) {
|
|
195
|
+
await this.ensureConnected(name)
|
|
196
|
+
|
|
197
|
+
/** @type {LockServerAcquireResult} */
|
|
198
|
+
const result = /** @type {LockServerAcquireResult} */ (await this.request("try-acquire", {name}))
|
|
199
|
+
|
|
200
|
+
if (result.status !== "acquired") return false
|
|
201
|
+
|
|
202
|
+
this.startRenewal(name, result.epoch)
|
|
203
|
+
|
|
204
|
+
return true
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Releases a held lease and stops its renewal.
|
|
209
|
+
* @param {string} name - Lock name.
|
|
210
|
+
* @returns {Promise<void>} - Resolves when released (or when the transport is gone).
|
|
211
|
+
*/
|
|
212
|
+
async release(name) {
|
|
213
|
+
const hold = this.holds.get(name)
|
|
214
|
+
|
|
215
|
+
if (!hold) return
|
|
216
|
+
|
|
217
|
+
this.holds.delete(name)
|
|
218
|
+
clearInterval(hold.timer)
|
|
219
|
+
|
|
220
|
+
if (!this.connection) return
|
|
221
|
+
|
|
222
|
+
try {
|
|
223
|
+
await this.request("release", {epoch: hold.epoch, name})
|
|
224
|
+
} catch (error) {
|
|
225
|
+
if (!(error instanceof AdvisoryLockServiceUnavailableError)) throw error
|
|
226
|
+
// The server releases every lease of a closed connection, and the lease
|
|
227
|
+
// expires otherwise; both paths reclaim the name.
|
|
228
|
+
this.reportError(error)
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Releases every lease this process holds (shutdown path).
|
|
234
|
+
* @returns {Promise<void>} - Resolves when every lease was released.
|
|
235
|
+
*/
|
|
236
|
+
async releaseAllHeld() {
|
|
237
|
+
for (const name of [...this.holds.keys()]) await this.release(name)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Runs the server's live holder list (diagnostics).
|
|
242
|
+
* @returns {Promise<Record<string, unknown>[]>} - Live holders.
|
|
243
|
+
*/
|
|
244
|
+
async list() {
|
|
245
|
+
const response = await this.request("list", {})
|
|
246
|
+
const holders = response.holders
|
|
247
|
+
|
|
248
|
+
return /** @type {Record<string, unknown>[]} */ (Array.isArray(holders) ? holders : [])
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Runs who-holds for one name (diagnostics).
|
|
253
|
+
* @param {string} name - Lock name.
|
|
254
|
+
* @returns {Promise<Record<string, unknown> | null>} - Holder, or null.
|
|
255
|
+
*/
|
|
256
|
+
async whoHolds(name) {
|
|
257
|
+
const response = await this.request("who-holds", {name})
|
|
258
|
+
const holder = response.holder
|
|
259
|
+
|
|
260
|
+
return /** @type {Record<string, unknown> | null} */ (holder && typeof holder === "object" ? holder : null)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Runs the fencing epoch granted for one held lease.
|
|
265
|
+
* @param {string} name - Lock name.
|
|
266
|
+
* @returns {number | null} - Granted epoch, or null when the lease is not (or no longer) held.
|
|
267
|
+
*/
|
|
268
|
+
getHoldEpoch(name) {
|
|
269
|
+
return this.holds.get(name)?.epoch ?? null
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Whether this hold was fenced or lost during its critical section.
|
|
274
|
+
* @param {string} name - Lock name.
|
|
275
|
+
* @param {number | null} epoch - Epoch the hold was granted.
|
|
276
|
+
* @returns {{currentEpoch: number | null, epoch: number | null} | null} - Fencing details when lost.
|
|
277
|
+
*/
|
|
278
|
+
takeLoss(name, epoch) {
|
|
279
|
+
const loss = this.fencedHolds.get(name)
|
|
280
|
+
|
|
281
|
+
if (loss) {
|
|
282
|
+
this.fencedHolds.delete(name)
|
|
283
|
+
|
|
284
|
+
return loss
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const hold = this.holds.get(name)
|
|
288
|
+
|
|
289
|
+
if (hold && hold.epoch !== epoch) return {currentEpoch: hold.epoch, epoch}
|
|
290
|
+
|
|
291
|
+
return null
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Reports connection state and the names currently leased through this
|
|
296
|
+
* process, for `getAdvisoryLockBackendStatus()`.
|
|
297
|
+
* @returns {{connected: boolean, lastError: string | null, lastProbeAt: string | null, leasedNames: string[]}} - Backend status for diagnostics.
|
|
298
|
+
*/
|
|
299
|
+
getStatus() {
|
|
300
|
+
return {
|
|
301
|
+
connected: Boolean(this.connection),
|
|
302
|
+
lastError: this.status.lastError,
|
|
303
|
+
lastProbeAt: this.status.lastProbeAt,
|
|
304
|
+
leasedNames: [...this.holds.keys()]
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Closes the backend: explicit release first, then the transport.
|
|
310
|
+
* @returns {Promise<void>} - Resolves when closed.
|
|
311
|
+
*/
|
|
312
|
+
async close() {
|
|
313
|
+
this.closed = true
|
|
314
|
+
await this.releaseAllHeld()
|
|
315
|
+
|
|
316
|
+
this.teardown()
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Dispatches one server message.
|
|
321
|
+
* @param {Record<string, unknown>} body - Server message.
|
|
322
|
+
* @returns {void}
|
|
323
|
+
*/
|
|
324
|
+
handleMessage(body) {
|
|
325
|
+
if (!body || typeof body !== "object") return
|
|
326
|
+
|
|
327
|
+
if (body.type === "hello-ack") {
|
|
328
|
+
const advertisedLeaseMs = Number(body.leaseMs)
|
|
329
|
+
|
|
330
|
+
if (Number.isFinite(advertisedLeaseMs) && advertisedLeaseMs > 0) this.leaseMs = advertisedLeaseMs
|
|
331
|
+
|
|
332
|
+
const helloWaiter = this.helloWaiter
|
|
333
|
+
|
|
334
|
+
this.helloWaiter = null
|
|
335
|
+
if (helloWaiter) {
|
|
336
|
+
if (helloWaiter.timer) clearTimeout(helloWaiter.timer)
|
|
337
|
+
helloWaiter.resolve()
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
return
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (body.type === "lost") {
|
|
344
|
+
const name = String(body.name ?? "")
|
|
345
|
+
const hold = this.holds.get(name)
|
|
346
|
+
|
|
347
|
+
if (!hold) return
|
|
348
|
+
|
|
349
|
+
this.holds.delete(name)
|
|
350
|
+
clearInterval(hold.timer)
|
|
351
|
+
this.fencedHolds.set(name, {currentEpoch: Number(body.epoch ?? 0), epoch: hold.epoch})
|
|
352
|
+
|
|
353
|
+
return
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (body.type !== "response" || typeof body.correlationId !== "string") return
|
|
357
|
+
|
|
358
|
+
const pending = this.pending.get(body.correlationId)
|
|
359
|
+
|
|
360
|
+
if (!pending) return
|
|
361
|
+
|
|
362
|
+
this.pending.delete(body.correlationId)
|
|
363
|
+
if (pending.timer) clearTimeout(pending.timer)
|
|
364
|
+
|
|
365
|
+
if (body.status === "error") {
|
|
366
|
+
pending.reject(new Error(String(body.error || "Lock server error")))
|
|
367
|
+
|
|
368
|
+
return
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
pending.resolve(body)
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Sends one request and awaits its correlated response.
|
|
376
|
+
* @param {string} type - Request type.
|
|
377
|
+
* @param {Record<string, unknown>} payload - Request fields.
|
|
378
|
+
* @param {number | null} [timeoutMs] - Response timeout; null waits for the server.
|
|
379
|
+
* @returns {Promise<Record<string, unknown>>} - Response body.
|
|
380
|
+
*/
|
|
381
|
+
async request(type, payload, timeoutMs = null) {
|
|
382
|
+
const connection = this.connection
|
|
383
|
+
|
|
384
|
+
if (!connection) {
|
|
385
|
+
throw new AdvisoryLockServiceUnavailableError("Lock server connection is not open", {lockName: String(payload.name ?? ""), reason: "not-connected"})
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const correlationId = `req-${++this.requestCounter}`
|
|
389
|
+
|
|
390
|
+
return await new Promise((resolve, reject) => {
|
|
391
|
+
const timer = typeof timeoutMs === "number" && timeoutMs > 0
|
|
392
|
+
? setTimeout(() => {
|
|
393
|
+
this.pending.delete(correlationId)
|
|
394
|
+
reject(new AdvisoryLockServiceUnavailableError(
|
|
395
|
+
`Lock server request timed out: ${type} ${String(payload.name ?? "")}`,
|
|
396
|
+
{lockName: String(payload.name ?? ""), reason: "request-timeout"}
|
|
397
|
+
))
|
|
398
|
+
}, timeoutMs)
|
|
399
|
+
: null
|
|
400
|
+
|
|
401
|
+
timer?.unref?.()
|
|
402
|
+
this.pending.set(correlationId, {reject, resolve, timer})
|
|
403
|
+
|
|
404
|
+
try {
|
|
405
|
+
connection.sendMessage({...payload, correlationId, type})
|
|
406
|
+
} catch (error) {
|
|
407
|
+
this.pending.delete(correlationId)
|
|
408
|
+
if (timer) clearTimeout(timer)
|
|
409
|
+
this.markUnavailable(error instanceof Error ? error : new Error(String(error)))
|
|
410
|
+
reject(new AdvisoryLockServiceUnavailableError(
|
|
411
|
+
`Lock server send failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
412
|
+
{lockName: String(payload.name ?? ""), reason: "send-failed"}
|
|
413
|
+
))
|
|
414
|
+
}
|
|
415
|
+
})
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Starts lease renewal for a granted lock.
|
|
420
|
+
* @param {string} name - Lock name.
|
|
421
|
+
* @param {number} epoch - Granted epoch.
|
|
422
|
+
* @returns {void}
|
|
423
|
+
*/
|
|
424
|
+
startRenewal(name, epoch) {
|
|
425
|
+
const existing = this.holds.get(name)
|
|
426
|
+
|
|
427
|
+
if (existing) clearInterval(existing.timer)
|
|
428
|
+
|
|
429
|
+
const intervalMs = Math.max(250, Math.floor(this.leaseMs / 3))
|
|
430
|
+
const timer = setInterval(() => {
|
|
431
|
+
void this.renew(name, epoch)
|
|
432
|
+
}, intervalMs)
|
|
433
|
+
|
|
434
|
+
timer.unref?.()
|
|
435
|
+
this.holds.set(name, {epoch, leaseExpiresAt: Date.now() + this.leaseMs, name, timer})
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Renews one lease; a fenced lease is recorded for the runner to surface.
|
|
440
|
+
* @param {string} name - Lock name.
|
|
441
|
+
* @param {number} epoch - Epoch the hold believes it owns.
|
|
442
|
+
* @returns {Promise<void>} - Resolves after the renewal round-trip.
|
|
443
|
+
*/
|
|
444
|
+
async renew(name, epoch) {
|
|
445
|
+
const hold = this.holds.get(name)
|
|
446
|
+
|
|
447
|
+
if (!hold || !this.connection) return
|
|
448
|
+
|
|
449
|
+
try {
|
|
450
|
+
const response = await this.request("renew", {epoch, name}, this.connectTimeoutMs * 2)
|
|
451
|
+
const status = String(response.status)
|
|
452
|
+
|
|
453
|
+
if (status === "renewed") {
|
|
454
|
+
hold.leaseExpiresAt = Number(response.leaseExpiresAt)
|
|
455
|
+
|
|
456
|
+
return
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
if (status === "lost" || status === "not-found") {
|
|
460
|
+
this.holds.delete(name)
|
|
461
|
+
clearInterval(hold.timer)
|
|
462
|
+
this.fencedHolds.set(name, {currentEpoch: Number(response.currentEpoch ?? 0), epoch})
|
|
463
|
+
}
|
|
464
|
+
} catch (error) {
|
|
465
|
+
if (!(error instanceof AdvisoryLockServiceUnavailableError)) throw error
|
|
466
|
+
|
|
467
|
+
// A lease that cannot be renewed can no longer be proven to be ours: the
|
|
468
|
+
// server may have fenced it while the transport was failing. Recording it
|
|
469
|
+
// as lost is the only honest answer, and stops the renewal loop.
|
|
470
|
+
this.markUnavailable(error)
|
|
471
|
+
this.holds.delete(name)
|
|
472
|
+
clearInterval(hold.timer)
|
|
473
|
+
this.fencedHolds.set(name, {currentEpoch: null, epoch})
|
|
474
|
+
this.reportError(error)
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Records that the lock server is unreachable and starts the re-probe cooldown.
|
|
480
|
+
* @param {Error} error - Failure.
|
|
481
|
+
* @returns {void}
|
|
482
|
+
*/
|
|
483
|
+
markUnavailable(error) {
|
|
484
|
+
this.status.lastError = error.message
|
|
485
|
+
this.status.lastProbeAt = new Date().toISOString()
|
|
486
|
+
this.status.unavailableUntil = Date.now() + this.reprobeCooldownMs
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Drops transport state and marks every outstanding hold lost.
|
|
491
|
+
* @returns {void}
|
|
492
|
+
*/
|
|
493
|
+
teardown() {
|
|
494
|
+
for (const [name, hold] of this.holds) {
|
|
495
|
+
clearInterval(hold.timer)
|
|
496
|
+
this.fencedHolds.set(name, {currentEpoch: null, epoch: hold.epoch})
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
this.holds.clear()
|
|
500
|
+
|
|
501
|
+
for (const pending of this.pending.values()) {
|
|
502
|
+
if (pending.timer) clearTimeout(pending.timer)
|
|
503
|
+
pending.reject(new AdvisoryLockServiceUnavailableError("Lock server connection dropped", {lockName: "", reason: "connection-dropped"}))
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
this.pending.clear()
|
|
507
|
+
|
|
508
|
+
const connection = this.connection
|
|
509
|
+
const client = this.client
|
|
510
|
+
|
|
511
|
+
this.connection = null
|
|
512
|
+
this.client = null
|
|
513
|
+
|
|
514
|
+
try {
|
|
515
|
+
connection?.close?.()
|
|
516
|
+
} catch (error) {
|
|
517
|
+
this.reportError(error instanceof Error ? error : new Error(String(error)))
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
try {
|
|
521
|
+
void client?.close?.()
|
|
522
|
+
} catch (error) {
|
|
523
|
+
this.reportError(error instanceof Error ? error : new Error(String(error)))
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* Reports a transport-side failure without breaking lock handling.
|
|
529
|
+
* @param {unknown} error - Failure to report.
|
|
530
|
+
* @returns {void}
|
|
531
|
+
*/
|
|
532
|
+
reportError(error) {
|
|
533
|
+
const normalized = error instanceof Error ? error : new Error(String(error))
|
|
534
|
+
|
|
535
|
+
this.status.lastError = normalized.message
|
|
536
|
+
|
|
537
|
+
if (this.onError) this.onError(normalized)
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Default transport factory: the framework websocket client with the Node
|
|
543
|
+
* `ws` implementation bound explicitly (no global mutation).
|
|
544
|
+
* @param {{authToken: string, url: string}} args - Connection args.
|
|
545
|
+
* @returns {LockServerClientLike} - Websocket client.
|
|
546
|
+
*/
|
|
547
|
+
function defaultClientFactory({url}) {
|
|
548
|
+
// Node's global WebSocket is not guaranteed to exist, so the implementation is
|
|
549
|
+
// bound explicitly instead of mutating `globalThis`.
|
|
550
|
+
const webSocketImplementation = /** @type {typeof globalThis.WebSocket} */ (/** @type {unknown} */ (WebSocket))
|
|
551
|
+
|
|
552
|
+
return /** @type {LockServerClientLike} */ (/** @type {unknown} */ (new VelociousWebsocketClient({
|
|
553
|
+
heartbeatIntervalMs: 10_000,
|
|
554
|
+
url,
|
|
555
|
+
webSocketImplementation
|
|
556
|
+
})))
|
|
557
|
+
}
|