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.
Files changed (42) hide show
  1. package/README.md +1 -2
  2. package/build/configuration-types.js +11 -0
  3. package/build/configuration.js +122 -1
  4. package/build/database/advisory-lock-backend-policy.js +204 -0
  5. package/build/database/advisory-lock-file-backend.js +348 -0
  6. package/build/database/advisory-lock-runner.js +239 -16
  7. package/build/environment-handlers/base.js +13 -0
  8. package/build/environment-handlers/node.js +11 -0
  9. package/build/http-server/server-lock.js +21 -115
  10. package/build/src/configuration-types.d.ts +31 -0
  11. package/build/src/configuration-types.d.ts.map +1 -1
  12. package/build/src/configuration-types.js +11 -1
  13. package/build/src/configuration.d.ts +88 -1
  14. package/build/src/configuration.d.ts.map +1 -1
  15. package/build/src/configuration.js +112 -2
  16. package/build/src/database/advisory-lock-backend-policy.d.ts +145 -0
  17. package/build/src/database/advisory-lock-backend-policy.d.ts.map +1 -0
  18. package/build/src/database/advisory-lock-backend-policy.js +184 -0
  19. package/build/src/database/advisory-lock-file-backend.d.ts +194 -0
  20. package/build/src/database/advisory-lock-file-backend.d.ts.map +1 -0
  21. package/build/src/database/advisory-lock-file-backend.js +313 -0
  22. package/build/src/database/advisory-lock-runner.d.ts +116 -15
  23. package/build/src/database/advisory-lock-runner.d.ts.map +1 -1
  24. package/build/src/database/advisory-lock-runner.js +190 -17
  25. package/build/src/environment-handlers/base.d.ts +14 -0
  26. package/build/src/environment-handlers/base.d.ts.map +1 -1
  27. package/build/src/environment-handlers/base.js +13 -1
  28. package/build/src/environment-handlers/node.d.ts +11 -0
  29. package/build/src/environment-handlers/node.d.ts.map +1 -1
  30. package/build/src/environment-handlers/node.js +11 -1
  31. package/build/src/http-server/server-lock.d.ts +13 -37
  32. package/build/src/http-server/server-lock.d.ts.map +1 -1
  33. package/build/src/http-server/server-lock.js +22 -109
  34. package/package.json +1 -1
  35. package/src/configuration-types.js +11 -0
  36. package/src/configuration.js +122 -1
  37. package/src/database/advisory-lock-backend-policy.js +204 -0
  38. package/src/database/advisory-lock-file-backend.js +348 -0
  39. package/src/database/advisory-lock-runner.js +239 -16
  40. package/src/environment-handlers/base.js +13 -0
  41. package/src/environment-handlers/node.js +11 -0
  42. 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
+ }