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
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import {createHash} from "node:crypto"
|
|
4
|
+
import fs from "node:fs/promises"
|
|
5
|
+
import os from "node:os"
|
|
6
|
+
import path from "node:path"
|
|
7
|
+
import wait from "awaitery/build/wait.js"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Owner metadata written inside one file-lock directory.
|
|
11
|
+
* @typedef {object} AdvisoryLockFileOwner
|
|
12
|
+
* @property {number} pid - PID of the holding process.
|
|
13
|
+
* @property {string} hostname - Host of the holding process.
|
|
14
|
+
* @property {string} holderId - Unique id of the acquiring backend instance.
|
|
15
|
+
* @property {string} acquiredAt - ISO timestamp of acquisition.
|
|
16
|
+
* @property {string} leaseExpiresAt - ISO timestamp when the current lease expires without renewal.
|
|
17
|
+
* @property {string} [host] - Extra owner field: HTTP host (server lock only).
|
|
18
|
+
* @property {number} [port] - Extra owner field: HTTP port (server lock only).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Directory-based advisory-lock backend for the STC-210 `file` tier
|
|
23
|
+
* (`docs/advisory-lock-backends.md` tier (d)). This generalizes the pattern
|
|
24
|
+
* already used by `src/http-server/server-lock.js` and the Node SQLite
|
|
25
|
+
* driver's filesystem layer into one reusable backend behind the shared
|
|
26
|
+
* advisory-lock seam:
|
|
27
|
+
*
|
|
28
|
+
* - Atomic publication: a lock is claimed by `fs.mkdtemp` + `fs.rename` onto
|
|
29
|
+
* the stable lock path, so a losing candidate never observes or deletes a
|
|
30
|
+
* half-written owner record.
|
|
31
|
+
* - Lease + renewal: each hold carries a `leaseMs` lease renewed on
|
|
32
|
+
* `renewIntervalMs` while held, so a holder that dies without releasing
|
|
33
|
+
* (or whose PID was recycled) is reclaimable once the lease expires.
|
|
34
|
+
* - Staleness: a lock directory is stale when its owner metadata is
|
|
35
|
+
* unreadable, its lease expired, or its owner is a dead local process
|
|
36
|
+
* (`kill(pid, 0)` → `ESRCH`). Cross-host owners are only reclaimable via
|
|
37
|
+
* lease expiry because PID liveness cannot be probed remotely.
|
|
38
|
+
*
|
|
39
|
+
* This tier provides no fencing tokens (design §8: a file lock has no
|
|
40
|
+
* monotonic authority a partitioned holder can be checked against), so
|
|
41
|
+
* acquisitions report `fencingSupported: false` instead of pretending.
|
|
42
|
+
*/
|
|
43
|
+
export default class AdvisoryLockFileBackend {
|
|
44
|
+
/**
|
|
45
|
+
* Creates one file-lock backend.
|
|
46
|
+
* @param {object} args - Backend options.
|
|
47
|
+
* @param {string} args.directory - Directory that holds the lock directories.
|
|
48
|
+
* @param {Record<string, string | number>} [args.extraOwnerFields] - Extra fields merged into every owner metadata record (e.g. the HTTP server lock's host/port).
|
|
49
|
+
* @param {number} [args.leaseMs] - Lease duration for one hold; expired leases make a lock stale. Defaults to 30000.
|
|
50
|
+
* @param {number} [args.renewIntervalMs] - Renewal cadence while holding; must stay below `leaseMs`. Defaults to 10000.
|
|
51
|
+
* @param {"hashed" | "plain"} [args.pathStyle] - `hashed` derives `<safeName>-<hash>.lock` names (advisory-lock tier); `plain` uses `<name>.lock` (HTTP server lock). Defaults to `hashed`.
|
|
52
|
+
* @param {number} [args.pollIntervalMs] - Wait between acquisition retries. Defaults to 50.
|
|
53
|
+
*/
|
|
54
|
+
constructor({directory, extraOwnerFields = {}, leaseMs = 30_000, renewIntervalMs = 10_000, pathStyle = "hashed", pollIntervalMs = 50}) {
|
|
55
|
+
this.directory = directory
|
|
56
|
+
this.extraOwnerFields = extraOwnerFields
|
|
57
|
+
this.leaseMs = leaseMs
|
|
58
|
+
this.renewIntervalMs = renewIntervalMs
|
|
59
|
+
this.pathStyle = pathStyle
|
|
60
|
+
this.pollIntervalMs = pollIntervalMs
|
|
61
|
+
this.holderId = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
|
|
62
|
+
/** @type {Map<string, {timer: ReturnType<typeof setInterval>}>} */
|
|
63
|
+
this.holdsByName = new Map()
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Resolves the stable lock directory path for one lock name.
|
|
68
|
+
* @param {string} name - Lock name.
|
|
69
|
+
* @returns {string} - Filesystem path for the lock directory.
|
|
70
|
+
*/
|
|
71
|
+
lockPathFor(name) {
|
|
72
|
+
if (this.pathStyle === "plain") return path.join(this.directory, `${name}.lock`)
|
|
73
|
+
|
|
74
|
+
const hash = createHash("sha256").update(name).digest("hex").slice(0, 16)
|
|
75
|
+
const safeName = name.replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 64)
|
|
76
|
+
|
|
77
|
+
return path.join(this.directory, `${safeName}-${hash}.lock`)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Blocks until the named lock is acquired or `timeoutMs` elapses.
|
|
82
|
+
* @param {string} name - Lock name.
|
|
83
|
+
* @param {{timeoutMs?: number | null}} [args] - Timeout args; `null`, `undefined`, or negative waits indefinitely.
|
|
84
|
+
* @returns {Promise<boolean>} - True when acquired, false when the timeout elapsed.
|
|
85
|
+
*/
|
|
86
|
+
async acquire(name, {timeoutMs} = {}) {
|
|
87
|
+
const deadline = typeof timeoutMs === "number" && timeoutMs >= 0 ? Date.now() + timeoutMs : null
|
|
88
|
+
|
|
89
|
+
await this.ensureDirectory()
|
|
90
|
+
|
|
91
|
+
// Intentionally looping without a fixed iteration cap — either the
|
|
92
|
+
// publication succeeds, the deadline elapses, or an unexpected error is
|
|
93
|
+
// re-thrown.
|
|
94
|
+
while (true) {
|
|
95
|
+
if (await this.tryAcquire(name)) return true
|
|
96
|
+
|
|
97
|
+
if (deadline !== null) {
|
|
98
|
+
const remaining = deadline - Date.now()
|
|
99
|
+
|
|
100
|
+
if (remaining <= 0) return false
|
|
101
|
+
|
|
102
|
+
await wait(Math.min(this.pollIntervalMs, remaining))
|
|
103
|
+
} else {
|
|
104
|
+
await wait(this.pollIntervalMs)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Attempts to acquire the named lock without waiting, reclaiming a stale
|
|
111
|
+
* lock directory when one is found.
|
|
112
|
+
* @param {string} name - Lock name.
|
|
113
|
+
* @returns {Promise<boolean>} - True when this backend became the lock owner.
|
|
114
|
+
*/
|
|
115
|
+
async tryAcquire(name) {
|
|
116
|
+
if (this.holdsByName.has(name)) return false
|
|
117
|
+
|
|
118
|
+
await this.ensureDirectory()
|
|
119
|
+
|
|
120
|
+
const lockPath = this.lockPathFor(name)
|
|
121
|
+
|
|
122
|
+
if (await this.publishLockDirectory(lockPath)) return this.startHold(name)
|
|
123
|
+
|
|
124
|
+
if (await this.isStale(lockPath)) {
|
|
125
|
+
await fs.rm(lockPath, {recursive: true, force: true})
|
|
126
|
+
|
|
127
|
+
if (await this.publishLockDirectory(lockPath)) return this.startHold(name)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return false
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Releases the lock only when this backend instance owns it, mirroring the
|
|
135
|
+
* "you can only release locks you own" contract of the database tiers.
|
|
136
|
+
* @param {string} name - Lock name.
|
|
137
|
+
* @returns {Promise<boolean>} - True when the held lock was released.
|
|
138
|
+
*/
|
|
139
|
+
async release(name) {
|
|
140
|
+
const hold = this.holdsByName.get(name)
|
|
141
|
+
|
|
142
|
+
if (!hold) return false
|
|
143
|
+
|
|
144
|
+
this.clearHold(name)
|
|
145
|
+
|
|
146
|
+
const owner = await this.readOwner(this.lockPathFor(name))
|
|
147
|
+
|
|
148
|
+
if (!owner || owner.holderId !== this.holderId) return false
|
|
149
|
+
|
|
150
|
+
await fs.rm(this.lockPathFor(name), {recursive: true, force: true})
|
|
151
|
+
|
|
152
|
+
return true
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Runs is held.
|
|
157
|
+
* @param {string} name - Lock name.
|
|
158
|
+
* @returns {Promise<boolean>} - Whether the lock is currently held by a live, unexpired owner.
|
|
159
|
+
*/
|
|
160
|
+
async isHeld(name) {
|
|
161
|
+
const lockPath = this.lockPathFor(name)
|
|
162
|
+
|
|
163
|
+
try {
|
|
164
|
+
await fs.stat(lockPath)
|
|
165
|
+
} catch {
|
|
166
|
+
return false
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return !await this.isStale(lockPath)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Reads the current owner metadata for diagnostics.
|
|
174
|
+
* @param {string} name - Lock name.
|
|
175
|
+
* @returns {Promise<AdvisoryLockFileOwner | null>} - Parsed owner metadata, when a lock directory exists.
|
|
176
|
+
*/
|
|
177
|
+
async holderInfo(name) {
|
|
178
|
+
return await this.readOwner(this.lockPathFor(name))
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Releases every lock this backend instance currently holds (shutdown path).
|
|
183
|
+
* @returns {Promise<void>} - Resolves after best-effort release of all holds.
|
|
184
|
+
*/
|
|
185
|
+
async releaseAllHeld() {
|
|
186
|
+
for (const name of [...this.holdsByName.keys()]) {
|
|
187
|
+
await this.release(name)
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Ensures the parent lock directory exists.
|
|
193
|
+
* @returns {Promise<void>} - Resolves after the directory exists.
|
|
194
|
+
*/
|
|
195
|
+
async ensureDirectory() {
|
|
196
|
+
await fs.mkdir(this.directory, {recursive: true})
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Publishes a fully initialized lock directory with one atomic rename so
|
|
201
|
+
* concurrent candidates cannot observe a half-written owner record.
|
|
202
|
+
* @param {string} lockPath - Stable lock directory path.
|
|
203
|
+
* @returns {Promise<boolean>} - Whether this candidate became the lock owner.
|
|
204
|
+
*/
|
|
205
|
+
async publishLockDirectory(lockPath) {
|
|
206
|
+
const candidatePath = await fs.mkdtemp(`${lockPath}.pending-`)
|
|
207
|
+
let published = false
|
|
208
|
+
|
|
209
|
+
try {
|
|
210
|
+
await this.writeOwner(candidatePath)
|
|
211
|
+
|
|
212
|
+
try {
|
|
213
|
+
await fs.rename(candidatePath, lockPath)
|
|
214
|
+
published = true
|
|
215
|
+
return true
|
|
216
|
+
} catch (error) {
|
|
217
|
+
const code = /** @type {Error & {code?: string}} */ (error)?.code
|
|
218
|
+
|
|
219
|
+
if (code === "EEXIST" || code === "ENOTEMPTY") return false
|
|
220
|
+
throw error
|
|
221
|
+
}
|
|
222
|
+
} finally {
|
|
223
|
+
if (!published) await fs.rm(candidatePath, {recursive: true, force: true})
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Starts lease renewal for a freshly acquired hold.
|
|
229
|
+
* @param {string} name - Lock name.
|
|
230
|
+
* @returns {true} - Always true; the hold is now tracked.
|
|
231
|
+
*/
|
|
232
|
+
startHold(name) {
|
|
233
|
+
const timer = setInterval(() => {
|
|
234
|
+
void this.renewHold(name)
|
|
235
|
+
}, this.renewIntervalMs)
|
|
236
|
+
|
|
237
|
+
if (typeof timer.unref === "function") timer.unref()
|
|
238
|
+
|
|
239
|
+
this.holdsByName.set(name, {timer})
|
|
240
|
+
|
|
241
|
+
return true
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Extends this instance's lease on one held lock; stops renewing when the
|
|
246
|
+
* directory is no longer ours (the lock was reclaimed).
|
|
247
|
+
* @param {string} name - Lock name.
|
|
248
|
+
* @returns {Promise<void>} - Resolves after the renewal attempt.
|
|
249
|
+
*/
|
|
250
|
+
async renewHold(name) {
|
|
251
|
+
const hold = this.holdsByName.get(name)
|
|
252
|
+
|
|
253
|
+
if (!hold) return
|
|
254
|
+
|
|
255
|
+
const owner = await this.readOwner(this.lockPathFor(name))
|
|
256
|
+
|
|
257
|
+
if (!owner || owner.holderId !== this.holderId) {
|
|
258
|
+
this.clearHold(name)
|
|
259
|
+
return
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
await this.writeOwner(this.lockPathFor(name))
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Clears the renewal timer for one hold.
|
|
267
|
+
* @param {string} name - Lock name.
|
|
268
|
+
* @returns {void}
|
|
269
|
+
*/
|
|
270
|
+
clearHold(name) {
|
|
271
|
+
const hold = this.holdsByName.get(name)
|
|
272
|
+
|
|
273
|
+
if (!hold) return
|
|
274
|
+
|
|
275
|
+
clearInterval(hold.timer)
|
|
276
|
+
this.holdsByName.delete(name)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Writes owner + lease metadata into a lock directory.
|
|
281
|
+
* @param {string} lockDirPath - Lock directory to write metadata into.
|
|
282
|
+
* @returns {Promise<void>} - Resolves after metadata is written.
|
|
283
|
+
*/
|
|
284
|
+
async writeOwner(lockDirPath) {
|
|
285
|
+
/** @type {AdvisoryLockFileOwner} */
|
|
286
|
+
const owner = {
|
|
287
|
+
acquiredAt: new Date().toISOString(),
|
|
288
|
+
holderId: this.holderId,
|
|
289
|
+
hostname: os.hostname(),
|
|
290
|
+
leaseExpiresAt: new Date(Date.now() + this.leaseMs).toISOString(),
|
|
291
|
+
pid: process.pid,
|
|
292
|
+
...this.extraOwnerFields
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
await fs.writeFile(path.join(lockDirPath, "owner.json"), JSON.stringify(owner))
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Reads owner metadata from a lock directory.
|
|
300
|
+
* @param {string} lockPath - Lock directory path.
|
|
301
|
+
* @returns {Promise<AdvisoryLockFileOwner | null>} - Parsed owner metadata, when readable.
|
|
302
|
+
*/
|
|
303
|
+
async readOwner(lockPath) {
|
|
304
|
+
/** @type {string} */
|
|
305
|
+
let rawOwner
|
|
306
|
+
|
|
307
|
+
try {
|
|
308
|
+
rawOwner = await fs.readFile(path.join(lockPath, "owner.json"), "utf8")
|
|
309
|
+
} catch {
|
|
310
|
+
return null
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
try {
|
|
314
|
+
return /** @type {AdvisoryLockFileOwner} */ (JSON.parse(rawOwner))
|
|
315
|
+
} catch {
|
|
316
|
+
return null
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* A lock directory is stale when its metadata is unreadable, its lease has
|
|
322
|
+
* expired, or its owner is a dead process on this host. Cross-host owners
|
|
323
|
+
* are only reclaimable via lease expiry — PID probing is unreliable beyond
|
|
324
|
+
* one host, which is exactly why this tier is the opt-in single-host last
|
|
325
|
+
* resort (design §4/§8).
|
|
326
|
+
* @param {string} lockPath - Lock directory path.
|
|
327
|
+
* @returns {Promise<boolean>} - Whether the lock can be reclaimed.
|
|
328
|
+
*/
|
|
329
|
+
async isStale(lockPath) {
|
|
330
|
+
const owner = await this.readOwner(lockPath)
|
|
331
|
+
|
|
332
|
+
if (!owner || typeof owner.pid !== "number") return true
|
|
333
|
+
|
|
334
|
+
if (owner.leaseExpiresAt && new Date(owner.leaseExpiresAt).getTime() <= Date.now()) return true
|
|
335
|
+
|
|
336
|
+
if (owner.hostname && owner.hostname !== os.hostname()) return false
|
|
337
|
+
|
|
338
|
+
try {
|
|
339
|
+
// `kill(pid, 0)` is a no-op signal that fails with ESRCH when the
|
|
340
|
+
// process is gone; permission errors still prove the process exists.
|
|
341
|
+
process.kill(owner.pid, 0)
|
|
342
|
+
|
|
343
|
+
return false
|
|
344
|
+
} catch (error) {
|
|
345
|
+
return /** @type {Error & {code?: string}} */ (error).code === "ESRCH"
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|