velocious 1.0.691 → 1.0.693
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 +6 -1
- package/build/background-jobs/scheduled-enqueue-bound.js +18 -0
- package/build/background-jobs/scheduled-enqueue-timeout-error.js +25 -0
- package/build/background-jobs/scheduler.js +121 -4
- package/build/background-jobs/store.js +61 -0
- package/build/cli/commands/mcp.js +7 -0
- package/build/cli/index.js +1 -1
- package/build/configuration-types.js +3 -0
- package/build/configuration.js +16 -1
- package/build/database/advisory-lock-bound.js +16 -0
- package/build/database/drivers/base.js +77 -4
- package/build/database/drivers/mysql/index.js +23 -0
- package/build/database/drivers/mysql/query.js +1 -1
- package/build/database/drivers/pgsql/index.js +27 -4
- package/build/environment-handlers/base.js +9 -0
- package/build/environment-handlers/node/cli/commands/mcp.js +87 -0
- package/build/environment-handlers/node.js +10 -0
- package/build/mcp/dispatcher.js +271 -0
- package/build/mcp/json-rpc-server.js +167 -0
- package/build/mcp/manifest-tools.js +310 -0
- package/build/routes/catch-all-route.js +58 -0
- package/build/routes/resolver.js +21 -8
- package/build/routes/root-route.js +26 -1
- package/build/src/background-jobs/scheduled-enqueue-bound.d.ts +15 -0
- package/build/src/background-jobs/scheduled-enqueue-bound.d.ts.map +1 -0
- package/build/src/background-jobs/scheduled-enqueue-bound.js +17 -0
- package/build/src/background-jobs/scheduled-enqueue-timeout-error.d.ts +24 -0
- package/build/src/background-jobs/scheduled-enqueue-timeout-error.d.ts.map +1 -0
- package/build/src/background-jobs/scheduled-enqueue-timeout-error.js +23 -0
- package/build/src/background-jobs/scheduler.d.ts +40 -0
- package/build/src/background-jobs/scheduler.d.ts.map +1 -1
- package/build/src/background-jobs/scheduler.js +111 -5
- package/build/src/background-jobs/store.d.ts +10 -0
- package/build/src/background-jobs/store.d.ts.map +1 -1
- package/build/src/background-jobs/store.js +55 -1
- package/build/src/cli/commands/mcp.d.ts +5 -0
- package/build/src/cli/commands/mcp.d.ts.map +1 -0
- package/build/src/cli/commands/mcp.js +7 -0
- package/build/src/cli/index.js +2 -2
- package/build/src/configuration-types.d.ts +15 -0
- package/build/src/configuration-types.d.ts.map +1 -1
- package/build/src/configuration-types.js +4 -1
- package/build/src/configuration.d.ts.map +1 -1
- package/build/src/configuration.js +13 -2
- package/build/src/database/advisory-lock-bound.d.ts +15 -0
- package/build/src/database/advisory-lock-bound.d.ts.map +1 -0
- package/build/src/database/advisory-lock-bound.js +16 -0
- package/build/src/database/drivers/base.d.ts +34 -1
- package/build/src/database/drivers/base.d.ts.map +1 -1
- package/build/src/database/drivers/base.js +74 -5
- package/build/src/database/drivers/mysql/index.d.ts +9 -0
- package/build/src/database/drivers/mysql/index.d.ts.map +1 -1
- package/build/src/database/drivers/mysql/index.js +20 -1
- package/build/src/database/drivers/mysql/query.js +2 -2
- package/build/src/database/drivers/pgsql/index.d.ts +15 -3
- package/build/src/database/drivers/pgsql/index.d.ts.map +1 -1
- package/build/src/database/drivers/pgsql/index.js +23 -5
- package/build/src/environment-handlers/base.d.ts +6 -0
- package/build/src/environment-handlers/base.d.ts.map +1 -1
- package/build/src/environment-handlers/base.js +9 -1
- package/build/src/environment-handlers/node/cli/commands/mcp.d.ts +19 -0
- package/build/src/environment-handlers/node/cli/commands/mcp.d.ts.map +1 -0
- package/build/src/environment-handlers/node/cli/commands/mcp.js +70 -0
- package/build/src/environment-handlers/node.d.ts +6 -0
- package/build/src/environment-handlers/node.d.ts.map +1 -1
- package/build/src/environment-handlers/node.js +10 -1
- package/build/src/mcp/dispatcher.d.ts +133 -0
- package/build/src/mcp/dispatcher.d.ts.map +1 -0
- package/build/src/mcp/dispatcher.js +232 -0
- package/build/src/mcp/json-rpc-server.d.ts +47 -0
- package/build/src/mcp/json-rpc-server.d.ts.map +1 -0
- package/build/src/mcp/json-rpc-server.js +142 -0
- package/build/src/mcp/manifest-tools.d.ts +76 -0
- package/build/src/mcp/manifest-tools.d.ts.map +1 -0
- package/build/src/mcp/manifest-tools.js +273 -0
- package/build/src/routes/catch-all-route.d.ts +48 -0
- package/build/src/routes/catch-all-route.d.ts.map +1 -0
- package/build/src/routes/catch-all-route.js +50 -0
- package/build/src/routes/resolver.d.ts.map +1 -1
- package/build/src/routes/resolver.js +23 -9
- package/build/src/routes/root-route.d.ts +15 -1
- package/build/src/routes/root-route.d.ts.map +1 -1
- package/build/src/routes/root-route.js +24 -2
- package/package.json +1 -1
- package/src/background-jobs/scheduled-enqueue-bound.js +18 -0
- package/src/background-jobs/scheduled-enqueue-timeout-error.js +25 -0
- package/src/background-jobs/scheduler.js +121 -4
- package/src/background-jobs/store.js +61 -0
- package/src/cli/commands/mcp.js +7 -0
- package/src/cli/index.js +1 -1
- package/src/configuration-types.js +3 -0
- package/src/configuration.js +16 -1
- package/src/database/advisory-lock-bound.js +16 -0
- package/src/database/drivers/base.js +77 -4
- package/src/database/drivers/mysql/index.js +23 -0
- package/src/database/drivers/mysql/query.js +1 -1
- package/src/database/drivers/pgsql/index.js +27 -4
- package/src/environment-handlers/base.js +9 -0
- package/src/environment-handlers/node/cli/commands/mcp.js +87 -0
- package/src/environment-handlers/node.js +10 -0
- package/src/mcp/dispatcher.js +271 -0
- package/src/mcp/json-rpc-server.js +167 -0
- package/src/mcp/manifest-tools.js +310 -0
- package/src/routes/catch-all-route.js +58 -0
- package/src/routes/resolver.js +21 -8
- package/src/routes/root-route.js +26 -1
package/README.md
CHANGED
|
@@ -50,6 +50,7 @@
|
|
|
50
50
|
* AbortSignal-driven MySQL/MariaDB query cancellation for raw, model, and cross-tenant aggregate queries (see [docs/database-query-cancellation.md](docs/database-query-cancellation.md))
|
|
51
51
|
* Optional built-in debug endpoint for inspecting server and database connection state (see [docs/debug-endpoint.md](docs/debug-endpoint.md))
|
|
52
52
|
* Optional built-in API manifest endpoint describing every registered frontend-model resource as human- and machine-readable JSON (see [docs/api-manifest-endpoint.md](docs/api-manifest-endpoint.md))
|
|
53
|
+
* Built-in MCP server command `velocious mcp` exposing every registered frontend-model command as MCP tools for AI clients, dispatched through the frontend-model controller with the app's own ability resolver (see [docs/mcp-server.md](docs/mcp-server.md))
|
|
53
54
|
* Backend record attachments with filesystem, S3, native callback, bounded Node path-input persistence, and model-declared client sync policy (see [docs/attachments.md](docs/attachments.md))
|
|
54
55
|
|
|
55
56
|
# Setup
|
|
@@ -975,6 +976,10 @@ Frontend-model command transport preserves `Date` and `undefined` by encoding th
|
|
|
975
976
|
|
|
976
977
|
Frontend-model commands raise an `Error` when the backend responds with `{status: "error"}` (using `errorMessage` when present), so unauthorized or missing-record update/find/destroy responses fail fast in frontend code.
|
|
977
978
|
|
|
979
|
+
## Route catch-all
|
|
980
|
+
|
|
981
|
+
Applications can declare their own 404 response in the routes file with `route.catchAll({action: "notFound", controller: "errors"})` instead of hand-wiring a route resolver hook. Unmatched requests then resolve to the given controller/action through the standard controller lookup, without tenant/ability/connection resolution. See [docs/route-catch-all.md](docs/route-catch-all.md).
|
|
982
|
+
|
|
978
983
|
## Route resolver hooks
|
|
979
984
|
|
|
980
985
|
Libraries can hook unresolved routes and hijack them before Velocious falls back to the built-in 404 controller.
|
|
@@ -2905,7 +2910,7 @@ backgroundJobs: {
|
|
|
2905
2910
|
|
|
2906
2911
|
`background-jobs-main` registers a built-in `velocious:prune-terminal-background-jobs` job on the scheduler when retention is enabled, so pruning runs as an ordinary scheduled/queued job (it needs a worker, appears in the job tables, and is bounded to one non-overlapping run). See [docs/background-jobs.md](docs/background-jobs.md#retention-pruning-old-job-rows).
|
|
2907
2912
|
|
|
2908
|
-
Candidate discovery is ordered by terminal timestamp and job id and runs outside the singleton count-revision mutation lock. Only the short transaction that revalidates and deletes the selected ids publishes a count delta, using the database's actual affected-row count, so a slow sweep or a concurrent pruner does not block enqueue acknowledgements or double-decrement counts. Queued and in-flight jobs never match retention deletion. Composite retention
|
|
2913
|
+
Candidate discovery is ordered by terminal timestamp and job id and runs outside the singleton count-revision mutation lock. Only the short transaction that revalidates and deletes the selected ids publishes a count delta, using the database's actual affected-row count, so a slow sweep or a concurrent pruner does not block enqueue acknowledgements or double-decrement counts. Queued and in-flight jobs never match retention deletion. Composite retention indexes `(status, completed_at_ms, id)`, `(status, failed_at_ms, id)`, and `(status, orphaned_at_ms, id)` are installed once during routine readiness and recorded in the internal-migrations ledger; MariaDB schema-metadata reads that collide with that online index build are retried with bounded backoff so the DDL does not block enqueue acknowledgements.
|
|
2909
2914
|
|
|
2910
2915
|
## Dashboard
|
|
2911
2916
|
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import {DEFAULT_ADVISORY_LOCK_ACQUIRE_TIMEOUT_MS} from "../database/advisory-lock-bound.js"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Default bound for one scheduled background-job enqueue attempt.
|
|
7
|
+
*
|
|
8
|
+
* A durable enqueue that never settles (wedged DB connection, advisory lock held by a dead process)
|
|
9
|
+
* used to poison its schedule key forever: `BackgroundJobsScheduler` kept the in-flight promise in
|
|
10
|
+
* `pendingEnqueuesByJobKey`, every later tick returned early, and shutdown could never drain. The
|
|
11
|
+
* bound makes that stall observable and self-healing instead of silent.
|
|
12
|
+
*
|
|
13
|
+
* It is deliberately larger than the advisory-lock acquisition bound, because a scheduled enqueue
|
|
14
|
+
* waits for a lock *and then* inserts. Keeping the enqueue watchdog outside the lock bound lets the
|
|
15
|
+
* precise `AdvisoryLockTimeoutError` surface first — naming the lock that is actually stuck —
|
|
16
|
+
* instead of a generic enqueue timeout that hides which lock caused the stall.
|
|
17
|
+
* @type {number} */
|
|
18
|
+
export const DEFAULT_SCHEDULED_ENQUEUE_TIMEOUT_MS = DEFAULT_ADVISORY_LOCK_ACQUIRE_TIMEOUT_MS + 5 * 60 * 1000
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import {TimeoutError} from "awaitery/build/timeout.js"
|
|
4
|
+
|
|
5
|
+
/** Typed failure for a scheduled background-job enqueue that never settled within its bound. */
|
|
6
|
+
export default class ScheduledBackgroundJobEnqueueTimeoutError extends TimeoutError {
|
|
7
|
+
/**
|
|
8
|
+
* Builds a scheduled-enqueue timeout error without job arguments or connection details.
|
|
9
|
+
* @param {object} args - Safe timeout context.
|
|
10
|
+
* @param {string} args.jobKey - Schedule key whose enqueue stalled.
|
|
11
|
+
* @param {string} args.jobName - Resolved job class name.
|
|
12
|
+
* @param {number} args.scheduledEnqueueTimeoutMs - Configured bound that expired.
|
|
13
|
+
* @param {TimeoutError} [args.cause] - Original watchdog timeout.
|
|
14
|
+
*/
|
|
15
|
+
constructor({jobKey, jobName, scheduledEnqueueTimeoutMs, cause}) {
|
|
16
|
+
super(`Scheduled background job enqueue did not settle within ${scheduledEnqueueTimeoutMs}ms`, cause ? {cause} : undefined)
|
|
17
|
+
|
|
18
|
+
this.name = "ScheduledBackgroundJobEnqueueTimeoutError"
|
|
19
|
+
/** @type {"BACKGROUND_JOB_SCHEDULED_ENQUEUE_TIMEOUT"} */
|
|
20
|
+
this.code = "BACKGROUND_JOB_SCHEDULED_ENQUEUE_TIMEOUT"
|
|
21
|
+
this.jobKey = jobKey
|
|
22
|
+
this.jobName = jobName
|
|
23
|
+
this.scheduledEnqueueTimeoutMs = scheduledEnqueueTimeoutMs
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
|
|
3
|
+
import {DEFAULT_SCHEDULED_ENQUEUE_TIMEOUT_MS} from "./scheduled-enqueue-bound.js"
|
|
4
|
+
import ScheduledBackgroundJobEnqueueTimeoutError from "./scheduled-enqueue-timeout-error.js"
|
|
5
|
+
|
|
3
6
|
import Logger from "../logger.js"
|
|
4
7
|
import {nextCronFireDate, parseCronExpression} from "./cron-expression.js"
|
|
5
8
|
|
|
@@ -25,6 +28,12 @@ const DURATION_MULTIPLIERS = {
|
|
|
25
28
|
weeks: 7 * 24 * 60 * 60 * 1000
|
|
26
29
|
}
|
|
27
30
|
|
|
31
|
+
// The watchdog below is internal machinery, not a schedule timer. Capturing the
|
|
32
|
+
// platform timers at module load keeps them out of a caller's schedule-timer
|
|
33
|
+
// observation, so bounding an enqueue never appears as an extra scheduled job.
|
|
34
|
+
const armWatchdogTimer = globalThis.setTimeout
|
|
35
|
+
const cancelWatchdogTimer = globalThis.clearTimeout
|
|
36
|
+
|
|
28
37
|
/**
|
|
29
38
|
* Runs the parseScheduledDuration helper.
|
|
30
39
|
* @param {number | string} value - Duration value.
|
|
@@ -73,6 +82,8 @@ export default class BackgroundJobsScheduler {
|
|
|
73
82
|
this.configuration = configuration
|
|
74
83
|
this.enqueueJob = enqueueJob
|
|
75
84
|
this.logger = new Logger(this)
|
|
85
|
+
/** @type {number | null} - Bound (ms) for one scheduled enqueue attempt; `null` disables the bound. */
|
|
86
|
+
this.scheduledEnqueueTimeoutMs = this._resolveScheduledEnqueueTimeoutMs()
|
|
76
87
|
/**
|
|
77
88
|
* Narrows the runtime value to the documented type.
|
|
78
89
|
* @type {Array<ReturnType<typeof setInterval>>} */
|
|
@@ -270,17 +281,123 @@ export default class BackgroundJobsScheduler {
|
|
|
270
281
|
// of identical scheduled jobs when the queue backed up. Dedup is by job identity (see the
|
|
271
282
|
// store), so the job keeps its queue-derived concurrency cap. A schedule can opt out with
|
|
272
283
|
// `deduplicateWhileQueued: false`.
|
|
273
|
-
await this.
|
|
274
|
-
args: Array.isArray(jobConfiguration.args) ? jobConfiguration.args : [],
|
|
275
|
-
jobClass: jobConfiguration.class,
|
|
284
|
+
await this._withScheduledEnqueueTimeout({
|
|
276
285
|
jobKey,
|
|
277
|
-
|
|
286
|
+
jobName: jobConfiguration.class.jobName(),
|
|
287
|
+
run: async () => {
|
|
288
|
+
await this.enqueueJob({
|
|
289
|
+
args: Array.isArray(jobConfiguration.args) ? jobConfiguration.args : [],
|
|
290
|
+
jobClass: jobConfiguration.class,
|
|
291
|
+
jobKey,
|
|
292
|
+
options: {deduplicateWhileQueued: true, ...(jobConfiguration.options || {})}
|
|
293
|
+
})
|
|
294
|
+
}
|
|
278
295
|
})
|
|
279
296
|
} catch (error) {
|
|
297
|
+
if (error instanceof ScheduledBackgroundJobEnqueueTimeoutError) {
|
|
298
|
+
this._reportScheduledEnqueueStall({error, jobKey})
|
|
299
|
+
|
|
300
|
+
return
|
|
301
|
+
}
|
|
302
|
+
|
|
280
303
|
await this.logger.error(() => ["Failed to enqueue scheduled background job", {jobKey, jobName: jobConfiguration.class.jobName()}, error])
|
|
281
304
|
}
|
|
282
305
|
}
|
|
283
306
|
|
|
307
|
+
/**
|
|
308
|
+
* Runs one durable enqueue attempt under the scheduled-enqueue bound.
|
|
309
|
+
*
|
|
310
|
+
* The bound is applied with a local race rather than `awaitery`'s `timeout()`:
|
|
311
|
+
* that helper starts its callback through `Promise.resolve().then(...)`, which
|
|
312
|
+
* would defer every scheduled enqueue by a microtask and change the tick
|
|
313
|
+
* semantics callers rely on. Here the attempt starts synchronously, and the
|
|
314
|
+
* watchdog uses the module-captured timers so it is never observable as a
|
|
315
|
+
* schedule timer. Only the watchdog raises the typed timeout error, so a
|
|
316
|
+
* timeout raised inside the enqueue itself keeps its own error path.
|
|
317
|
+
* @param {object} args - Options.
|
|
318
|
+
* @param {string} args.jobKey - Schedule key.
|
|
319
|
+
* @param {string} args.jobName - Resolved job class name.
|
|
320
|
+
* @param {() => Promise<void>} args.run - Enqueue attempt.
|
|
321
|
+
* @returns {Promise<void>} - Resolves when the attempt settles within the bound.
|
|
322
|
+
*/
|
|
323
|
+
async _withScheduledEnqueueTimeout({jobKey, jobName, run}) {
|
|
324
|
+
const timeoutMs = this.scheduledEnqueueTimeoutMs
|
|
325
|
+
/** @type {Promise<void>} */
|
|
326
|
+
let attempt
|
|
327
|
+
|
|
328
|
+
try {
|
|
329
|
+
attempt = Promise.resolve(run())
|
|
330
|
+
} catch (error) {
|
|
331
|
+
attempt = Promise.reject(/** @type {Error} */ (error))
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (timeoutMs === null) return await attempt
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Rejects the watchdog promise when the bound expires.
|
|
338
|
+
* @type {(error: Error) => void} */
|
|
339
|
+
let rejectScheduledEnqueueTimeout = () => {}
|
|
340
|
+
const watchdog = new Promise((_, reject) => { rejectScheduledEnqueueTimeout = reject })
|
|
341
|
+
/**
|
|
342
|
+
* Watchdog timer handle.
|
|
343
|
+
* @type {ReturnType<typeof armWatchdogTimer>} */
|
|
344
|
+
const watchdogId = armWatchdogTimer(() => {
|
|
345
|
+
rejectScheduledEnqueueTimeout(new ScheduledBackgroundJobEnqueueTimeoutError({
|
|
346
|
+
jobKey,
|
|
347
|
+
jobName,
|
|
348
|
+
scheduledEnqueueTimeoutMs: timeoutMs
|
|
349
|
+
}))
|
|
350
|
+
}, timeoutMs)
|
|
351
|
+
|
|
352
|
+
try {
|
|
353
|
+
return await Promise.race([attempt, watchdog])
|
|
354
|
+
} finally {
|
|
355
|
+
cancelWatchdogTimer(watchdogId)
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Surfaces a scheduled enqueue whose store call never settled. Without this report the only
|
|
361
|
+
* symptom is a schedule that silently stops enqueueing after one wedged call.
|
|
362
|
+
* @param {object} args - Options.
|
|
363
|
+
* @param {Error} args.error - Timeout error.
|
|
364
|
+
* @param {string} args.jobKey - Schedule key.
|
|
365
|
+
* @returns {void}
|
|
366
|
+
*/
|
|
367
|
+
_reportScheduledEnqueueStall({error, jobKey}) {
|
|
368
|
+
const payload = {
|
|
369
|
+
context: {
|
|
370
|
+
jobKey,
|
|
371
|
+
scheduledEnqueueTimeoutMs: this.scheduledEnqueueTimeoutMs,
|
|
372
|
+
stage: "background-jobs-scheduled-enqueue-stall"
|
|
373
|
+
},
|
|
374
|
+
error
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
void this.logger.error(() => ["Scheduled background job enqueue stalled", payload.context, error])
|
|
378
|
+
|
|
379
|
+
const errorEvents = typeof this.configuration?.getErrorEvents === "function" ? this.configuration.getErrorEvents() : undefined
|
|
380
|
+
|
|
381
|
+
if (!errorEvents) return
|
|
382
|
+
|
|
383
|
+
errorEvents.emit("framework-error", payload)
|
|
384
|
+
errorEvents.emit("all-error", {...payload, errorType: "framework-error"})
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Resolves the scheduled-enqueue bound from configuration.
|
|
389
|
+
* @returns {number | null} - Bound in milliseconds, or `null` for an unbounded wait.
|
|
390
|
+
*/
|
|
391
|
+
_resolveScheduledEnqueueTimeoutMs() {
|
|
392
|
+
const backgroundJobsConfig = typeof this.configuration?.getBackgroundJobsConfig === "function" ? this.configuration.getBackgroundJobsConfig() : undefined
|
|
393
|
+
const configured = backgroundJobsConfig?.scheduledEnqueueTimeoutMs
|
|
394
|
+
|
|
395
|
+
if (configured === null) return null
|
|
396
|
+
if (typeof configured === "number" && configured > 0) return configured
|
|
397
|
+
|
|
398
|
+
return DEFAULT_SCHEDULED_ENQUEUE_TIMEOUT_MS
|
|
399
|
+
}
|
|
400
|
+
|
|
284
401
|
/**
|
|
285
402
|
* Runs normalize every.
|
|
286
403
|
* @param {NonNullable<import("../configuration-types.js").ScheduledBackgroundJobConfiguration["every"]>} every - Every config (caller must guarantee not undefined).
|
|
@@ -116,6 +116,17 @@ const SCHEDULE_KEYS_TABLE = "background_job_schedule_keys"
|
|
|
116
116
|
const SCHEDULE_ORDER_WATERMARKS_TABLE = "background_job_schedule_order_watermarks"
|
|
117
117
|
const SCHEDULE_ORDER_WATERMARK_MIGRATION_VERSION = "20260911120000"
|
|
118
118
|
const SCHEDULE_HISTORY_ORDER_INDEX = "index_background_jobs_schedule_history_order"
|
|
119
|
+
// Composite retention indexes bound the terminal-status discovery scan to the expired
|
|
120
|
+
// (status, terminal_timestamp) range, so retention candidate pages stay cheap on
|
|
121
|
+
// multi-million-row tables. Activation was staged separately from the lock-scope fix
|
|
122
|
+
// because MariaDB online index DDL makes concurrent metadata inspection fail with
|
|
123
|
+
// ER_WARN_I_S_SKIPPED_TABLE; the driver now outlasts that skip with a bounded retry.
|
|
124
|
+
const RETENTION_INDEX_MIGRATION_VERSION = "20261012120000"
|
|
125
|
+
const RETENTION_INDEX_DEFINITIONS = [
|
|
126
|
+
{columns: ["status", "completed_at_ms", "id"], name: "index_background_jobs_completed_retention"},
|
|
127
|
+
{columns: ["status", "failed_at_ms", "id"], name: "index_background_jobs_failed_retention"},
|
|
128
|
+
{columns: ["status", "orphaned_at_ms", "id"], name: "index_background_jobs_orphaned_retention"}
|
|
129
|
+
]
|
|
119
130
|
const CONCURRENCY_TABLE = "background_job_concurrency"
|
|
120
131
|
const COUNTS_REVISION_TABLE = "background_job_count_revisions"
|
|
121
132
|
const COUNTS_REVISION_KEY = "counts"
|
|
@@ -2155,6 +2166,7 @@ export default class BackgroundJobsStore extends BackgroundJobsAdapter {
|
|
|
2155
2166
|
await this._ensureScheduleKeysTable(db)
|
|
2156
2167
|
await this._ensureConcurrencyTable(db)
|
|
2157
2168
|
await this._ensureCountRevisionTable(db)
|
|
2169
|
+
await this._ensureRetentionIndexesOnce(db)
|
|
2158
2170
|
|
|
2159
2171
|
return
|
|
2160
2172
|
}
|
|
@@ -2360,6 +2372,7 @@ export default class BackgroundJobsStore extends BackgroundJobsAdapter {
|
|
|
2360
2372
|
await this._ensureJobTimeoutColumn(db)
|
|
2361
2373
|
await this._ensureChildAcceptanceColumns(db)
|
|
2362
2374
|
await this._ensureJobsTableIndexesOnce(db)
|
|
2375
|
+
await this._ensureRetentionIndexesOnce(db)
|
|
2363
2376
|
}
|
|
2364
2377
|
|
|
2365
2378
|
/**
|
|
@@ -2451,6 +2464,54 @@ export default class BackgroundJobsStore extends BackgroundJobsAdapter {
|
|
|
2451
2464
|
}
|
|
2452
2465
|
}
|
|
2453
2466
|
|
|
2467
|
+
/**
|
|
2468
|
+
* Idempotently creates the ordered composite retention indexes. An index
|
|
2469
|
+
* with the expected name but a different column order is a manual-repair
|
|
2470
|
+
* situation and fails readiness loudly instead of being rebuilt or
|
|
2471
|
+
* re-recorded; the migration ledger is written only once every expected
|
|
2472
|
+
* index exists with its exact ordered columns.
|
|
2473
|
+
* @param {import("../database/drivers/base.js").default} db - Database connection.
|
|
2474
|
+
* @returns {Promise<void>} - Resolves when all retention indexes exist.
|
|
2475
|
+
*/
|
|
2476
|
+
async _ensureRetentionIndexesOnce(db) {
|
|
2477
|
+
const migrationVersion = RETENTION_INDEX_MIGRATION_VERSION
|
|
2478
|
+
const migrationKey = this._migrationKey(migrationVersion)
|
|
2479
|
+
|
|
2480
|
+
if (await this._hasMigration(db, migrationVersion)) return
|
|
2481
|
+
|
|
2482
|
+
const acquired = await db.acquireAdvisoryLock(migrationKey)
|
|
2483
|
+
|
|
2484
|
+
if (!acquired) throw new Error("Failed to acquire background jobs retention index lock")
|
|
2485
|
+
|
|
2486
|
+
try {
|
|
2487
|
+
if (await this._hasMigration(db, migrationVersion)) return
|
|
2488
|
+
|
|
2489
|
+
db.clearSchemaCache()
|
|
2490
|
+
const table = await db.getTableByNameOrFail(JOBS_TABLE)
|
|
2491
|
+
const existingIndexColumns = new Map(
|
|
2492
|
+
(await table.getIndexes()).map((index) => [index.getName(), index.getColumnNames()])
|
|
2493
|
+
)
|
|
2494
|
+
|
|
2495
|
+
for (const {columns, name} of RETENTION_INDEX_DEFINITIONS) {
|
|
2496
|
+
const existingColumns = existingIndexColumns.get(name)
|
|
2497
|
+
|
|
2498
|
+
if (existingColumns) {
|
|
2499
|
+
if (existingColumns.join(",") === columns.join(",")) continue
|
|
2500
|
+
throw new Error(`Background jobs retention index ${name} exists with unexpected columns: ${existingColumns.join(",")}`)
|
|
2501
|
+
}
|
|
2502
|
+
|
|
2503
|
+
for (const sql of await db.createIndexSQLs({columns, ifNotExists: db.getType() === "sqlite", name, tableName: JOBS_TABLE})) {
|
|
2504
|
+
await db.query(sql)
|
|
2505
|
+
}
|
|
2506
|
+
}
|
|
2507
|
+
|
|
2508
|
+
db.clearSchemaCache()
|
|
2509
|
+
await this._recordMigration(db, migrationVersion)
|
|
2510
|
+
} finally {
|
|
2511
|
+
await db.releaseAdvisoryLock(migrationKey)
|
|
2512
|
+
}
|
|
2513
|
+
}
|
|
2514
|
+
|
|
2454
2515
|
/**
|
|
2455
2516
|
* Idempotently adds the per-job wall-clock timeout to existing job tables.
|
|
2456
2517
|
* @param {import("../database/drivers/base.js").default} db - Database connection.
|
package/build/cli/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
|
|
3
|
-
const COMMAND_VALUE_OPTIONS = new Set(["--generation", "--initial-generation-state", "--lifecycle-socket", "--phase", "--socket", "--tenant"])
|
|
3
|
+
const COMMAND_VALUE_OPTIONS = new Set(["--generation", "--initial-generation-state", "--lifecycle-socket", "--phase", "--request-context", "--socket", "--tenant"])
|
|
4
4
|
|
|
5
5
|
export default class VelociousCli {
|
|
6
6
|
/**
|
|
@@ -68,6 +68,7 @@
|
|
|
68
68
|
|
|
69
69
|
/**
|
|
70
70
|
* @typedef {object} DatabaseConfigurationType
|
|
71
|
+
* @property {number | null} [advisoryLockAcquireTimeoutMs] - Bound (ms) a blocking advisory-lock acquisition waits for a contended lock before reporting failure. Default: `900000` (15 minutes). `null` opts out and restores the driver's indefinite wait. A per-call `timeoutMs` argument always overrides this.
|
|
71
72
|
* @property {string} [databaseCharset] - Default character set applied by `db:create` via mysql/mariadb `CREATE DATABASE ... CHARACTER SET`. Distinct from `charset`, which is the client connection charset forwarded to the mysql2 driver.
|
|
72
73
|
* @property {string} [databaseCollation] - Default collation applied by `db:create` via mysql/mariadb `CREATE DATABASE ... COLLATE`.
|
|
73
74
|
* @property {string} [database] - Database name for this connection.
|
|
@@ -93,6 +94,7 @@
|
|
|
93
94
|
* @property {boolean} [readOnly] - Whether writes should be blocked for this database.
|
|
94
95
|
* @property {string} [schema] - Default schema for unqualified table lookups (MSSQL).
|
|
95
96
|
* @property {boolean} [schemaCache] - Whether schema metadata should be cached on the driver. Defaults to true.
|
|
97
|
+
* @property {number} [schemaMetadataRetryDeadlineMs] - Bounded deadline (ms) for outlasting a cached schema-metadata read the server skipped because concurrent online DDL is modifying the table. Defaults to 60000.
|
|
96
98
|
* @property {object} [record] - Record-level configuration.
|
|
97
99
|
* @property {boolean} [record.transactions] - Whether record operations should use transactions.
|
|
98
100
|
* @property {boolean} [reset] - Whether to reset the database on startup.
|
|
@@ -221,6 +223,7 @@
|
|
|
221
223
|
* Priorities may be any number, including negative to sink a queue below the
|
|
222
224
|
* default. Jobs within the same priority keep FIFO (`scheduled_at`, then
|
|
223
225
|
* `created_at`) order. Default: `{}` (no queue caps, all queues priority `0`).
|
|
226
|
+
* @property {number | null} [scheduledEnqueueTimeoutMs] - Bounds one scheduled enqueue attempt in the main process. A durable enqueue that never settles (wedged DB connection, contended advisory lock) would otherwise keep its schedule key permanently in flight, so every later tick skipped itself and shutdown could never drain. On timeout the attempt is reported as `background-jobs-scheduled-enqueue-stall` and the key clears for the next tick. Default: 20 minutes — deliberately 5 minutes above `advisoryLockAcquireTimeoutMs` so the precise lock error surfaces first. `null` opts out.
|
|
224
227
|
* @property {BackgroundJobsDispatchStrategy} [dispatchStrategy] - How the main process
|
|
225
228
|
* detects new work. Defaults to `"beacon"` (event-driven). Set to `"polling"`
|
|
226
229
|
* to restore the legacy fixed-interval poll.
|
package/build/configuration.js
CHANGED
|
@@ -26,6 +26,7 @@ import UUID from "pure-uuid"
|
|
|
26
26
|
import translate from "gettext-universal/build/src/translate.js"
|
|
27
27
|
import Ability from "./authorization/ability.js"
|
|
28
28
|
import BackgroundJobsAdapter from "./background-jobs/adapter.js"
|
|
29
|
+
import {DEFAULT_SCHEDULED_ENQUEUE_TIMEOUT_MS} from "./background-jobs/scheduled-enqueue-bound.js"
|
|
29
30
|
import DatabaseOperation from "./database/operation.js"
|
|
30
31
|
import { initializeAuditedModelRelationships } from "./database/record/auditing.js"
|
|
31
32
|
import EventEmitter from "./utils/event-emitter.js"
|
|
@@ -1707,6 +1708,20 @@ export default class VelociousConfiguration {
|
|
|
1707
1708
|
const drainStoreOperationTimeoutMs = typeof configured.drainStoreOperationTimeoutMs === "number" && configured.drainStoreOperationTimeoutMs >= 1
|
|
1708
1709
|
? configured.drainStoreOperationTimeoutMs
|
|
1709
1710
|
: 60_000
|
|
1711
|
+
// `null` is an explicit opt-out of the scheduled-enqueue bound. A present
|
|
1712
|
+
// but non-positive number is a mistake, not an opt-out, so it falls back to
|
|
1713
|
+
// the framework default rather than silently disabling the watchdog.
|
|
1714
|
+
const scheduledEnqueueTimeoutMs = "scheduledEnqueueTimeoutMs" in configured
|
|
1715
|
+
? (
|
|
1716
|
+
configured.scheduledEnqueueTimeoutMs === null ?
|
|
1717
|
+
null
|
|
1718
|
+
: (
|
|
1719
|
+
typeof configured.scheduledEnqueueTimeoutMs === "number" && configured.scheduledEnqueueTimeoutMs > 0 ?
|
|
1720
|
+
configured.scheduledEnqueueTimeoutMs
|
|
1721
|
+
: DEFAULT_SCHEDULED_ENQUEUE_TIMEOUT_MS
|
|
1722
|
+
)
|
|
1723
|
+
)
|
|
1724
|
+
: DEFAULT_SCHEDULED_ENQUEUE_TIMEOUT_MS
|
|
1710
1725
|
const queues = configured.queues && typeof configured.queues === "object" ? configured.queues : {}
|
|
1711
1726
|
// An explicit config value wins over the env var — including `null`/`0`,
|
|
1712
1727
|
// which disable the backstop even when the environment sets a default.
|
|
@@ -1732,7 +1747,7 @@ export default class VelociousConfiguration {
|
|
|
1732
1747
|
|
|
1733
1748
|
const jobClasses = this.getBackgroundJobClasses()
|
|
1734
1749
|
|
|
1735
|
-
return {host, port, databaseIdentifier, maxConcurrentForkedJobs, maxConcurrentInlineJobs, mode, pooledRunnerCount, pooledRunnerConcurrency, pooledRunnerMaxJobs, pooledRunnerMaxRssBytes, pooledRunnerMaxLifetimeMs, runnerMaxOldSpaceSizeMb, runnerMallocArenaMax, dispatchStrategy, pollIntervalMs, drainStoreOperationTimeoutMs, queues, jobClasses, jobTimeoutMs, retention, generationId, initialGenerationState, lifecycleSocketPath}
|
|
1750
|
+
return {host, port, databaseIdentifier, maxConcurrentForkedJobs, maxConcurrentInlineJobs, mode, pooledRunnerCount, pooledRunnerConcurrency, pooledRunnerMaxJobs, pooledRunnerMaxRssBytes, pooledRunnerMaxLifetimeMs, runnerMaxOldSpaceSizeMb, runnerMallocArenaMax, dispatchStrategy, pollIntervalMs, drainStoreOperationTimeoutMs, scheduledEnqueueTimeoutMs, queues, jobClasses, jobTimeoutMs, retention, generationId, initialGenerationState, lifecycleSocketPath}
|
|
1736
1751
|
}
|
|
1737
1752
|
|
|
1738
1753
|
/**
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Default bound for one blocking advisory-lock acquisition.
|
|
5
|
+
*
|
|
6
|
+
* A blocking `acquireAdvisoryLock(name)` call with no explicit timeout used to park a pooled
|
|
7
|
+
* connection for the driver's indefinite wait (MySQL/MariaDB: one year). One wedged holder — a
|
|
8
|
+
* killed process, a runner that died while holding the lock, a connection that never returned —
|
|
9
|
+
* then convoyed every later caller on the same lock name: the queue kept growing while every pool
|
|
10
|
+
* connection sat in MariaDB's `User lock` state and no dispatch happened anywhere.
|
|
11
|
+
*
|
|
12
|
+
* The default is deliberately far above any contention observed in healthy production operation, so
|
|
13
|
+
* a legitimate lock handover is never cut off, while a wedged holder stops stealing pool capacity
|
|
14
|
+
* forever. Pass `null` explicitly to opt back in to the indefinite wait.
|
|
15
|
+
* @type {number} */
|
|
16
|
+
export const DEFAULT_ADVISORY_LOCK_ACQUIRE_TIMEOUT_MS = 15 * 60 * 1000
|
|
@@ -160,6 +160,7 @@ import BacktraceCleaner from "../../utils/backtrace-cleaner.js"
|
|
|
160
160
|
import { getDatabaseAnnotations } from "../annotations.js"
|
|
161
161
|
import { formatDateForDatabase } from "../datetime-storage.js"
|
|
162
162
|
import isDate from "../../utils/is-date.js"
|
|
163
|
+
import {DEFAULT_ADVISORY_LOCK_ACQUIRE_TIMEOUT_MS} from "../advisory-lock-bound.js"
|
|
163
164
|
import Logger from "../../logger.js"
|
|
164
165
|
import Query from "../query/index.js"
|
|
165
166
|
import QueryAbortedError from "../query-aborted-error.js"
|
|
@@ -183,6 +184,12 @@ const SCHEMA_INVALIDATION_SCAN_LIMIT = 8192
|
|
|
183
184
|
/** Maximum checkout-name characters inspected by retry diagnostics. */
|
|
184
185
|
const OPERATION_NAME_SCAN_LIMIT = 1024
|
|
185
186
|
const REDACTED_DIAGNOSTIC_LABEL = "[REDACTED]"
|
|
187
|
+
/** First backoff between retries of a schema-metadata read blocked by concurrent online DDL. */
|
|
188
|
+
const SCHEMA_METADATA_RETRY_INITIAL_DELAY_MS = 250
|
|
189
|
+
/** Largest backoff between retries of a schema-metadata read blocked by concurrent online DDL. */
|
|
190
|
+
const SCHEMA_METADATA_RETRY_MAX_DELAY_MS = 2000
|
|
191
|
+
/** Default bounded deadline for outlasting a concurrent online-DDL metadata skip. */
|
|
192
|
+
const SCHEMA_METADATA_RETRY_DEADLINE_MS = 60000
|
|
186
193
|
|
|
187
194
|
/**
|
|
188
195
|
* Builds a non-reversible, stable SQL fingerprint without retaining SQL text. Literal spelling is
|
|
@@ -777,6 +784,52 @@ export default class VelociousDatabaseDriversBase {
|
|
|
777
784
|
return this.getArgs().schemaCache !== false
|
|
778
785
|
}
|
|
779
786
|
|
|
787
|
+
/**
|
|
788
|
+
* Runs schema metadata retry deadline ms.
|
|
789
|
+
* @returns {number} - Bounded deadline for outlasting transient schema-metadata contention.
|
|
790
|
+
*/
|
|
791
|
+
_schemaMetadataRetryDeadlineMs() {
|
|
792
|
+
const configured = this.getArgs().schemaMetadataRetryDeadlineMs
|
|
793
|
+
|
|
794
|
+
return typeof configured === "number" ? configured : SCHEMA_METADATA_RETRY_DEADLINE_MS
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
/**
|
|
798
|
+
* Whether an error raised by a schema-metadata read is transient contention that a bounded
|
|
799
|
+
* retry can outlast. Drivers override this for their own contention signatures; the default
|
|
800
|
+
* treats every failure as final so only drivers that prove the transient shape retry it.
|
|
801
|
+
* @param {unknown} _error - Error raised by a schema-metadata read.
|
|
802
|
+
* @returns {boolean} - Whether the read may be retried.
|
|
803
|
+
*/
|
|
804
|
+
_isTransientSchemaMetadataError(_error) {
|
|
805
|
+
return false
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
/**
|
|
809
|
+
* Runs a schema-metadata read, retrying transient concurrent-DDL contention until the bounded
|
|
810
|
+
* deadline. Non-transient failures and exhausted deadlines propagate the original error.
|
|
811
|
+
* @template T
|
|
812
|
+
* @param {() => Promise<T>} callback - Cache-miss schema read.
|
|
813
|
+
* @returns {Promise<T>} - Resolves with the read result.
|
|
814
|
+
*/
|
|
815
|
+
async _withSchemaMetadataDdlRetry(callback) {
|
|
816
|
+
const deadlineMs = this._schemaMetadataRetryDeadlineMs()
|
|
817
|
+
const startedAt = Date.now()
|
|
818
|
+
let delayMs = SCHEMA_METADATA_RETRY_INITIAL_DELAY_MS
|
|
819
|
+
|
|
820
|
+
for (;;) {
|
|
821
|
+
try {
|
|
822
|
+
return await callback()
|
|
823
|
+
} catch (error) {
|
|
824
|
+
if (!this._isTransientSchemaMetadataError(error)) throw error
|
|
825
|
+
if (Date.now() - startedAt + delayMs > deadlineMs) throw error
|
|
826
|
+
|
|
827
|
+
await wait(delayMs)
|
|
828
|
+
delayMs = Math.min(delayMs * 2, SCHEMA_METADATA_RETRY_MAX_DELAY_MS)
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
|
|
780
833
|
/**
|
|
781
834
|
* Runs cached schema metadata.
|
|
782
835
|
* @template T
|
|
@@ -785,7 +838,7 @@ export default class VelociousDatabaseDriversBase {
|
|
|
785
838
|
* @returns {Promise<T>} - Resolves with the cached metadata.
|
|
786
839
|
*/
|
|
787
840
|
async _cachedSchemaMetadata(cacheKey, callback) {
|
|
788
|
-
if (!this._schemaCacheEnabled()) return await callback
|
|
841
|
+
if (!this._schemaCacheEnabled()) return await this._withSchemaMetadataDdlRetry(callback)
|
|
789
842
|
|
|
790
843
|
const existingPromise = this._schemaCache.get(cacheKey)
|
|
791
844
|
|
|
@@ -793,7 +846,7 @@ export default class VelociousDatabaseDriversBase {
|
|
|
793
846
|
return /** @type {T} */ (this._schemaCacheReturnValue(await existingPromise))
|
|
794
847
|
}
|
|
795
848
|
|
|
796
|
-
const promise = (async () => await callback
|
|
849
|
+
const promise = (async () => await this._withSchemaMetadataDdlRetry(callback))()
|
|
797
850
|
|
|
798
851
|
this._schemaCache.set(cacheKey, promise)
|
|
799
852
|
|
|
@@ -2876,17 +2929,37 @@ export default class VelociousDatabaseDriversBase {
|
|
|
2876
2929
|
* same name and let you serialize functionality without blocking readers
|
|
2877
2930
|
* or writers that do not participate in the same lock.
|
|
2878
2931
|
* @param {string} name - Lock name.
|
|
2879
|
-
* @param {{timeoutMs?: number | null}} [args] - Optional timeout in milliseconds; `null` or
|
|
2932
|
+
* @param {{timeoutMs?: number | null}} [args] - Optional timeout in milliseconds; `null` blocks forever, and an omitted value uses the database's `advisoryLockAcquireTimeoutMs` or the framework default.
|
|
2880
2933
|
* @returns {Promise<boolean>} - Resolves to true when the lock has been acquired, false if the timeout elapsed.
|
|
2881
2934
|
*/
|
|
2882
2935
|
async acquireAdvisoryLock(name, args = {}) {
|
|
2883
|
-
const acquired = await this._acquireAdvisoryLock(name, args)
|
|
2936
|
+
const acquired = await this._acquireAdvisoryLock(name, {timeoutMs: this.advisoryLockAcquireTimeoutMs(args)})
|
|
2884
2937
|
|
|
2885
2938
|
if (acquired) this._trackAdvisoryLock(name)
|
|
2886
2939
|
|
|
2887
2940
|
return acquired
|
|
2888
2941
|
}
|
|
2889
2942
|
|
|
2943
|
+
/**
|
|
2944
|
+
* Resolves the wait bound for one advisory-lock acquisition.
|
|
2945
|
+
* An explicit `timeoutMs` — including an explicit `null` opt-out — always
|
|
2946
|
+
* wins. Otherwise the database configuration's `advisoryLockAcquireTimeoutMs`
|
|
2947
|
+
* applies, and a null there opts out too. Only when neither is configured does
|
|
2948
|
+
* the framework default bound the wait.
|
|
2949
|
+
* @param {{timeoutMs?: number | null}} [args] - Acquisition options from the caller.
|
|
2950
|
+
* @returns {number | null} - Wait bound in milliseconds, or `null` for an indefinite wait.
|
|
2951
|
+
*/
|
|
2952
|
+
advisoryLockAcquireTimeoutMs(args = {}) {
|
|
2953
|
+
if (args.timeoutMs !== undefined) return args.timeoutMs
|
|
2954
|
+
|
|
2955
|
+
const configured = this.getArgs()?.advisoryLockAcquireTimeoutMs
|
|
2956
|
+
|
|
2957
|
+
if (configured === null) return null
|
|
2958
|
+
if (typeof configured === "number" && configured >= 0) return configured
|
|
2959
|
+
|
|
2960
|
+
return DEFAULT_ADVISORY_LOCK_ACQUIRE_TIMEOUT_MS
|
|
2961
|
+
}
|
|
2962
|
+
|
|
2890
2963
|
/**
|
|
2891
2964
|
* Driver-specific blocking advisory-lock acquisition hook.
|
|
2892
2965
|
* @abstract
|
|
@@ -723,6 +723,29 @@ export default class VelociousDatabaseDriversMysql extends Base{
|
|
|
723
723
|
return insert.toSql()
|
|
724
724
|
}
|
|
725
725
|
|
|
726
|
+
/**
|
|
727
|
+
* MariaDB skips information-schema/SHOW reads for a table whose definition is being modified
|
|
728
|
+
* by concurrent online DDL (ER_WARN_I_S_SKIPPED_TABLE). The build completes in seconds, so the
|
|
729
|
+
* skip is transient contention that the bounded schema-metadata retry can outlast. Query
|
|
730
|
+
* helpers wrap the driver error, so the code is searched through the cause chain.
|
|
731
|
+
* @param {unknown} error - Error raised by a schema-metadata read.
|
|
732
|
+
* @returns {boolean} - Whether the read hit concurrent-DDL metadata contention.
|
|
733
|
+
*/
|
|
734
|
+
_isTransientSchemaMetadataError(error) {
|
|
735
|
+
let currentError = /** @type {{code?: string, cause?: unknown} | undefined} */ (
|
|
736
|
+
/** @type {unknown} */ (error)
|
|
737
|
+
)
|
|
738
|
+
|
|
739
|
+
for (let depth = 0; depth < 5 && currentError; depth++) {
|
|
740
|
+
if (currentError.code === "ER_WARN_I_S_SKIPPED_TABLE") return true
|
|
741
|
+
currentError = /** @type {{code?: string, cause?: unknown} | undefined} */ (
|
|
742
|
+
/** @type {unknown} */ (currentError.cause)
|
|
743
|
+
)
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
return false
|
|
747
|
+
}
|
|
748
|
+
|
|
726
749
|
/**
|
|
727
750
|
* Runs get tables.
|
|
728
751
|
* @returns {Promise<Array<import("../base-table.js").default>>} - Resolves with the tables.
|
|
@@ -171,7 +171,7 @@ export default async function query(pool, sql, {signal} = {}) {
|
|
|
171
171
|
connection.release()
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
-
reject(new Error(`Query failed because of ${error}: ${sql}
|
|
174
|
+
reject(new Error(`Query failed because of ${error}: ${sql}`, {cause: error}))
|
|
175
175
|
|
|
176
176
|
return
|
|
177
177
|
}
|
|
@@ -22,6 +22,11 @@ import Update from "./sql/update.js"
|
|
|
22
22
|
|
|
23
23
|
const PG_TIMESTAMP_WITHOUT_TIMEZONE_OID = 1114
|
|
24
24
|
|
|
25
|
+
/** Smallest wait between `pg_try_advisory_lock` probes; preserves the historical 50ms cadence for short bounds. */
|
|
26
|
+
const PGSQL_ADVISORY_LOCK_POLL_MIN_INTERVAL_MS = 50
|
|
27
|
+
/** Largest wait between `pg_try_advisory_lock` probes; keeps a long bounded wait from probing tens of thousands of times. */
|
|
28
|
+
const PGSQL_ADVISORY_LOCK_POLL_MAX_INTERVAL_MS = 2000
|
|
29
|
+
|
|
25
30
|
pgTypes.setTypeParser(PG_TIMESTAMP_WITHOUT_TIMEZONE_OID, (value) => new Date(`${value.replace(" ", "T")}Z`))
|
|
26
31
|
|
|
27
32
|
export default class VelociousDatabaseDriversPgsql extends Base{
|
|
@@ -425,14 +430,32 @@ export default class VelociousDatabaseDriversPgsql extends Base{
|
|
|
425
430
|
return signed.toString()
|
|
426
431
|
}
|
|
427
432
|
|
|
433
|
+
/**
|
|
434
|
+
* Resolves the `pg_try_advisory_lock` probe cadence for one bounded wait.
|
|
435
|
+
*
|
|
436
|
+
* `pg_advisory_lock` has no native timeout, so a bounded acquisition polls. Scaling the interval
|
|
437
|
+
* with the bound keeps the historical 50ms cadence — and its fast acquisition — for the short
|
|
438
|
+
* explicit `timeoutMs` values callers already pass, while a long framework default stays
|
|
439
|
+
* responsive without turning one contended lock into tens of thousands of probe queries.
|
|
440
|
+
* @param {number} timeoutMs - Bounded wait in milliseconds.
|
|
441
|
+
* @returns {number} - Probe interval in milliseconds.
|
|
442
|
+
*/
|
|
443
|
+
static advisoryLockPollIntervalMs(timeoutMs) {
|
|
444
|
+
return Math.min(
|
|
445
|
+
Math.max(Math.floor(timeoutMs / 100), PGSQL_ADVISORY_LOCK_POLL_MIN_INTERVAL_MS),
|
|
446
|
+
PGSQL_ADVISORY_LOCK_POLL_MAX_INTERVAL_MS
|
|
447
|
+
)
|
|
448
|
+
}
|
|
449
|
+
|
|
428
450
|
/**
|
|
429
451
|
* Blocks until a PostgreSQL session-level advisory lock is acquired on
|
|
430
452
|
* this connection. Implemented via `pg_advisory_lock(bigint)`, which has
|
|
431
453
|
* no native timeout — the `timeoutMs` argument is emulated by racing a
|
|
432
|
-
* `pg_try_advisory_lock` poll loop so callers on MySQL and Postgres see
|
|
433
|
-
*
|
|
454
|
+
* `pg_try_advisory_lock` poll loop so callers on MySQL and Postgres see the
|
|
455
|
+
* same contract. The poll interval backs off to a cap so a long bounded wait
|
|
456
|
+
* does not turn one contended lock into thousands of probe queries.
|
|
434
457
|
* @param {string} name - Lock name.
|
|
435
|
-
* @param {{timeoutMs?: number | null}} [args] - Optional timeout in milliseconds; `null
|
|
458
|
+
* @param {{timeoutMs?: number | null}} [args] - Optional timeout in milliseconds; `null` or negative blocks forever, and an omitted value uses the database's `advisoryLockAcquireTimeoutMs` or the framework default.
|
|
436
459
|
* @returns {Promise<boolean>} - True if the lock was acquired, false if the timeout elapsed.
|
|
437
460
|
*/
|
|
438
461
|
async _acquireAdvisoryLock(name, {timeoutMs} = {}) {
|
|
@@ -444,7 +467,7 @@ export default class VelociousDatabaseDriversPgsql extends Base{
|
|
|
444
467
|
}
|
|
445
468
|
|
|
446
469
|
const deadline = Date.now() + timeoutMs
|
|
447
|
-
const pollIntervalMs =
|
|
470
|
+
const pollIntervalMs = VelociousDatabaseDriversPgsql.advisoryLockPollIntervalMs(timeoutMs)
|
|
448
471
|
|
|
449
472
|
while (true) {
|
|
450
473
|
if (await this._tryAcquireAdvisoryLock(name)) return true
|