velocious 1.0.699 → 1.0.700
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 -2
- package/build/configuration-types.js +11 -0
- package/build/configuration.js +122 -1
- package/build/database/advisory-lock-backend-policy.js +204 -0
- package/build/database/advisory-lock-file-backend.js +348 -0
- package/build/database/advisory-lock-runner.js +239 -16
- package/build/environment-handlers/base.js +13 -0
- package/build/environment-handlers/node.js +11 -0
- package/build/http-server/server-lock.js +21 -115
- package/build/src/configuration-types.d.ts +31 -0
- package/build/src/configuration-types.d.ts.map +1 -1
- package/build/src/configuration-types.js +11 -1
- package/build/src/configuration.d.ts +88 -1
- package/build/src/configuration.d.ts.map +1 -1
- package/build/src/configuration.js +112 -2
- package/build/src/database/advisory-lock-backend-policy.d.ts +145 -0
- package/build/src/database/advisory-lock-backend-policy.d.ts.map +1 -0
- package/build/src/database/advisory-lock-backend-policy.js +184 -0
- package/build/src/database/advisory-lock-file-backend.d.ts +194 -0
- package/build/src/database/advisory-lock-file-backend.d.ts.map +1 -0
- package/build/src/database/advisory-lock-file-backend.js +313 -0
- package/build/src/database/advisory-lock-runner.d.ts +116 -15
- package/build/src/database/advisory-lock-runner.d.ts.map +1 -1
- package/build/src/database/advisory-lock-runner.js +190 -17
- package/build/src/environment-handlers/base.d.ts +14 -0
- package/build/src/environment-handlers/base.d.ts.map +1 -1
- package/build/src/environment-handlers/base.js +13 -1
- package/build/src/environment-handlers/node.d.ts +11 -0
- package/build/src/environment-handlers/node.d.ts.map +1 -1
- package/build/src/environment-handlers/node.js +11 -1
- package/build/src/http-server/server-lock.d.ts +13 -37
- package/build/src/http-server/server-lock.d.ts.map +1 -1
- package/build/src/http-server/server-lock.js +22 -109
- package/package.json +1 -1
- package/src/configuration-types.js +11 -0
- package/src/configuration.js +122 -1
- package/src/database/advisory-lock-backend-policy.js +204 -0
- package/src/database/advisory-lock-file-backend.js +348 -0
- package/src/database/advisory-lock-runner.js +239 -16
- package/src/environment-handlers/base.js +13 -0
- package/src/environment-handlers/node.js +11 -0
- package/src/http-server/server-lock.js +21 -115
|
@@ -1,6 +1,24 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
|
|
3
3
|
import timeout, {TimeoutError} from "awaitery/build/timeout.js"
|
|
4
|
+
import {DEFAULT_ADVISORY_LOCK_ACQUIRE_TIMEOUT_MS} from "./advisory-lock-bound.js"
|
|
5
|
+
import {
|
|
6
|
+
AdvisoryLockBackendUnavailableError,
|
|
7
|
+
resolveAdvisoryLockBackendPolicy,
|
|
8
|
+
refuseAdvisoryLockTierBelowMinimum
|
|
9
|
+
} from "./advisory-lock-backend-policy.js"
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Provenance of one advisory-lock acquisition (STC-210 design §3): every
|
|
13
|
+
* held lock reports the tier that actually served it, so a fallback is
|
|
14
|
+
* visible to the callback, in logs, and in `getAdvisoryLockBackendStatus()`.
|
|
15
|
+
* @typedef {object} AdvisoryLockBackendProvenance
|
|
16
|
+
* @property {import("./advisory-lock-backend-policy.js").AdvisoryLockTier} backend - Tier that served the acquisition.
|
|
17
|
+
* @property {string} [driverType] - Database driver type when the driver tier served it.
|
|
18
|
+
* @property {boolean} fencingSupported - Whether this tier carries fencing tokens (design §8); the file tier reports false rather than pretending.
|
|
19
|
+
* @property {string} [holderId] - Backend holder id for the file tier.
|
|
20
|
+
* @property {string} [lockName] - Lock name this provenance belongs to.
|
|
21
|
+
*/
|
|
4
22
|
|
|
5
23
|
/**
|
|
6
24
|
* Thrown when an advisory lock could not be acquired before `timeoutMs` elapsed.
|
|
@@ -77,19 +95,41 @@ export default class AdvisoryLockRunner {
|
|
|
77
95
|
* cleanup can release the lock even if callback database work is stuck.
|
|
78
96
|
* @template T
|
|
79
97
|
* @param {string} name - Lock name.
|
|
80
|
-
* @param {(args?: {control
|
|
98
|
+
* @param {(args?: {control?: import("awaitery/build/timeout.js").TimeoutControl, lockBackend: AdvisoryLockBackendProvenance}) => Promise<T>} callback - Callback to invoke while the lock is held.
|
|
81
99
|
* @param {{timeoutMs?: number | null, holdTimeoutMs?: number | null, dedicatedConnection?: boolean}} [args] - Lock and hold timeout options.
|
|
82
100
|
* @returns {Promise<T>} - Resolves with the callback result.
|
|
83
101
|
*/
|
|
84
102
|
async withAdvisoryLock(name, callback, args = {}) {
|
|
103
|
+
const fileBackend = await this.fileBackendForName(name)
|
|
104
|
+
|
|
105
|
+
if (fileBackend) {
|
|
106
|
+
const acquired = await fileBackend.acquire(name, {timeoutMs: this.fileAcquireTimeoutMs(args)})
|
|
107
|
+
|
|
108
|
+
if (!acquired) {
|
|
109
|
+
throw new AdvisoryLockTimeoutError(`Timed out waiting for advisory lock ${JSON.stringify(name)} on the file tier`, {name})
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const lockBackend = this.fileProvenance(fileBackend, name)
|
|
113
|
+
|
|
114
|
+
this.configuration.recordAdvisoryLockAcquisition({backend: lockBackend.backend, fencingSupported: lockBackend.fencingSupported, lockName: name})
|
|
115
|
+
|
|
116
|
+
return await this.runLockedCallback({callback, holdTimeoutMs: args.holdTimeoutMs, lockBackend, name, release: async () => await fileBackend.release(name)})
|
|
117
|
+
}
|
|
118
|
+
|
|
85
119
|
return await this.withLockConnection(args, async (connection) => {
|
|
120
|
+
const lockBackend = this.driverProvenance(connection)
|
|
121
|
+
|
|
122
|
+
this.assertDriverTierEligible(name, lockBackend)
|
|
123
|
+
|
|
86
124
|
const acquired = await connection.acquireAdvisoryLock(name, args)
|
|
87
125
|
|
|
88
126
|
if (!acquired) {
|
|
89
127
|
throw new AdvisoryLockTimeoutError(`Timed out waiting for advisory lock ${JSON.stringify(name)}`, {name})
|
|
90
128
|
}
|
|
91
129
|
|
|
92
|
-
|
|
130
|
+
this.configuration.recordAdvisoryLockAcquisition({backend: lockBackend.backend, fencingSupported: lockBackend.fencingSupported, lockName: name})
|
|
131
|
+
|
|
132
|
+
return await this.runLockedCallback({callback, holdTimeoutMs: args.holdTimeoutMs, lockBackend, name, release: async () => await connection.releaseAdvisoryLock(name)})
|
|
93
133
|
})
|
|
94
134
|
}
|
|
95
135
|
|
|
@@ -102,22 +142,45 @@ export default class AdvisoryLockRunner {
|
|
|
102
142
|
* cancellation.
|
|
103
143
|
* @template T
|
|
104
144
|
* @param {string} name - Lock name.
|
|
105
|
-
* @param {(args?: {control
|
|
145
|
+
* @param {(args?: {control?: import("awaitery/build/timeout.js").TimeoutControl, lockBackend: AdvisoryLockBackendProvenance}) => Promise<T>} callback - Callback to invoke while the lock is held.
|
|
106
146
|
* @param {{holdTimeoutMs?: number | null, dedicatedConnection?: boolean}} [args] - Hold timeout options.
|
|
107
147
|
* @returns {Promise<T>} - Resolves with the callback result.
|
|
108
148
|
*/
|
|
109
149
|
async withAdvisoryLockOrFail(name, callback, args = {}) {
|
|
150
|
+
const fileBackend = await this.fileBackendForName(name)
|
|
151
|
+
|
|
152
|
+
if (fileBackend) {
|
|
153
|
+
const acquired = await fileBackend.tryAcquire(name)
|
|
154
|
+
|
|
155
|
+
if (!acquired) {
|
|
156
|
+
throw new AdvisoryLockBusyError(`Advisory lock ${JSON.stringify(name)} is already held on the file tier`, {name})
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const lockBackend = this.fileProvenance(fileBackend, name)
|
|
160
|
+
|
|
161
|
+
this.configuration.recordAdvisoryLockAcquisition({backend: lockBackend.backend, fencingSupported: lockBackend.fencingSupported, lockName: name})
|
|
162
|
+
|
|
163
|
+
return await this.runLockedCallback({callback, holdTimeoutMs: args.holdTimeoutMs, lockBackend, name, release: async () => await fileBackend.release(name)})
|
|
164
|
+
}
|
|
165
|
+
|
|
110
166
|
return await this.withLockConnection(args, async (connection) => {
|
|
167
|
+
const lockBackend = this.driverProvenance(connection)
|
|
168
|
+
|
|
169
|
+
this.assertDriverTierEligible(name, lockBackend)
|
|
170
|
+
|
|
111
171
|
const acquired = await connection.tryAcquireAdvisoryLock(name)
|
|
112
172
|
|
|
113
173
|
if (!acquired) {
|
|
114
174
|
throw new AdvisoryLockBusyError(`Advisory lock ${JSON.stringify(name)} is already held`, {name})
|
|
115
175
|
}
|
|
116
176
|
|
|
117
|
-
|
|
177
|
+
this.configuration.recordAdvisoryLockAcquisition({backend: lockBackend.backend, fencingSupported: lockBackend.fencingSupported, lockName: name})
|
|
178
|
+
|
|
179
|
+
return await this.runLockedCallback({callback, holdTimeoutMs: args.holdTimeoutMs, lockBackend, name, release: async () => await connection.releaseAdvisoryLock(name)})
|
|
118
180
|
})
|
|
119
181
|
}
|
|
120
182
|
|
|
183
|
+
/**
|
|
121
184
|
/**
|
|
122
185
|
* Runs a callback if the advisory lock can be acquired without waiting;
|
|
123
186
|
* otherwise returns a `{acquired: false}` defer result instead of throwing.
|
|
@@ -133,33 +196,172 @@ export default class AdvisoryLockRunner {
|
|
|
133
196
|
* file lock), so a caller never branches on the backend.
|
|
134
197
|
* @template T
|
|
135
198
|
* @param {string} name - Lock name.
|
|
136
|
-
* @param {(args?: {control
|
|
199
|
+
* @param {(args?: {control?: import("awaitery/build/timeout.js").TimeoutControl, lockBackend: AdvisoryLockBackendProvenance}) => Promise<T>} callback - Callback to invoke while the lock is held.
|
|
137
200
|
* @param {{holdTimeoutMs?: number | null, dedicatedConnection?: boolean}} [args] - Hold timeout options.
|
|
138
201
|
* @returns {Promise<{acquired: true, result: T} | {acquired: false, reason: "contended", lockName: string}>} - Either the held-callback result or the defer outcome.
|
|
139
202
|
*/
|
|
140
203
|
async withAdvisoryLockOrDefer(name, callback, args = {}) {
|
|
204
|
+
const fileBackend = await this.fileBackendForName(name)
|
|
205
|
+
|
|
206
|
+
if (fileBackend) {
|
|
207
|
+
const acquired = await fileBackend.acquire(name, {timeoutMs: 0})
|
|
208
|
+
|
|
209
|
+
if (!acquired) return {acquired: false, reason: "contended", lockName: name}
|
|
210
|
+
|
|
211
|
+
const lockBackend = this.fileProvenance(fileBackend, name)
|
|
212
|
+
|
|
213
|
+
this.configuration.recordAdvisoryLockAcquisition({backend: lockBackend.backend, fencingSupported: lockBackend.fencingSupported, lockName: name})
|
|
214
|
+
|
|
215
|
+
const result = await this.runLockedCallback({callback, holdTimeoutMs: args.holdTimeoutMs, lockBackend, name, release: async () => await fileBackend.release(name)})
|
|
216
|
+
|
|
217
|
+
return {acquired: true, result}
|
|
218
|
+
}
|
|
219
|
+
|
|
141
220
|
return await this.withLockConnection(args, async (connection) => {
|
|
142
221
|
const acquired = await connection.tryAcquireAdvisoryLock(name)
|
|
143
222
|
|
|
144
223
|
if (!acquired) return {acquired: false, reason: "contended", lockName: name}
|
|
145
224
|
|
|
146
|
-
const
|
|
225
|
+
const lockBackend = this.driverProvenance(connection)
|
|
226
|
+
|
|
227
|
+
this.assertDriverTierEligible(name, lockBackend)
|
|
228
|
+
|
|
229
|
+
this.configuration.recordAdvisoryLockAcquisition({backend: lockBackend.backend, fencingSupported: lockBackend.fencingSupported, lockName: name})
|
|
230
|
+
|
|
231
|
+
const result = await this.runLockedCallback({callback, holdTimeoutMs: args.holdTimeoutMs, lockBackend, name, release: async () => await connection.releaseAdvisoryLock(name)})
|
|
147
232
|
|
|
148
233
|
return {acquired: true, result}
|
|
149
234
|
})
|
|
150
235
|
}
|
|
151
236
|
|
|
152
237
|
/**
|
|
153
|
-
* Runs the lock holder callback and releases the lock from its owning
|
|
238
|
+
* Runs the lock holder callback and releases the lock from its owning backend.
|
|
154
239
|
* @template T
|
|
155
|
-
* @param {{callback: (args?: {control
|
|
240
|
+
* @param {{callback: (args?: {control?: import("awaitery/build/timeout.js").TimeoutControl, lockBackend: AdvisoryLockBackendProvenance}) => Promise<T>, holdTimeoutMs?: number | null, lockBackend: AdvisoryLockBackendProvenance, name: string, release: () => Promise<unknown>}} args - Locked callback args.
|
|
156
241
|
* @returns {Promise<T>} - Resolves with the callback result.
|
|
157
242
|
*/
|
|
158
|
-
async runLockedCallback({callback,
|
|
243
|
+
async runLockedCallback({callback, holdTimeoutMs, lockBackend, name, release}) {
|
|
159
244
|
try {
|
|
160
|
-
return await AdvisoryLockRunner.runWithAdvisoryLockHoldTimeout(name, callback, holdTimeoutMs)
|
|
245
|
+
return await AdvisoryLockRunner.runWithAdvisoryLockHoldTimeout(name, callback, holdTimeoutMs, lockBackend)
|
|
161
246
|
} finally {
|
|
162
|
-
await
|
|
247
|
+
await release()
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Resolves the backend policy for one lock name and returns the file
|
|
253
|
+
* backend when the name explicitly opts into the file tier
|
|
254
|
+
* (`preferredTier: "file"` plus `advisoryLocks.backends.file.enabled: true`).
|
|
255
|
+
* A name whose declared minimum tier is cross-host is refused loudly
|
|
256
|
+
* (`AdvisoryLockMinTierRefusedError`) before any contact with the file
|
|
257
|
+
* tier. A name that prefers the file tier while the backend is disabled
|
|
258
|
+
* moves to the driver path (a stronger-or-equal tier), never the other way
|
|
259
|
+
* around. Nothing here ever auto-enables the file tier.
|
|
260
|
+
* @param {string} name - Lock name.
|
|
261
|
+
* @returns {Promise<import("./advisory-lock-file-backend.js").default | null>} - Resolves with the file backend serving this name, or null for the driver path.
|
|
262
|
+
*/
|
|
263
|
+
async fileBackendForName(name) {
|
|
264
|
+
const policy = resolveAdvisoryLockBackendPolicy(name, this.configuration.getAdvisoryLockPolicy())
|
|
265
|
+
|
|
266
|
+
if (policy.preferredTier !== "file") return null
|
|
267
|
+
|
|
268
|
+
if (!advisoryLockPolicyAllowsFileTier(policy)) {
|
|
269
|
+
refuseAdvisoryLockTierBelowMinimum({
|
|
270
|
+
configuration: this.configuration,
|
|
271
|
+
lockName: name,
|
|
272
|
+
minTier: policy.minTier,
|
|
273
|
+
requestedBackend: "file"
|
|
274
|
+
})
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const backend = await this.configuration.getAdvisoryLockFileBackend()
|
|
278
|
+
|
|
279
|
+
if (!backend) {
|
|
280
|
+
// Preferred tier unavailable: fail *upward* to the driver path (a
|
|
281
|
+
// stronger-or-equal tier), never downward. If the driver cannot satisfy
|
|
282
|
+
// the minimum either, `assertDriverTierEligible` throws
|
|
283
|
+
// `AdvisoryLockBackendUnavailableError` on the driver path.
|
|
284
|
+
return null
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return backend
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Fail-closed check on the driver path (design §9): an undeclared lock name
|
|
292
|
+
* keeps today's behavior (any driver serves it — full backward
|
|
293
|
+
* compatibility), but a name with an explicit policy entry is refused with
|
|
294
|
+
* `AdvisoryLockBackendUnavailableError` when the configured driver cannot
|
|
295
|
+
* satisfy its minimum tier (e.g. `minTier: "driver-native"` on a SQLite
|
|
296
|
+
* driver, which has no native advisory-lock primitive).
|
|
297
|
+
* @param {string} name - Lock name.
|
|
298
|
+
* @param {AdvisoryLockBackendProvenance} lockBackend - Tier the driver path would serve with.
|
|
299
|
+
* @returns {void}
|
|
300
|
+
*/
|
|
301
|
+
assertDriverTierEligible(name, lockBackend) {
|
|
302
|
+
const policy = resolveAdvisoryLockBackendPolicy(name, this.configuration.getAdvisoryLockPolicy())
|
|
303
|
+
|
|
304
|
+
if (!policy.entry) return
|
|
305
|
+
|
|
306
|
+
const tier = lockBackend.backend
|
|
307
|
+
|
|
308
|
+
if (tier === "process-local" && !policy.allowedTiers.includes("process-local")) {
|
|
309
|
+
throw new AdvisoryLockBackendUnavailableError(
|
|
310
|
+
`Advisory lock ${JSON.stringify(name)} requires minimum tier ${JSON.stringify(policy.minTier)} but the configured driver (${String(lockBackend.driverType)}) only offers the process-local tier`,
|
|
311
|
+
{lockName: name, minTier: policy.minTier, reasons: [`driver tier ${tier} below minTier ${policy.minTier}`]}
|
|
312
|
+
)
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Resolves the wait bound for a file-tier acquisition: an explicit caller
|
|
318
|
+
* `timeoutMs` (including an explicit `null` opt-out) wins, otherwise the
|
|
319
|
+
* same framework default that bounds driver acquisitions applies.
|
|
320
|
+
* @param {{timeoutMs?: number | null}} args - Lock options.
|
|
321
|
+
* @returns {number | null} - Wait bound in milliseconds, or `null` for an indefinite wait.
|
|
322
|
+
*/
|
|
323
|
+
fileAcquireTimeoutMs(args) {
|
|
324
|
+
if (args.timeoutMs !== undefined) return args.timeoutMs
|
|
325
|
+
|
|
326
|
+
return DEFAULT_ADVISORY_LOCK_ACQUIRE_TIMEOUT_MS
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Builds the acquisition provenance for a file-tier hold. The file tier
|
|
331
|
+
* cannot carry fencing tokens, so the limitation is reported on every
|
|
332
|
+
* acquisition rather than silently pretended away (design §8).
|
|
333
|
+
* @param {import("./advisory-lock-file-backend.js").default} backend - Backend that served the acquisition.
|
|
334
|
+
* @param {string} name - Lock name.
|
|
335
|
+
* @returns {AdvisoryLockBackendProvenance} - Acquisition provenance.
|
|
336
|
+
*/
|
|
337
|
+
fileProvenance(backend, name) {
|
|
338
|
+
return {backend: "file", fencingSupported: false, holderId: backend.holderId, lockName: name}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Builds the acquisition provenance for a driver-served hold. SQLite has no
|
|
343
|
+
* native advisory-lock primitive, so its emulation tier is reported as
|
|
344
|
+
* `process-local` rather than pretending to be database-native.
|
|
345
|
+
* @param {import("./drivers/base.js").default} connection - Connection that served the acquisition.
|
|
346
|
+
* @returns {AdvisoryLockBackendProvenance} - Acquisition provenance.
|
|
347
|
+
*/
|
|
348
|
+
driverProvenance(connection) {
|
|
349
|
+
let driverType = null
|
|
350
|
+
|
|
351
|
+
try {
|
|
352
|
+
driverType = connection.getType()
|
|
353
|
+
} catch {
|
|
354
|
+
// Test stub drivers may not implement getType(); the tier alone identifies the backend.
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const tier = typeof connection.advisoryLockBackendTier === "function"
|
|
358
|
+
? connection.advisoryLockBackendTier()
|
|
359
|
+
: (driverType === "sqlite" ? "process-local" : "driver-native")
|
|
360
|
+
|
|
361
|
+
return {
|
|
362
|
+
backend: /** @type {import("./advisory-lock-backend-policy.js").AdvisoryLockTier} */ (tier),
|
|
363
|
+
driverType: driverType ?? undefined,
|
|
364
|
+
fencingSupported: tier === "driver-native"
|
|
163
365
|
}
|
|
164
366
|
}
|
|
165
367
|
|
|
@@ -218,16 +420,18 @@ export default class AdvisoryLockRunner {
|
|
|
218
420
|
*
|
|
219
421
|
* The callback receives a `TimeoutControl` from awaitery, enabling cooperative
|
|
220
422
|
* cancellation via `control.check()`, `control.signal`, `control.timedOut`,
|
|
221
|
-
* and `control.remaining()
|
|
423
|
+
* and `control.remaining()`, plus `lockBackend` provenance for the tier that
|
|
424
|
+
* served the acquisition.
|
|
222
425
|
* @template T
|
|
223
426
|
* @param {string} name - Lock name (for the error message).
|
|
224
|
-
* @param {(args?: {control
|
|
427
|
+
* @param {(args?: {control?: import("awaitery/build/timeout.js").TimeoutControl, lockBackend: AdvisoryLockBackendProvenance}) => Promise<T>} callback - Callback holding the lock.
|
|
225
428
|
* @param {number | null} [holdTimeoutMs] - Max hold time; falsy disables the timeout.
|
|
429
|
+
* @param {AdvisoryLockBackendProvenance} [lockBackend] - Provenance of the acquisition serving this hold.
|
|
226
430
|
* @returns {Promise<T>} - Resolves with the callback result.
|
|
227
431
|
*/
|
|
228
|
-
static async runWithAdvisoryLockHoldTimeout(name, callback, holdTimeoutMs) {
|
|
432
|
+
static async runWithAdvisoryLockHoldTimeout(name, callback, holdTimeoutMs, lockBackend) {
|
|
229
433
|
if (!holdTimeoutMs || holdTimeoutMs <= 0) {
|
|
230
|
-
return await callback()
|
|
434
|
+
return await callback({lockBackend: lockBackend ?? AdvisoryLockRunner.unknownBackendProvenance()})
|
|
231
435
|
}
|
|
232
436
|
|
|
233
437
|
let callbackSettled = false
|
|
@@ -235,7 +439,7 @@ export default class AdvisoryLockRunner {
|
|
|
235
439
|
try {
|
|
236
440
|
return await timeout({timeout: holdTimeoutMs}, async ({control}) => {
|
|
237
441
|
try {
|
|
238
|
-
return await callback({control})
|
|
442
|
+
return await callback({control, lockBackend: lockBackend ?? AdvisoryLockRunner.unknownBackendProvenance()})
|
|
239
443
|
} finally {
|
|
240
444
|
callbackSettled = true
|
|
241
445
|
}
|
|
@@ -248,6 +452,25 @@ export default class AdvisoryLockRunner {
|
|
|
248
452
|
throw error
|
|
249
453
|
}
|
|
250
454
|
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Neutral provenance for direct `runWithAdvisoryLockHoldTimeout` callers that
|
|
458
|
+
* hold a lock acquired outside this runner.
|
|
459
|
+
* @returns {AdvisoryLockBackendProvenance} - Provenance with an unknown tier.
|
|
460
|
+
*/
|
|
461
|
+
static unknownBackendProvenance() {
|
|
462
|
+
return {backend: "process-local", fencingSupported: false}
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Runs advisory lock policy allows file tier.
|
|
468
|
+
* @param {import("./advisory-lock-backend-policy.js").ResolvedAdvisoryLockBackendPolicy} policy - Resolved policy.
|
|
469
|
+
* @returns {boolean} - Whether the file tier may serve this name.
|
|
470
|
+
*/
|
|
471
|
+
function advisoryLockPolicyAllowsFileTier(policy) {
|
|
472
|
+
return policy.allowedTiers.includes("file")
|
|
251
473
|
}
|
|
252
474
|
|
|
253
475
|
export {AdvisoryLockBusyError, AdvisoryLockHoldTimeoutError, AdvisoryLockTimeoutError}
|
|
476
|
+
export {AdvisoryLockBackendUnavailableError, AdvisoryLockMinTierRefusedError} from "./advisory-lock-backend-policy.js"
|
|
@@ -36,6 +36,19 @@ export default class VelociousEnvironmentHandlerBase {
|
|
|
36
36
|
*/
|
|
37
37
|
resolveTestSharedTransactionPoolType({configuredPoolType}) { return configuredPoolType }
|
|
38
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Creates the file-tier advisory-lock backend (STC-213). Only filesystem
|
|
41
|
+
* capable environments (the Node handler) can provide this tier; browser
|
|
42
|
+
* and Expo configurations have no `fs`, so the default refuses loudly
|
|
43
|
+
* instead of half-working when a deployment explicitly enables
|
|
44
|
+
* `advisoryLocks.backends.file.enabled`.
|
|
45
|
+
* @param {{directory: string, leaseMs: number, renewIntervalMs: number}} _args - Backend options.
|
|
46
|
+
* @returns {Promise<import("../database/advisory-lock-file-backend.js").default>} - Resolves with the created backend in filesystem-capable handlers.
|
|
47
|
+
*/
|
|
48
|
+
async createAdvisoryLockFileBackend(_args) {
|
|
49
|
+
throw new Error("The advisory-lock file tier requires a filesystem-capable environment handler (Node)")
|
|
50
|
+
}
|
|
51
|
+
|
|
39
52
|
/**
|
|
40
53
|
* Node test runtimes may replace a physical child connection with a broker
|
|
41
54
|
* proxy. Other environments never participate in this test-only protocol.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
|
|
3
3
|
import "../database/annotations-async-hooks.js"
|
|
4
|
+
import AdvisoryLockFileBackend from "../database/advisory-lock-file-backend.js"
|
|
4
5
|
import Base from "./base.js"
|
|
5
6
|
import BackgroundJobsClient from "../background-jobs/client.js"
|
|
6
7
|
import SqlBackgroundJobsAdapter from "../background-jobs/sql-adapter.js"
|
|
@@ -106,6 +107,16 @@ export default class VelociousEnvironmentHandlerNode extends Base{
|
|
|
106
107
|
return AsyncTrackedMultiConnectionPool
|
|
107
108
|
}
|
|
108
109
|
|
|
110
|
+
/**
|
|
111
|
+
* Creates the filesystem-backed advisory-lock backend for the file tier
|
|
112
|
+
* (STC-213). The Node handler is the only filesystem-capable environment.
|
|
113
|
+
* @param {{directory: string, leaseMs: number, renewIntervalMs: number}} args - Backend options.
|
|
114
|
+
* @returns {Promise<import("../database/advisory-lock-file-backend.js").default>} - Resolves with the created backend.
|
|
115
|
+
*/
|
|
116
|
+
async createAdvisoryLockFileBackend(args) {
|
|
117
|
+
return new AdvisoryLockFileBackend(args)
|
|
118
|
+
}
|
|
119
|
+
|
|
109
120
|
/**
|
|
110
121
|
* Creates a test-only child proxy when TestRunner supplied an active broker.
|
|
111
122
|
* @param {{DriverClass: typeof import("../database/drivers/base.js").default, config: import("../configuration-types.js").DatabaseConfigurationType, configuration: import("../configuration.js").default, databaseIdentifier: string, reuseKey?: string}} args - Connection details.
|
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
|
|
3
|
-
import
|
|
4
|
-
import os from "node:os"
|
|
3
|
+
import AdvisoryLockFileBackend from "../database/advisory-lock-file-backend.js"
|
|
5
4
|
import path from "node:path"
|
|
6
5
|
|
|
7
6
|
/**
|
|
8
|
-
* Directory-based lock that allows only one HTTP server process per app
|
|
7
|
+
* Directory-based lock that allows only one HTTP server process per app
|
|
8
|
+
* directory. Thin wrapper over the shared file-lock backend
|
|
9
|
+
* (`src/database/advisory-lock-file-backend.js` — the STC-210 `file` tier
|
|
10
|
+
* primitive generalized from this lock's original hand-rolled directory
|
|
11
|
+
* protocol), so the server lock and the advisory-lock file tier share one
|
|
12
|
+
* acquisition, owner-metadata, lease-renewal, and staleness implementation.
|
|
9
13
|
*/
|
|
10
14
|
export default class VelociousHttpServerLock {
|
|
11
15
|
/**
|
|
@@ -20,53 +24,26 @@ export default class VelociousHttpServerLock {
|
|
|
20
24
|
this.host = host
|
|
21
25
|
this.port = port
|
|
22
26
|
this.lockPath = path.join(configuration.getDirectory(), "tmp", "server.lock")
|
|
27
|
+
this.backend = new AdvisoryLockFileBackend({
|
|
28
|
+
directory: path.join(configuration.getDirectory(), "tmp"),
|
|
29
|
+
extraOwnerFields: {host: this.host, port: this.port},
|
|
30
|
+
pathStyle: "plain"
|
|
31
|
+
})
|
|
23
32
|
this.acquired = false
|
|
24
33
|
}
|
|
25
34
|
|
|
26
35
|
/**
|
|
27
|
-
* Acquires the app-directory HTTP server lock before startup side effects
|
|
36
|
+
* Acquires the app-directory HTTP server lock before startup side effects
|
|
37
|
+
* run. A stale lock (dead local owner or expired lease) is reclaimed by the
|
|
38
|
+
* backend; a live foreign owner fails loudly with the owner details.
|
|
28
39
|
* @returns {Promise<void>} - Resolves after the lock has been acquired.
|
|
29
40
|
*/
|
|
30
41
|
async acquire() {
|
|
31
|
-
await
|
|
42
|
+
const acquired = await this.backend.acquire("server", {timeoutMs: 0})
|
|
32
43
|
|
|
33
|
-
|
|
34
|
-
const acquired = await this.tryAcquire()
|
|
44
|
+
if (!acquired) throw new Error(await this.lockHeldMessage())
|
|
35
45
|
|
|
36
|
-
|
|
37
|
-
return
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
if (await this.isStale()) {
|
|
41
|
-
await fs.rm(this.lockPath, {recursive: true, force: true})
|
|
42
|
-
continue
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
throw new Error(await this.lockHeldMessage())
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Tries to create the lock directory and write owner metadata.
|
|
51
|
-
* @returns {Promise<boolean>} - Whether the lock was acquired.
|
|
52
|
-
*/
|
|
53
|
-
async tryAcquire() {
|
|
54
|
-
try {
|
|
55
|
-
await fs.mkdir(this.lockPath)
|
|
56
|
-
} catch (error) {
|
|
57
|
-
if (/** @type {{code?: string}} */ (error).code === "EEXIST") return false
|
|
58
|
-
throw error
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
try {
|
|
62
|
-
await this.writeOwnerMetadata()
|
|
63
|
-
this.acquired = true
|
|
64
|
-
|
|
65
|
-
return true
|
|
66
|
-
} catch (error) {
|
|
67
|
-
await fs.rm(this.lockPath, {recursive: true, force: true})
|
|
68
|
-
throw error
|
|
69
|
-
}
|
|
46
|
+
this.acquired = true
|
|
70
47
|
}
|
|
71
48
|
|
|
72
49
|
/**
|
|
@@ -77,86 +54,15 @@ export default class VelociousHttpServerLock {
|
|
|
77
54
|
if (!this.acquired) return
|
|
78
55
|
|
|
79
56
|
this.acquired = false
|
|
80
|
-
await
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/**
|
|
84
|
-
* Writes metadata used to explain or reclaim an existing server lock.
|
|
85
|
-
* @returns {Promise<void>} - Resolves after owner metadata has been written.
|
|
86
|
-
*/
|
|
87
|
-
async writeOwnerMetadata() {
|
|
88
|
-
await fs.writeFile(path.join(this.lockPath, "owner.json"), JSON.stringify({
|
|
89
|
-
acquiredAt: new Date().toISOString(),
|
|
90
|
-
host: this.host,
|
|
91
|
-
hostname: os.hostname(),
|
|
92
|
-
pid: process.pid,
|
|
93
|
-
port: this.port
|
|
94
|
-
}))
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
/**
|
|
98
|
-
* Checks whether the current lock owner is a dead process on this host.
|
|
99
|
-
* @returns {Promise<boolean>} - Whether the existing lock belongs to a dead process.
|
|
100
|
-
*/
|
|
101
|
-
async isStale() {
|
|
102
|
-
const owner = await this.readOwnerMetadata()
|
|
103
|
-
|
|
104
|
-
if (!this.isLocalProcessOwner(owner)) return false
|
|
105
|
-
|
|
106
|
-
return this.processIsDead(/** @type {{pid: number}} */ (owner).pid)
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/**
|
|
110
|
-
* Runs is local process owner.
|
|
111
|
-
* @param {Record<string, ReturnType<typeof JSON.parse>> | null} owner - Existing lock owner metadata.
|
|
112
|
-
* @returns {boolean} - Whether owner metadata names a local process.
|
|
113
|
-
*/
|
|
114
|
-
isLocalProcessOwner(owner) {
|
|
115
|
-
if (!owner) return false
|
|
116
|
-
if (typeof owner.pid !== "number") return false
|
|
117
|
-
|
|
118
|
-
return this.ownerHostnameMatches(owner)
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/**
|
|
122
|
-
* Runs owner hostname matches.
|
|
123
|
-
* @param {Record<string, ReturnType<typeof JSON.parse>>} owner - Existing lock owner metadata.
|
|
124
|
-
* @returns {boolean} - Whether the owner hostname is local or absent.
|
|
125
|
-
*/
|
|
126
|
-
ownerHostnameMatches(owner) {
|
|
127
|
-
if (!owner.hostname) return true
|
|
128
|
-
if (owner.hostname === os.hostname()) return true
|
|
129
|
-
|
|
130
|
-
return false
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
/**
|
|
134
|
-
* Runs process is dead.
|
|
135
|
-
* @param {number} pid - Process id.
|
|
136
|
-
* @returns {boolean} - Whether the process no longer exists.
|
|
137
|
-
*/
|
|
138
|
-
processIsDead(pid) {
|
|
139
|
-
try {
|
|
140
|
-
process.kill(pid, 0)
|
|
141
|
-
|
|
142
|
-
return false
|
|
143
|
-
} catch (error) {
|
|
144
|
-
return /** @type {{code?: string}} */ (error).code === "ESRCH"
|
|
145
|
-
}
|
|
57
|
+
await this.backend.release("server")
|
|
146
58
|
}
|
|
147
59
|
|
|
148
60
|
/**
|
|
149
61
|
* Reads owner metadata from an existing server lock directory.
|
|
150
|
-
* @returns {Promise<
|
|
62
|
+
* @returns {Promise<import("../database/advisory-lock-file-backend.js").AdvisoryLockFileOwner | null>} - Parsed owner metadata, when readable.
|
|
151
63
|
*/
|
|
152
64
|
async readOwnerMetadata() {
|
|
153
|
-
|
|
154
|
-
const rawOwner = await fs.readFile(path.join(this.lockPath, "owner.json"), "utf8")
|
|
155
|
-
|
|
156
|
-
return /** @type {Record<string, ReturnType<typeof JSON.parse>>} */ (JSON.parse(rawOwner))
|
|
157
|
-
} catch {
|
|
158
|
-
return null
|
|
159
|
-
}
|
|
65
|
+
return await this.backend.holderInfo("server")
|
|
160
66
|
}
|
|
161
67
|
|
|
162
68
|
/**
|
|
@@ -1635,6 +1635,23 @@ export type TenantDatabaseProviderType = {
|
|
|
1635
1635
|
tenant: ReturnType<typeof JSON.parse>;
|
|
1636
1636
|
}) => void | Promise<void>;
|
|
1637
1637
|
};
|
|
1638
|
+
export type AdvisoryLocksConfiguration = {
|
|
1639
|
+
/**
|
|
1640
|
+
* - Backend enablement. `file.enabled` defaults to false; enabling it is the deployment's explicit single-host declaration.
|
|
1641
|
+
*/
|
|
1642
|
+
backends?: {
|
|
1643
|
+
file?: {
|
|
1644
|
+
directory?: string;
|
|
1645
|
+
enabled?: boolean;
|
|
1646
|
+
leaseMs?: number;
|
|
1647
|
+
renewIntervalMs?: number;
|
|
1648
|
+
};
|
|
1649
|
+
};
|
|
1650
|
+
/**
|
|
1651
|
+
* - Per-lock-name minimum/allowed/preferred tier rules (design §5).
|
|
1652
|
+
*/
|
|
1653
|
+
policy?: import("./database/advisory-lock-backend-policy.js").AdvisoryLockBackendPolicyEntry[];
|
|
1654
|
+
};
|
|
1638
1655
|
export type ConfigurationArgsType = {
|
|
1639
1656
|
/**
|
|
1640
1657
|
* - Globally enable auto-batch-preload of relationships on lazy access. Default true.
|
|
@@ -1656,6 +1673,10 @@ export type ConfigurationArgsType = {
|
|
|
1656
1673
|
* - Resolver for creating request-scoped ability instances.
|
|
1657
1674
|
*/
|
|
1658
1675
|
abilityResolver?: AbilityResolverType;
|
|
1676
|
+
/**
|
|
1677
|
+
* - Advisory-lock backend chain configuration (backends + per-lock-name tier policy). Defaults to today's driver-native-only behavior.
|
|
1678
|
+
*/
|
|
1679
|
+
advisoryLocks?: AdvisoryLocksConfiguration;
|
|
1659
1680
|
/**
|
|
1660
1681
|
* - Attachment storage configuration.
|
|
1661
1682
|
*/
|
|
@@ -2519,6 +2540,15 @@ export type ConfigurationArgsType = {
|
|
|
2519
2540
|
* @property {(args: {configuration: import("./configuration.js").default, databaseConfiguration: DatabaseConfigurationType, identifier: string, tenant: ReturnType<typeof JSON.parse>}) => void | Promise<void>} [checkTenant] - Checks one tenant database before generic connection validation.
|
|
2520
2541
|
* @property {(args: {configuration: import("./configuration.js").default, databaseConfiguration: DatabaseConfigurationType, identifier: string, migrationsApplied: number, tenant: ReturnType<typeof JSON.parse>}) => void | Promise<void>} [afterMigrateTenant] - Runs app-owned tenant work after generic migrations for one tenant. `migrationsApplied` is how many migrations actually ran (0 when the tenant was already up to date), so the app can skip expensive per-tenant work on no-op deploys.
|
|
2521
2542
|
*/
|
|
2543
|
+
/**
|
|
2544
|
+
* Advisory-lock backend chain configuration (STC-210 design §10, file tier
|
|
2545
|
+
* implemented by STC-213). Everything defaults to today's driver-native-only
|
|
2546
|
+
* behavior; the file backend is disabled by default and only selectable by
|
|
2547
|
+
* explicit configuration.
|
|
2548
|
+
* @typedef {object} AdvisoryLocksConfiguration
|
|
2549
|
+
* @property {{file?: {directory?: string, enabled?: boolean, leaseMs?: number, renewIntervalMs?: number}}} [backends] - Backend enablement. `file.enabled` defaults to false; enabling it is the deployment's explicit single-host declaration.
|
|
2550
|
+
* @property {import("./database/advisory-lock-backend-policy.js").AdvisoryLockBackendPolicyEntry[]} [policy] - Per-lock-name minimum/allowed/preferred tier rules (design §5).
|
|
2551
|
+
*/
|
|
2522
2552
|
/**
|
|
2523
2553
|
* @typedef {object} ConfigurationArgsType
|
|
2524
2554
|
* @property {boolean} [autoload] - Globally enable auto-batch-preload of relationships on lazy access. Default true.
|
|
@@ -2526,6 +2556,7 @@ export type ConfigurationArgsType = {
|
|
|
2526
2556
|
* @property {string} [cookieSecret] - Secret for encrypting cookies.
|
|
2527
2557
|
* @property {AbilityResourceClassType[]} [abilityResources] - Resource classes used to define abilities per model.
|
|
2528
2558
|
* @property {AbilityResolverType} [abilityResolver] - Resolver for creating request-scoped ability instances.
|
|
2559
|
+
* @property {AdvisoryLocksConfiguration} [advisoryLocks] - Advisory-lock backend chain configuration (backends + per-lock-name tier policy). Defaults to today's driver-native-only behavior.
|
|
2529
2560
|
* @property {AttachmentsConfiguration} [attachments] - Attachment storage configuration.
|
|
2530
2561
|
* @property {BackendProjectConfiguration[]} [backendProjects] - Backend project definitions used for frontend model generation.
|
|
2531
2562
|
* @property {VelociousPackageConfiguration[]} [packages] - External Velocious packages that contribute models, frontend-model resources and migrations.
|