velocious 1.0.708 → 1.0.710

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 (37) hide show
  1. package/README.md +1 -1
  2. package/build/configuration-types.js +19 -3
  3. package/build/configuration.js +85 -3
  4. package/build/database/advisory-lock-errors.js +59 -0
  5. package/build/database/advisory-lock-lock-server-backend.js +557 -0
  6. package/build/database/advisory-lock-runner.js +175 -2
  7. package/build/database/migrator.js +29 -1
  8. package/build/database/record/index.js +16 -2
  9. package/build/src/configuration-types.d.ts +76 -4
  10. package/build/src/configuration-types.d.ts.map +1 -1
  11. package/build/src/configuration-types.js +19 -4
  12. package/build/src/configuration.d.ts +42 -2
  13. package/build/src/configuration.d.ts.map +1 -1
  14. package/build/src/configuration.js +82 -4
  15. package/build/src/database/advisory-lock-errors.d.ts +54 -0
  16. package/build/src/database/advisory-lock-errors.d.ts.map +1 -0
  17. package/build/src/database/advisory-lock-errors.js +52 -0
  18. package/build/src/database/advisory-lock-lock-server-backend.d.ts +279 -0
  19. package/build/src/database/advisory-lock-lock-server-backend.d.ts.map +1 -0
  20. package/build/src/database/advisory-lock-lock-server-backend.js +474 -0
  21. package/build/src/database/advisory-lock-runner.d.ts +69 -1
  22. package/build/src/database/advisory-lock-runner.d.ts.map +1 -1
  23. package/build/src/database/advisory-lock-runner.js +150 -3
  24. package/build/src/database/migrator.d.ts +13 -0
  25. package/build/src/database/migrator.d.ts.map +1 -1
  26. package/build/src/database/migrator.js +27 -2
  27. package/build/src/database/record/index.d.ts +2 -2
  28. package/build/src/database/record/index.d.ts.map +1 -1
  29. package/build/src/database/record/index.js +3 -3
  30. package/package.json +1 -1
  31. package/src/configuration-types.js +19 -3
  32. package/src/configuration.js +85 -3
  33. package/src/database/advisory-lock-errors.js +59 -0
  34. package/src/database/advisory-lock-lock-server-backend.js +557 -0
  35. package/src/database/advisory-lock-runner.js +175 -2
  36. package/src/database/migrator.js +29 -1
  37. package/src/database/record/index.js +16 -2
@@ -7,6 +7,11 @@ import {
7
7
  resolveAdvisoryLockBackendPolicy,
8
8
  refuseAdvisoryLockTierBelowMinimum
9
9
  } from "./advisory-lock-backend-policy.js"
10
+ import {
11
+ AdvisoryLockLostError,
12
+ AdvisoryLockServiceUnavailableError,
13
+ advisoryLockBackendUnavailable
14
+ } from "./advisory-lock-errors.js"
10
15
 
11
16
  /**
12
17
  * Provenance of one advisory-lock acquisition (STC-210 design §3): every
@@ -15,6 +20,7 @@ import {
15
20
  * @typedef {object} AdvisoryLockBackendProvenance
16
21
  * @property {import("./advisory-lock-backend-policy.js").AdvisoryLockTier} backend - Tier that served the acquisition.
17
22
  * @property {string} [driverType] - Database driver type when the driver tier served it.
23
+ * @property {number} [epoch] - Fencing epoch when the serving tier issues one.
18
24
  * @property {boolean} fencingSupported - Whether this tier carries fencing tokens (design §8); the file tier reports false rather than pretending.
19
25
  * @property {string} [holderId] - Backend holder id for the file tier.
20
26
  * @property {string} [lockName] - Lock name this provenance belongs to.
@@ -100,10 +106,15 @@ export default class AdvisoryLockRunner {
100
106
  * @returns {Promise<T>} - Resolves with the callback result.
101
107
  */
102
108
  async withAdvisoryLock(name, callback, args = {}) {
109
+ const lockServerResult = await this.attemptLockServer({args, callback, mode: "wait", name})
110
+
111
+ // The wait mode never returns a deferred outcome, so the value member is present.
112
+ if (lockServerResult) return /** @type {{value: T}} */ (lockServerResult).value
113
+
103
114
  const fileBackend = await this.fileBackendForName(name)
104
115
 
105
116
  if (fileBackend) {
106
- const acquired = await fileBackend.acquire(name, {timeoutMs: this.fileAcquireTimeoutMs(args)})
117
+ const acquired = await fileBackend.acquire(name, {timeoutMs: this.acquireTimeoutMs(args)})
107
118
 
108
119
  if (!acquired) {
109
120
  throw new AdvisoryLockTimeoutError(`Timed out waiting for advisory lock ${JSON.stringify(name)} on the file tier`, {name})
@@ -147,6 +158,11 @@ export default class AdvisoryLockRunner {
147
158
  * @returns {Promise<T>} - Resolves with the callback result.
148
159
  */
149
160
  async withAdvisoryLockOrFail(name, callback, args = {}) {
161
+ const lockServerResult = await this.attemptLockServer({args, callback, mode: "or-fail", name})
162
+
163
+ // The or-fail mode throws on contention, so the value member is present.
164
+ if (lockServerResult) return /** @type {{value: T}} */ (lockServerResult).value
165
+
150
166
  const fileBackend = await this.fileBackendForName(name)
151
167
 
152
168
  if (fileBackend) {
@@ -201,6 +217,14 @@ export default class AdvisoryLockRunner {
201
217
  * @returns {Promise<{acquired: true, result: T} | {acquired: false, reason: "contended", lockName: string}>} - Either the held-callback result or the defer outcome.
202
218
  */
203
219
  async withAdvisoryLockOrDefer(name, callback, args = {}) {
220
+ const lockServerResult = await this.attemptLockServer({args, callback, mode: "defer", name})
221
+
222
+ if (lockServerResult) {
223
+ if ("deferred" in lockServerResult) return lockServerResult.deferred
224
+
225
+ return {acquired: true, result: /** @type {{value: T}} */ (lockServerResult).value}
226
+ }
227
+
204
228
  const fileBackend = await this.fileBackendForName(name)
205
229
 
206
230
  if (fileBackend) {
@@ -248,6 +272,154 @@ export default class AdvisoryLockRunner {
248
272
  }
249
273
  }
250
274
 
275
+ /**
276
+ * Serves one acquisition on the lock-server tier when the lock name prefers
277
+ * it (design §1/§9), falling *upward* to the driver path when the server is
278
+ * unreachable and the name's minimum tier permits a DB-native hold.
279
+ *
280
+ * `null` means "this tier did not serve the acquisition — continue with the
281
+ * next eligible tier". A returned value means the lock-server tier answered,
282
+ * including a definitive busy/timeout outcome.
283
+ * @template T
284
+ * @param {{args: {holdTimeoutMs?: number | null, timeoutMs?: number | null}, callback: (args?: {control?: import("awaitery/build/timeout.js").TimeoutControl, lockBackend: AdvisoryLockBackendProvenance}) => Promise<T>, mode: "wait" | "or-fail" | "defer", name: string}} request - Acquisition request.
285
+ * @returns {Promise<{deferred: {acquired: false, reason: "contended", lockName: string}} | {value: T} | null>} - Result when this tier served it, otherwise null.
286
+ */
287
+ async attemptLockServer({args, callback, mode, name}) {
288
+ const policy = resolveAdvisoryLockBackendPolicy(name, this.configuration.getAdvisoryLockPolicy())
289
+
290
+ if (policy.preferredTier !== "lock-server") return null
291
+ if (!policy.allowedTiers.includes("lock-server")) return null
292
+
293
+ const backend = await this.configuration.getAdvisoryLockLockServerBackend()
294
+
295
+ if (!backend) {
296
+ this.reportAdvisoryLockFallback({reason: "lock-server tier is not enabled in configuration", servedBy: "driver-native", lockName: name})
297
+
298
+ return null
299
+ }
300
+
301
+ /** @type {boolean} */
302
+ let acquired
303
+
304
+ try {
305
+ if (mode === "wait") {
306
+ acquired = await backend.acquire(name, {timeoutMs: this.acquireTimeoutMs(args)})
307
+ } else {
308
+ acquired = await backend.tryAcquire(name)
309
+ }
310
+ } catch (error) {
311
+ if (!(error instanceof AdvisoryLockServiceUnavailableError)) throw error
312
+
313
+ if (policy.minTier === "lock-server") {
314
+ throw advisoryLockBackendUnavailable({
315
+ lockName: name,
316
+ minTier: policy.minTier,
317
+ reasons: [`lock-server unavailable: ${error.message}`]
318
+ })
319
+ }
320
+
321
+ this.reportAdvisoryLockFallback({reason: error.message, servedBy: "driver-native", lockName: name})
322
+
323
+ return null
324
+ }
325
+
326
+ if (!acquired) {
327
+ if (mode === "defer") return {deferred: {acquired: false, reason: "contended", lockName: name}}
328
+ if (mode === "or-fail") throw new AdvisoryLockBusyError(`Advisory lock ${JSON.stringify(name)} is already held`, {name})
329
+
330
+ throw new AdvisoryLockTimeoutError(`Timed out waiting for advisory lock ${JSON.stringify(name)}`, {name})
331
+ }
332
+
333
+ const epoch = backend.getHoldEpoch(name)
334
+ const lockBackend = /** @type {AdvisoryLockBackendProvenance} */ ({
335
+ backend: "lock-server",
336
+ epoch: epoch ?? undefined,
337
+ fencingSupported: true,
338
+ lockName: name
339
+ })
340
+
341
+ this.configuration.recordAdvisoryLockAcquisition({backend: "lock-server", fencingSupported: true, lockName: name})
342
+
343
+ const value = await this.runLockServerHold({
344
+ backend,
345
+ callback,
346
+ holdTimeoutMs: args.holdTimeoutMs,
347
+ lockBackend,
348
+ name
349
+ })
350
+
351
+ return {value}
352
+ }
353
+
354
+ /**
355
+ * Runs the critical section under a lock-server lease and surfaces a fenced
356
+ * lease as `AdvisoryLockLostError` (design §8): the section may have
357
+ * overlapped another holder, so its outcome must not be trusted as exclusive.
358
+ * @template T
359
+ * @param {{backend: import("./advisory-lock-lock-server-backend.js").default, callback: (args?: {control?: import("awaitery/build/timeout.js").TimeoutControl, lockBackend: AdvisoryLockBackendProvenance}) => Promise<T>, holdTimeoutMs?: number | null, lockBackend: AdvisoryLockBackendProvenance, name: string}} args - Hold request.
360
+ * @returns {Promise<T>} - Callback result.
361
+ */
362
+ async runLockServerHold({backend, callback, holdTimeoutMs, lockBackend, name}) {
363
+ /** @type {T | undefined} */
364
+ let value
365
+ /** @type {unknown} */
366
+ let callbackError
367
+ let failed = false
368
+
369
+ try {
370
+ value = await this.runLockedCallback({
371
+ callback,
372
+ holdTimeoutMs,
373
+ lockBackend,
374
+ name,
375
+ release: async () => await backend.release(name)
376
+ })
377
+ } catch (error) {
378
+ failed = true
379
+ callbackError = error
380
+ }
381
+
382
+ const loss = backend.takeLoss(name, lockBackend.epoch ?? null)
383
+
384
+ if (loss) {
385
+ throw new AdvisoryLockLostError(
386
+ `Lost the lease for advisory lock ${JSON.stringify(name)} while the critical section was running`,
387
+ {
388
+ cause: callbackError instanceof Error ? callbackError : undefined,
389
+ currentEpoch: loss.currentEpoch,
390
+ epoch: lockBackend.epoch ?? null,
391
+ lockName: name
392
+ }
393
+ )
394
+ }
395
+
396
+ if (failed) throw callbackError
397
+
398
+ return /** @type {T} */ (value)
399
+ }
400
+
401
+ /**
402
+ * Reports an acquisition served below the lock name's preferred tier
403
+ * (design §3): every fallback is visible on the framework-error channel, so a
404
+ * silent downgrade is impossible.
405
+ * @param {{lockName: string, reason: string, servedBy: import("./advisory-lock-backend-policy.js").AdvisoryLockTier}} args - Fallback details.
406
+ * @returns {void}
407
+ */
408
+ reportAdvisoryLockFallback({lockName, reason, servedBy}) {
409
+ const policy = resolveAdvisoryLockBackendPolicy(lockName, this.configuration.getAdvisoryLockPolicy())
410
+ const payload = {
411
+ context: "advisory-lock-fallback",
412
+ lockName,
413
+ preferredTier: policy.preferredTier,
414
+ reason,
415
+ servedBy
416
+ }
417
+ const errorEvents = this.configuration.getErrorEvents()
418
+
419
+ errorEvents.emit("framework-error", payload)
420
+ errorEvents.emit("all-error", {...payload, errorType: "framework-error"})
421
+ }
422
+
251
423
  /**
252
424
  * Resolves the backend policy for one lock name and returns the file
253
425
  * backend when the name explicitly opts into the file tier
@@ -320,7 +492,7 @@ export default class AdvisoryLockRunner {
320
492
  * @param {{timeoutMs?: number | null}} args - Lock options.
321
493
  * @returns {number | null} - Wait bound in milliseconds, or `null` for an indefinite wait.
322
494
  */
323
- fileAcquireTimeoutMs(args) {
495
+ acquireTimeoutMs(args) {
324
496
  if (args.timeoutMs !== undefined) return args.timeoutMs
325
497
 
326
498
  return DEFAULT_ADVISORY_LOCK_ACQUIRE_TIMEOUT_MS
@@ -474,3 +646,4 @@ function advisoryLockPolicyAllowsFileTier(policy) {
474
646
 
475
647
  export {AdvisoryLockBusyError, AdvisoryLockHoldTimeoutError, AdvisoryLockTimeoutError}
476
648
  export {AdvisoryLockBackendUnavailableError, AdvisoryLockMinTierRefusedError} from "./advisory-lock-backend-policy.js"
649
+ export {AdvisoryLockLostError, AdvisoryLockServiceUnavailableError} from "./advisory-lock-errors.js"
@@ -124,6 +124,28 @@ export default class VelociousDatabaseMigrator {
124
124
  return false
125
125
  }
126
126
 
127
+ /**
128
+ * Application migrations belong to the schema-owning lifecycle. HTTP worker
129
+ * threads (`processContext.type === "worker-handler"`) each run the
130
+ * application's `initializeModels` hook, so an app that migrates from that hook
131
+ * would apply the pending set (and write the structure dump) once per worker.
132
+ * The server process and deploy migration tasks migrate before workers start,
133
+ * so workers skip here instead of repeating that work.
134
+ * @returns {Promise<boolean>} - Whether this lifecycle must skip application migrations.
135
+ */
136
+ async _skipApplicationMigrationsInWorkerLifecycle() {
137
+ const processContext = this.configuration.getApplicationProcessContext()
138
+
139
+ if (processContext?.type !== "worker-handler") return false
140
+
141
+ await this.logger.debug("Skipping application migrations in an HTTP worker thread - the schema-owning process migrates before workers start", {
142
+ instanceId: processContext.instanceId,
143
+ processType: processContext.type
144
+ })
145
+
146
+ return true
147
+ }
148
+
127
149
  /**
128
150
  * Runs migrate files.
129
151
  * @param {import("./migrator/types.js").MigrationObjectType[]} files - Files.
@@ -131,8 +153,9 @@ export default class VelociousDatabaseMigrator {
131
153
  * @returns {Promise<number>} - Number of migrations actually applied (not skipped as already-run).
132
154
  */
133
155
  async migrateFiles(files, importCallback) {
156
+ if (await this._skipApplicationMigrationsInWorkerLifecycle()) return 0
157
+
134
158
  let appliedCount = 0
135
- console.error("[MIG-DEBUG] migrateFiles count=" + files.length)
136
159
 
137
160
  await this.configuration.ensureConnections({databaseIdentifiers: this.databaseIdentifiers, name: "Database migrator: migrate files"}, async () => {
138
161
  for (const migration of files) {
@@ -162,10 +185,15 @@ export default class VelociousDatabaseMigrator {
162
185
 
163
186
  /**
164
187
  * Runs migrate files from require context.
188
+ *
189
+ * Application migrations belong to the schema-owning lifecycle; see
190
+ * {@link VelociousDatabaseMigrator#_skipApplicationMigrationsInWorkerLifecycle}.
165
191
  * @param {import("./migrator/types.js").RequireMigrationContextType} requireContext - Require context.
166
192
  * @returns {Promise<void>} - Resolves when complete.
167
193
  */
168
194
  async migrateFilesFromRequireContext(requireContext) {
195
+ if (await this._skipApplicationMigrationsInWorkerLifecycle()) return
196
+
169
197
  const files = this.migrationsFromRequireContext(requireContext)
170
198
 
171
199
  await this.configuration.ensureConnections({databaseIdentifiers: this.databaseIdentifiers, name: "Database migrator: migrate require-context files"}, async () => {
@@ -29,7 +29,13 @@
29
29
  * @typedef {boolean | null | string | undefined | Promise<void> | string[] | import("../drivers/base-column.js").default[] | import("../drivers/base-table.js").default | Record<string, string> | Record<string, string | undefined> | Record<string, import("../drivers/base-column.js").default>} RecordMetadataValue
30
30
  */
31
31
 
32
- import AdvisoryLockRunner, {AdvisoryLockBusyError, AdvisoryLockHoldTimeoutError, AdvisoryLockTimeoutError} from "../advisory-lock-runner.js"
32
+ import AdvisoryLockRunner, {
33
+ AdvisoryLockBusyError,
34
+ AdvisoryLockHoldTimeoutError,
35
+ AdvisoryLockLostError,
36
+ AdvisoryLockServiceUnavailableError,
37
+ AdvisoryLockTimeoutError
38
+ } from "../advisory-lock-runner.js"
33
39
  import BelongsToInstanceRelationship from "./instance-relationships/belongs-to.js"
34
40
  import BelongsToRelationship from "./relationships/belongs-to.js"
35
41
  import Configuration from "../../configuration.js"
@@ -5049,5 +5055,13 @@ VelociousDatabaseRecord.registerValidatorType("length", ValidatorsLength)
5049
5055
  VelociousDatabaseRecord.registerValidatorType("presence", ValidatorsPresence)
5050
5056
  VelociousDatabaseRecord.registerValidatorType("uniqueness", ValidatorsUniqueness)
5051
5057
 
5052
- export {AdvisoryLockBusyError, AdvisoryLockHoldTimeoutError, AdvisoryLockTimeoutError, TenantDatabaseScopeError, ValidationError}
5058
+ export {
5059
+ AdvisoryLockBusyError,
5060
+ AdvisoryLockHoldTimeoutError,
5061
+ AdvisoryLockLostError,
5062
+ AdvisoryLockServiceUnavailableError,
5063
+ AdvisoryLockTimeoutError,
5064
+ TenantDatabaseScopeError,
5065
+ ValidationError
5066
+ }
5053
5067
  export default VelociousDatabaseRecord