velocious 1.0.626 → 1.0.628
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 +15 -3
- package/build/background-jobs/job-runner.js +9 -1
- package/build/background-jobs/local-dispatcher.js +23 -1
- package/build/background-jobs/main.js +6 -4
- package/build/background-jobs/perform-job.js +10 -1
- package/build/background-jobs/platform-job.js +62 -0
- package/build/background-jobs/runtime.js +6 -3
- package/build/background-jobs/types.js +8 -0
- package/build/background-jobs/worker.js +3 -1
- package/build/database/pool/async-tracked-multi-connection.js +46 -9
- package/build/database/pool/base.js +3 -0
- package/build/database/pool/checkout-timeout-error.js +16 -0
- package/build/http-client/websocket-client.js +52 -0
- package/build/http-server/client/index.js +6 -0
- package/build/http-server/client/websocket-session.js +30 -0
- package/build/http-server/index.js +202 -38
- package/build/http-server/worker-handler/index.js +15 -1
- package/build/http-server/worker-handler/worker-thread.js +8 -0
- package/build/src/background-jobs/job-runner.d.ts.map +1 -1
- package/build/src/background-jobs/job-runner.js +10 -2
- package/build/src/background-jobs/local-dispatcher.d.ts.map +1 -1
- package/build/src/background-jobs/local-dispatcher.js +24 -2
- package/build/src/background-jobs/main.d.ts.map +1 -1
- package/build/src/background-jobs/main.js +7 -5
- package/build/src/background-jobs/perform-job.d.ts +5 -1
- package/build/src/background-jobs/perform-job.d.ts.map +1 -1
- package/build/src/background-jobs/perform-job.js +11 -2
- package/build/src/background-jobs/platform-job.d.ts +34 -0
- package/build/src/background-jobs/platform-job.d.ts.map +1 -1
- package/build/src/background-jobs/platform-job.js +56 -1
- package/build/src/background-jobs/runtime.d.ts.map +1 -1
- package/build/src/background-jobs/runtime.js +7 -4
- package/build/src/background-jobs/types.d.ts +30 -0
- package/build/src/background-jobs/types.d.ts.map +1 -1
- package/build/src/background-jobs/types.js +9 -1
- package/build/src/background-jobs/worker.d.ts.map +1 -1
- package/build/src/background-jobs/worker.js +4 -2
- package/build/src/database/pool/async-tracked-multi-connection.d.ts +22 -2
- package/build/src/database/pool/async-tracked-multi-connection.d.ts.map +1 -1
- package/build/src/database/pool/async-tracked-multi-connection.js +44 -10
- package/build/src/database/pool/base.d.ts +15 -0
- package/build/src/database/pool/base.d.ts.map +1 -1
- package/build/src/database/pool/base.js +4 -1
- package/build/src/database/pool/checkout-timeout-error.d.ts +13 -0
- package/build/src/database/pool/checkout-timeout-error.d.ts.map +1 -0
- package/build/src/database/pool/checkout-timeout-error.js +15 -0
- package/build/src/http-client/websocket-client.d.ts +18 -0
- package/build/src/http-client/websocket-client.d.ts.map +1 -1
- package/build/src/http-client/websocket-client.js +48 -1
- package/build/src/http-server/client/index.d.ts.map +1 -1
- package/build/src/http-server/client/index.js +7 -1
- package/build/src/http-server/client/websocket-session.d.ts +6 -0
- package/build/src/http-server/client/websocket-session.d.ts.map +1 -1
- package/build/src/http-server/client/websocket-session.js +27 -1
- package/build/src/http-server/index.d.ts +82 -10
- package/build/src/http-server/index.d.ts.map +1 -1
- package/build/src/http-server/index.js +186 -33
- package/build/src/http-server/worker-handler/index.d.ts +28 -1
- package/build/src/http-server/worker-handler/index.d.ts.map +1 -1
- package/build/src/http-server/worker-handler/index.js +23 -2
- package/build/src/http-server/worker-handler/worker-thread.d.ts.map +1 -1
- package/build/src/http-server/worker-handler/worker-thread.js +7 -1
- package/build/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/background-jobs/job-runner.js +9 -1
- package/src/background-jobs/local-dispatcher.js +23 -1
- package/src/background-jobs/main.js +6 -4
- package/src/background-jobs/perform-job.js +10 -1
- package/src/background-jobs/platform-job.js +62 -0
- package/src/background-jobs/runtime.js +6 -3
- package/src/background-jobs/types.js +8 -0
- package/src/background-jobs/worker.js +3 -1
- package/src/database/pool/async-tracked-multi-connection.js +46 -9
- package/src/database/pool/base.js +3 -0
- package/src/database/pool/checkout-timeout-error.js +16 -0
- package/src/http-client/websocket-client.js +52 -0
- package/src/http-server/client/index.js +6 -0
- package/src/http-server/client/websocket-session.js +30 -0
- package/src/http-server/index.js +202 -38
- package/src/http-server/worker-handler/index.js +15 -1
- package/src/http-server/worker-handler/worker-thread.js +8 -0
package/README.md
CHANGED
|
@@ -1827,7 +1827,7 @@ database: {
|
|
|
1827
1827
|
}
|
|
1828
1828
|
```
|
|
1829
1829
|
|
|
1830
|
-
`pool.max` caps live async-tracked connections for that pool and defaults to `10` when omitted. When the cap is reached, new checkouts wait until a matching checked-in connection can be handed over or capacity is freed. Set `pool.max` to `null` only when a process is deliberately allowed to open an unbounded number of database connections. The built-in debug endpoint reports each in-use connection's `checkedOutForMs`, each idle connection's `idleForMs`,
|
|
1830
|
+
`pool.max` caps live async-tracked connections for that pool and defaults to `10` when omitted. When the cap is reached, new checkouts wait until a matching checked-in connection can be handed over or capacity is freed. Set `pool.max` to `null` only when a process is deliberately allowed to open an unbounded number of database connections. The built-in debug endpoint reports each in-use connection's `checkedOutForMs`, each idle connection's `idleForMs`, queued `pendingCheckouts[].waitingForMs`, and matching idle capacity plus checkout-drain state so production diagnostics can distinguish long-held checkouts, pool-capacity waits, and an invariant violation where compatible capacity is unexpectedly idle.
|
|
1831
1831
|
|
|
1832
1832
|
Debug snapshots also expose cumulative connection-creation, checkout-wait and
|
|
1833
1833
|
timeout, idle-reap, and peak-live-connection telemetry. Opt-in test profiles can
|
|
@@ -2028,7 +2028,7 @@ await client.close()
|
|
|
2028
2028
|
|
|
2029
2029
|
For long-lived Node clients, the constructor also accepts opt-in liveness options (all default off, so browser/Expo usage is unchanged): `webSocketImplementation` (inject Node's `ws`, since the global/undici WebSocket exposes neither protocol ping nor an unref-able socket), `heartbeatIntervalMs` (a ping heartbeat that drops a socket whose peer stops ponging, so a client notices a vanished server), and `unref` (unref the underlying socket so an idle connection can't keep the process alive on its own). See [docs/websocket-channels.md](docs/websocket-channels.md).
|
|
2030
2030
|
|
|
2031
|
-
`await client.close()` is a final graceful shutdown that releases resumable server-session state; unexpected transport drops first attempt to resume that state. A successful resume retains the existing server-side connection and channel instances. If the server instead rejects the old session with `session-gone`, SnapReq promotes the already-established fresh session, reopens still-live one-to-one connection handles, and re-subscribes still-live channel handles. Those public handles remain usable and channel readiness resolves on the fresh session; explicitly closed handles stay closed. See [the WebSocket channel lifecycle guarantees](docs/websocket-channels.md#lifecycle-guarantees-phase-1b).
|
|
2031
|
+
`await client.close()` is a final graceful shutdown that releases resumable server-session state; unexpected transport drops first attempt to resume that state. On a multi-worker server, the client automatically puts the prior session identity in the reconnect upgrade URL so the host can route it to its owning worker; routing is session-based and never source-IP-based. A successful resume retains the existing server-side connection and channel instances. If the server instead rejects the old session with `session-gone`, SnapReq promotes the already-established fresh session, reopens still-live one-to-one connection handles, and re-subscribes still-live channel handles. Those public handles remain usable and channel readiness resolves on the fresh session; explicitly closed handles stay closed. See [the WebSocket channel lifecycle guarantees](docs/websocket-channels.md#lifecycle-guarantees-phase-1b).
|
|
2032
2032
|
|
|
2033
2033
|
## Subscribe to events
|
|
2034
2034
|
|
|
@@ -2310,7 +2310,7 @@ Create the file `src/routes/testing/another-action.ejs` and so something like th
|
|
|
2310
2310
|
|
|
2311
2311
|
Velocious includes a simple background jobs system inspired by Sidekiq.
|
|
2312
2312
|
|
|
2313
|
-
Jobs can opt into cross-worker durable concurrency limits by pairing a non-empty `concurrencyKey` with a positive-integer `maxConcurrency` in their background-job options. The first cap registered for a key is stable; conflicting caps are rejected. See [durable concurrency limits](docs/background-jobs.md#durable-concurrency-limits).
|
|
2313
|
+
Jobs can opt into cross-worker durable concurrency limits by pairing a non-empty `concurrencyKey` with a positive-integer `maxConcurrency` in their background-job options, or by deriving the key in a hydrated job instance's non-static `concurrencyKey()` method. Explicit enqueue options win. The first cap registered for a key is stable; conflicting caps are rejected. See [durable concurrency limits](docs/background-jobs.md#durable-concurrency-limits).
|
|
2314
2314
|
|
|
2315
2315
|
Production apps can listen for `background-job-failed` (or its `all-error` mirror) to report accepted failed attempts, including retry and terminal-state metadata, and for `background-job-orphaned` to react to a specific job the main process reclaimed after its worker died mid-run — e.g. enqueue a targeted recovery for the work it left behind, instead of only polling for the aftermath. Orphan handlers run before the sweep waits for reclaimed jobs to be dispatched, so a stalled dispatcher does not delay application recovery. See [docs/background-jobs.md](docs/background-jobs.md#failure-events).
|
|
2316
2316
|
|
|
@@ -2701,6 +2701,18 @@ It exposes `GET /api/stats`, `/api/jobs`, `/api/jobs/:id`, `/api/schedule` and `
|
|
|
2701
2701
|
npx velocious server --host 0.0.0.0 --port 8082
|
|
2702
2702
|
```
|
|
2703
2703
|
|
|
2704
|
+
Threaded servers default to `os.availableParallelism()` HTTP workers. Pass
|
|
2705
|
+
`--workers` or configure `httpServer.workers` to override that count. Ordinary
|
|
2706
|
+
connections are distributed round-robin even behind a loopback reverse proxy,
|
|
2707
|
+
while resumable WebSockets return to their session's worker. Each worker owns a
|
|
2708
|
+
separate configuration and database pools, so per-worker limits multiply across
|
|
2709
|
+
the effective worker count (for example, four workers with `pool.max: 10` can
|
|
2710
|
+
open 40 connections for that pool). The debug snapshot exposes the configured
|
|
2711
|
+
and effective counts; default in-process mode uses one effective handler.
|
|
2712
|
+
Only requests carrying a resumable-session query wait for upgrade headers before
|
|
2713
|
+
worker assignment; ordinary and malformed requests continue directly to the
|
|
2714
|
+
request parser.
|
|
2715
|
+
|
|
2704
2716
|
When the server runs in the `development` environment, Velocious watches application `src/` trees and hot-reloads by recycling HTTP workers after `.js`/`.mjs`/`.cjs`/`.json`/`.ejs` changes. That picks up edited controllers, models, resources, routes, and views without a manual server restart while keeping production/test behavior unchanged.
|
|
2705
2717
|
|
|
2706
2718
|
Starting the HTTP server creates `tmp/server.lock` under the configured application directory before Beacon, workers, or the TCP listener start. A second server for the same app fails fast with the lock owner details instead of partially starting. Normal shutdown removes the lock; stale locks with a dead local PID are reclaimed automatically, while locks from another host or unreadable metadata should be removed manually only after confirming no server is running. See [docs/http-server.md](docs/http-server.md#server-lock).
|
|
@@ -93,6 +93,14 @@ export default async function runJobPayload(payload, {closeConnections = true, m
|
|
|
93
93
|
await registry.load()
|
|
94
94
|
const JobClass = registry.getJobByName(payload.jobName)
|
|
95
95
|
const jobInstance = new JobClass()
|
|
96
|
+
const jobArgs = payload.args || []
|
|
97
|
+
jobInstance._setBackgroundJobContext({
|
|
98
|
+
args: jobArgs,
|
|
99
|
+
jobClass: JobClass,
|
|
100
|
+
jobName: payload.jobName,
|
|
101
|
+
options: payload.options || {},
|
|
102
|
+
payload
|
|
103
|
+
})
|
|
96
104
|
/**
|
|
97
105
|
* Perform.
|
|
98
106
|
* @type {(...args: Array<ReturnType<typeof JSON.parse>>) => Promise<void>} */
|
|
@@ -107,7 +115,7 @@ export default async function runJobPayload(payload, {closeConnections = true, m
|
|
|
107
115
|
try {
|
|
108
116
|
try {
|
|
109
117
|
await configuration.withConnections({databaseIdentifiers: JobClass.databaseIdentifiers, name: `Background job runner: ${payload.jobName}`}, async () => {
|
|
110
|
-
await perform.apply(jobInstance,
|
|
118
|
+
await perform.apply(jobInstance, jobArgs)
|
|
111
119
|
})
|
|
112
120
|
} catch (error) {
|
|
113
121
|
if (error instanceof BackgroundJobRescheduleSignal) {
|
|
@@ -184,7 +184,29 @@ export default class LocalBackgroundJobsDispatcher {
|
|
|
184
184
|
configuration: this.configuration,
|
|
185
185
|
JobClass,
|
|
186
186
|
jobArgs: job.args,
|
|
187
|
-
|
|
187
|
+
jobOptions: {
|
|
188
|
+
concurrencyKey: job.concurrencyKey || undefined,
|
|
189
|
+
executionMode: job.executionMode,
|
|
190
|
+
maxConcurrency: job.maxConcurrency ?? undefined,
|
|
191
|
+
maxRetries: job.maxRetries ?? undefined,
|
|
192
|
+
queue: job.queue,
|
|
193
|
+
scheduledAtMs: job.scheduledAtMs ?? undefined,
|
|
194
|
+
timeoutMs: job.timeoutMs ?? undefined
|
|
195
|
+
},
|
|
196
|
+
name: `Local background job: ${job.jobName}`,
|
|
197
|
+
payload: {
|
|
198
|
+
args: job.args,
|
|
199
|
+
handedOffAtMs: handoff.handedOffAtMs,
|
|
200
|
+
handoffId: handoff.handoffId,
|
|
201
|
+
id: job.id,
|
|
202
|
+
jobName: job.jobName,
|
|
203
|
+
options: {
|
|
204
|
+
concurrencyKey: job.concurrencyKey || undefined,
|
|
205
|
+
executionMode: job.executionMode,
|
|
206
|
+
maxConcurrency: job.maxConcurrency ?? undefined,
|
|
207
|
+
queue: job.queue
|
|
208
|
+
}
|
|
209
|
+
}
|
|
188
210
|
})
|
|
189
211
|
} catch (error) {
|
|
190
212
|
if (error instanceof BackgroundJobRescheduleSignal) {
|
|
@@ -223,10 +223,7 @@ export default class BackgroundJobsMain {
|
|
|
223
223
|
await this.store.enqueue({
|
|
224
224
|
jobName: jobClass.jobName(),
|
|
225
225
|
args,
|
|
226
|
-
|
|
227
|
-
// scheduled job with `static queue = "..."` lands on its queue and
|
|
228
|
-
// honors the configured cap without every schedule repeating it.
|
|
229
|
-
options: jobClass._withQueue(options)
|
|
226
|
+
options: jobClass._withJobContext({jobArgs: args, jobOptions: options})
|
|
230
227
|
})
|
|
231
228
|
this._notifyEnqueued()
|
|
232
229
|
// Persistence is the scheduler enqueue boundary. Dispatch remains
|
|
@@ -1400,7 +1397,12 @@ export default class BackgroundJobsMain {
|
|
|
1400
1397
|
workerId: worker.workerId,
|
|
1401
1398
|
handedOffAtMs: handoff.handedOffAtMs,
|
|
1402
1399
|
options: {
|
|
1400
|
+
concurrencyKey: job.concurrencyKey || undefined,
|
|
1403
1401
|
executionMode: job.executionMode,
|
|
1402
|
+
maxConcurrency: job.maxConcurrency ?? undefined,
|
|
1403
|
+
maxRetries: job.maxRetries ?? undefined,
|
|
1404
|
+
queue: job.queue,
|
|
1405
|
+
scheduledAtMs: job.scheduledAtMs ?? undefined,
|
|
1404
1406
|
...(job.timeoutMs === null ? {} : {timeoutMs: job.timeoutMs})
|
|
1405
1407
|
}
|
|
1406
1408
|
}
|
|
@@ -6,11 +6,20 @@
|
|
|
6
6
|
* @param {import("../configuration.js").default} args.configuration - Active configuration.
|
|
7
7
|
* @param {typeof import("./platform-job.js").default} args.JobClass - Job class.
|
|
8
8
|
* @param {Array<ReturnType<typeof JSON.parse>>} args.jobArgs - Job arguments.
|
|
9
|
+
* @param {import("./types.js").BackgroundJobOptions} [args.jobOptions] - Resolved runtime options.
|
|
9
10
|
* @param {string} args.name - Connection-scope label.
|
|
11
|
+
* @param {import("./types.js").BackgroundJobPayload} [args.payload] - Persisted runner payload.
|
|
10
12
|
* @returns {Promise<void>} - Resolves after performance.
|
|
11
13
|
*/
|
|
12
|
-
export default async function performBackgroundJob({configuration, JobClass, jobArgs, name}) {
|
|
14
|
+
export default async function performBackgroundJob({configuration, JobClass, jobArgs, jobOptions = {}, name, payload}) {
|
|
13
15
|
const jobInstance = new JobClass()
|
|
16
|
+
jobInstance._setBackgroundJobContext({
|
|
17
|
+
args: jobArgs,
|
|
18
|
+
jobClass: JobClass,
|
|
19
|
+
jobName: JobClass.jobName(),
|
|
20
|
+
options: jobOptions,
|
|
21
|
+
...(payload ? {payload} : {})
|
|
22
|
+
})
|
|
14
23
|
/**
|
|
15
24
|
* Narrows the generic subclass's runtime method to serialized job arguments.
|
|
16
25
|
* @type {(...args: Array<ReturnType<typeof JSON.parse>>) => Promise<void>}
|
|
@@ -14,6 +14,11 @@ import {cancelScheduledBackgroundJob, enqueueBackgroundJob, replaceScheduledBack
|
|
|
14
14
|
* @template {Array<ReturnType<typeof JSON.parse>>} [TArgs=[]]
|
|
15
15
|
*/
|
|
16
16
|
export default class VelociousJob {
|
|
17
|
+
constructor() {
|
|
18
|
+
/** @type {import("./types.js").BackgroundJobContext | undefined} */
|
|
19
|
+
this._backgroundJobContext = undefined
|
|
20
|
+
}
|
|
21
|
+
|
|
17
22
|
/**
|
|
18
23
|
* Database identifiers checked out while this job performs. Set an explicit
|
|
19
24
|
* list to avoid holding unrelated configured database connections, or `[]`
|
|
@@ -82,6 +87,63 @@ export default class VelociousJob {
|
|
|
82
87
|
return merged
|
|
83
88
|
}
|
|
84
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Resolves class-derived enqueue options on a hydrated job instance. Explicit
|
|
92
|
+
* per-enqueue options take precedence over the instance concurrency key.
|
|
93
|
+
* @param {object} args - Job context.
|
|
94
|
+
* @param {Array<ReturnType<typeof JSON.parse>>} args.jobArgs - Job arguments.
|
|
95
|
+
* @param {import("./types.js").BackgroundJobOptions | undefined} args.jobOptions - Job options.
|
|
96
|
+
* @returns {import("./types.js").BackgroundJobOptions} - Resolved job options.
|
|
97
|
+
*/
|
|
98
|
+
static _withJobContext({jobArgs, jobOptions}) {
|
|
99
|
+
const options = this._withQueue(jobOptions)
|
|
100
|
+
|
|
101
|
+
if (options.concurrencyKey !== undefined) return options
|
|
102
|
+
|
|
103
|
+
const jobInstance = new this()
|
|
104
|
+
jobInstance._setBackgroundJobContext({
|
|
105
|
+
args: jobArgs,
|
|
106
|
+
jobClass: this,
|
|
107
|
+
jobName: this.jobName(),
|
|
108
|
+
options
|
|
109
|
+
})
|
|
110
|
+
const concurrencyKey = jobInstance.concurrencyKey()
|
|
111
|
+
|
|
112
|
+
if (concurrencyKey !== undefined) options.concurrencyKey = concurrencyKey
|
|
113
|
+
|
|
114
|
+
return options
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Sets the complete context available to this hydrated job instance.
|
|
119
|
+
* Framework enqueue/runner boundaries own this method.
|
|
120
|
+
* @param {import("./types.js").BackgroundJobContext} context - Job context.
|
|
121
|
+
* @returns {void}
|
|
122
|
+
*/
|
|
123
|
+
_setBackgroundJobContext(context) {
|
|
124
|
+
this._backgroundJobContext = context
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Returns this hydrated job's complete enqueue or runner context.
|
|
129
|
+
* @returns {import("./types.js").BackgroundJobContext} - Job context.
|
|
130
|
+
*/
|
|
131
|
+
backgroundJobContext() {
|
|
132
|
+
if (!this._backgroundJobContext) throw new Error("Background job context is not hydrated")
|
|
133
|
+
|
|
134
|
+
return this._backgroundJobContext
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Override to derive a durable concurrency key from `backgroundJobContext()`.
|
|
139
|
+
* Pair the derived key with `maxConcurrency` in enqueue options. An explicit
|
|
140
|
+
* per-enqueue `concurrencyKey` takes precedence and skips this method.
|
|
141
|
+
* @returns {string | undefined} - Derived concurrency key, or undefined for none.
|
|
142
|
+
*/
|
|
143
|
+
concurrencyKey() {
|
|
144
|
+
return undefined
|
|
145
|
+
}
|
|
146
|
+
|
|
85
147
|
/**
|
|
86
148
|
* Runs perform later.
|
|
87
149
|
* @param {...ReturnType<typeof JSON.parse>} args - Job args.
|
|
@@ -52,9 +52,10 @@ export async function enqueueBackgroundJob({JobClass, jobArgs, jobOptions}) {
|
|
|
52
52
|
* @returns {Promise<string>} - Durable job id or ephemeral inline performance id.
|
|
53
53
|
*/
|
|
54
54
|
export async function enqueueBackgroundJobForConfiguration({configuration, JobClass, jobArgs, jobOptions}) {
|
|
55
|
+
const resolvedJobOptions = JobClass._withJobContext({jobArgs, jobOptions})
|
|
55
56
|
|
|
56
57
|
if (configuration.getBackgroundJobsConfig().mode === "inline") {
|
|
57
|
-
validateInlineOptions(
|
|
58
|
+
validateInlineOptions(resolvedJobOptions)
|
|
58
59
|
configuration.setCurrent()
|
|
59
60
|
await configuration.initialize({type: "background-jobs-inline"})
|
|
60
61
|
|
|
@@ -63,6 +64,7 @@ export async function enqueueBackgroundJobForConfiguration({configuration, JobCl
|
|
|
63
64
|
configuration,
|
|
64
65
|
JobClass,
|
|
65
66
|
jobArgs,
|
|
67
|
+
jobOptions: resolvedJobOptions,
|
|
66
68
|
name: `Background job inline mode: ${JobClass.jobName()}`
|
|
67
69
|
})
|
|
68
70
|
} catch (error) {
|
|
@@ -81,7 +83,7 @@ export async function enqueueBackgroundJobForConfiguration({configuration, JobCl
|
|
|
81
83
|
return await client.enqueue({
|
|
82
84
|
jobName: JobClass.jobName(),
|
|
83
85
|
args: jobArgs,
|
|
84
|
-
options:
|
|
86
|
+
options: resolvedJobOptions
|
|
85
87
|
})
|
|
86
88
|
}
|
|
87
89
|
|
|
@@ -117,12 +119,13 @@ export async function replaceScheduledBackgroundJobForConfiguration({configurati
|
|
|
117
119
|
}
|
|
118
120
|
|
|
119
121
|
const client = configuration.getEnvironmentHandler().backgroundJobsClient({configuration})
|
|
122
|
+
const resolvedJobOptions = JobClass._withJobContext({jobArgs, jobOptions})
|
|
120
123
|
|
|
121
124
|
return await client.replaceScheduled({
|
|
122
125
|
scheduleKey,
|
|
123
126
|
jobName: JobClass.jobName(),
|
|
124
127
|
args: jobArgs,
|
|
125
|
-
options:
|
|
128
|
+
options: resolvedJobOptions
|
|
126
129
|
})
|
|
127
130
|
}
|
|
128
131
|
|
|
@@ -71,6 +71,14 @@
|
|
|
71
71
|
* @property {number} [handedOffAtMs] - Time handed to a worker in ms.
|
|
72
72
|
* @property {BackgroundJobOptions} [options] - Runtime options.
|
|
73
73
|
*/
|
|
74
|
+
/**
|
|
75
|
+
* @typedef {object} BackgroundJobContext
|
|
76
|
+
* @property {typeof import("./platform-job.js").default} jobClass - Concrete job class.
|
|
77
|
+
* @property {string} jobName - Registered job name.
|
|
78
|
+
* @property {Array<ReturnType<typeof JSON.parse>>} args - Serialized job arguments.
|
|
79
|
+
* @property {BackgroundJobOptions} options - Resolved enqueue/runtime options.
|
|
80
|
+
* @property {BackgroundJobPayload} [payload] - Complete persisted runner payload when the job is performing.
|
|
81
|
+
*/
|
|
74
82
|
/**
|
|
75
83
|
* @typedef {object} BackgroundJobRow
|
|
76
84
|
* @property {string} id - Job id.
|
|
@@ -1054,7 +1054,9 @@ export default class BackgroundJobsWorker {
|
|
|
1054
1054
|
configuration,
|
|
1055
1055
|
JobClass,
|
|
1056
1056
|
jobArgs: payload.args || [],
|
|
1057
|
-
|
|
1057
|
+
jobOptions: payload.options || {},
|
|
1058
|
+
name: `Background job worker inline: ${payload.jobName}`,
|
|
1059
|
+
payload
|
|
1058
1060
|
})
|
|
1059
1061
|
}
|
|
1060
1062
|
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { AsyncLocalStorage } from "async_hooks"
|
|
4
4
|
import BasePool, { POOL_CONFIGURATION_KEY } from "./base.js"
|
|
5
|
+
import DatabasePoolCheckoutTimeoutError from "./checkout-timeout-error.js"
|
|
5
6
|
import { currentTestProfileContext } from "../../testing/test-profile-context.js"
|
|
6
7
|
|
|
7
8
|
/**
|
|
@@ -101,6 +102,9 @@ export default class VelociousDatabasePoolAsyncTrackedMultiConnection extends Ba
|
|
|
101
102
|
* @type {Promise<void> | undefined} */
|
|
102
103
|
pendingCheckoutDrainPromise = undefined
|
|
103
104
|
|
|
105
|
+
/** Whether a caller requested another pass through the pending checkout queue. */
|
|
106
|
+
pendingCheckoutDrainRequested = false
|
|
107
|
+
|
|
104
108
|
/**
|
|
105
109
|
* Idle connection reaper timer.
|
|
106
110
|
* @type {ReturnType<typeof setTimeout> | undefined} */
|
|
@@ -602,18 +606,45 @@ export default class VelociousDatabasePoolAsyncTrackedMultiConnection extends Ba
|
|
|
602
606
|
* @returns {Promise<void>} - Resolves when pending checkouts have been drained as far as possible.
|
|
603
607
|
*/
|
|
604
608
|
async drainPendingCheckouts() {
|
|
605
|
-
|
|
606
|
-
await this.pendingCheckoutDrainPromise
|
|
607
|
-
return
|
|
608
|
-
}
|
|
609
|
+
this.pendingCheckoutDrainRequested = true
|
|
609
610
|
|
|
610
|
-
this.pendingCheckoutDrainPromise
|
|
611
|
+
if (!this.pendingCheckoutDrainPromise) this.startPendingCheckoutDrain()
|
|
612
|
+
await this.pendingCheckoutDrainPromise
|
|
613
|
+
}
|
|
611
614
|
|
|
615
|
+
/**
|
|
616
|
+
* Starts the single checkout-drain owner. The shared promise is cleared before
|
|
617
|
+
* it settles, closing the resolved-promise/stale-field interval in which a new
|
|
618
|
+
* request could otherwise be lost.
|
|
619
|
+
* @returns {void}
|
|
620
|
+
*/
|
|
621
|
+
startPendingCheckoutDrain() {
|
|
622
|
+
const {promise, reject, resolve} = Promise.withResolvers()
|
|
623
|
+
|
|
624
|
+
this.pendingCheckoutDrainPromise = promise
|
|
625
|
+
void this.runRequestedPendingCheckoutDrains({reject, resolve})
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* Runs drain passes until every request observed during the active pass has
|
|
630
|
+
* received a later pass.
|
|
631
|
+
* @param {{reject: (reason?: ReturnType<typeof JSON.parse>) => void, resolve: (value?: void) => void}} deferred - Shared drain settlement.
|
|
632
|
+
* @returns {Promise<void>}
|
|
633
|
+
*/
|
|
634
|
+
async runRequestedPendingCheckoutDrains({reject, resolve}) {
|
|
612
635
|
try {
|
|
613
|
-
|
|
614
|
-
|
|
636
|
+
while (this.pendingCheckoutDrainRequested) {
|
|
637
|
+
this.pendingCheckoutDrainRequested = false
|
|
638
|
+
await this.drainPendingCheckoutsActual()
|
|
639
|
+
}
|
|
640
|
+
} catch (error) {
|
|
615
641
|
this.pendingCheckoutDrainPromise = undefined
|
|
642
|
+
reject(error)
|
|
643
|
+
return
|
|
616
644
|
}
|
|
645
|
+
|
|
646
|
+
this.pendingCheckoutDrainPromise = undefined
|
|
647
|
+
resolve()
|
|
617
648
|
}
|
|
618
649
|
|
|
619
650
|
/**
|
|
@@ -726,13 +757,13 @@ export default class VelociousDatabasePoolAsyncTrackedMultiConnection extends Ba
|
|
|
726
757
|
/**
|
|
727
758
|
* Runs pending checkout timeout error.
|
|
728
759
|
* @param {PendingCheckout} checkout - Timed-out checkout.
|
|
729
|
-
* @returns {
|
|
760
|
+
* @returns {DatabasePoolCheckoutTimeoutError} - Timeout error.
|
|
730
761
|
*/
|
|
731
762
|
pendingCheckoutTimeoutError(checkout) {
|
|
732
763
|
const checkoutName = checkout.options.name ? ` Checkout name: ${JSON.stringify(checkout.options.name)}.` : ""
|
|
733
764
|
const diagnostics = this.pendingCheckoutTimeoutDiagnostics(checkout)
|
|
734
765
|
|
|
735
|
-
return new
|
|
766
|
+
return new DatabasePoolCheckoutTimeoutError(`Timed out after ${checkout.timeoutMillis}ms waiting for database connection checkout from pool "${this.identifier}".${checkoutName} ${diagnostics}`)
|
|
736
767
|
}
|
|
737
768
|
|
|
738
769
|
/**
|
|
@@ -1255,7 +1286,13 @@ export default class VelociousDatabasePoolAsyncTrackedMultiConnection extends Ba
|
|
|
1255
1286
|
connections,
|
|
1256
1287
|
connectionsBeingSpawned: this.connectionsBeingSpawned,
|
|
1257
1288
|
idleCount: this.connections.length + [...this.lifecycleRetainedConnections.values()].filter((connection) => connection.getIdSeq() === undefined).length,
|
|
1289
|
+
idleMatchingPendingCheckoutCount: this.connections.filter((connection) => {
|
|
1290
|
+
return !this.connectionHasOpenTransaction(connection)
|
|
1291
|
+
&& this.pendingCheckouts.some((checkout) => this.connectionMatchesReuseKey(connection, checkout.reuseKey))
|
|
1292
|
+
}).length,
|
|
1258
1293
|
inUseCount: Object.keys(this.connectionsInUse).length,
|
|
1294
|
+
pendingCheckoutDrainActive: Boolean(this.pendingCheckoutDrainPromise),
|
|
1295
|
+
pendingCheckoutDrainRequested: this.pendingCheckoutDrainRequested,
|
|
1259
1296
|
pendingCheckouts: this.pendingCheckoutDebugSnapshots(now),
|
|
1260
1297
|
pendingCheckoutCount: this.pendingCheckouts.length,
|
|
1261
1298
|
telemetry: {...this.telemetry}
|
|
@@ -40,10 +40,13 @@ import sha256Hex from "../../utils/sha256-hex.js"
|
|
|
40
40
|
* @property {Array<Record<string, ReturnType<typeof JSON.parse>>>} connections - Live connection snapshots.
|
|
41
41
|
* @property {number} connectionsBeingSpawned - Number of in-progress connection spawns.
|
|
42
42
|
* @property {number} idleCount - Number of idle connections.
|
|
43
|
+
* @property {number} [idleMatchingPendingCheckoutCount] - Idle connections that can satisfy at least one pending checkout.
|
|
43
44
|
* @property {string} identifier - Database identifier.
|
|
44
45
|
* @property {number} inUseCount - Number of checked-out connections.
|
|
45
46
|
* @property {Array<DatabasePoolPendingCheckoutDebugSnapshot>} [pendingCheckouts] - Waiting checkout snapshots.
|
|
46
47
|
* @property {number} pendingCheckoutCount - Number of queued checkout requests.
|
|
48
|
+
* @property {boolean} [pendingCheckoutDrainActive] - Whether a checkout drain pass is active.
|
|
49
|
+
* @property {boolean} [pendingCheckoutDrainRequested] - Whether another checkout drain pass was requested.
|
|
47
50
|
* @property {string} poolClass - Pool class name.
|
|
48
51
|
* @property {{connectionCreationCount: number, connectionCreationFailureCount: number, connectionCreationMaxMs: number, connectionCreationTotalMs: number, checkoutTimeoutCount: number, checkoutWaitCount: number, checkoutWaitMaxMs: number, checkoutWaitTotalMs: number, idleReapCount: number, idleReapDisposalCount: number, idleReapFailureCount: number, idleReapMaxMs: number, idleReapTotalMs: number, peakLiveConnections: number}} [telemetry] - Cumulative pool lifecycle telemetry.
|
|
49
52
|
*/
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
/** Stable framework error for a database pool checkout that exceeded its wait limit. */
|
|
4
|
+
export default class DatabasePoolCheckoutTimeoutError extends Error {
|
|
5
|
+
/**
|
|
6
|
+
* Builds a database pool checkout timeout error.
|
|
7
|
+
* @param {string} message - Detailed sanitized pool timeout message.
|
|
8
|
+
* @param {{cause?: unknown}} [args] - Error options.
|
|
9
|
+
*/
|
|
10
|
+
constructor(message, {cause} = {}) {
|
|
11
|
+
super(message, cause === undefined ? undefined : {cause})
|
|
12
|
+
|
|
13
|
+
this.name = "DatabasePoolCheckoutTimeoutError"
|
|
14
|
+
this.code = "VELOCIOUS_DATABASE_POOL_CHECKOUT_TIMEOUT"
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -4,6 +4,7 @@ import SnapReqWebSocketClient from "snapreq/websocket"
|
|
|
4
4
|
import {deserializeFrontendModelTransportValue} from "../frontend-models/transport-serialization.js"
|
|
5
5
|
|
|
6
6
|
const DEFAULT_URL = "ws://127.0.0.1:3006/websocket"
|
|
7
|
+
const SESSION_ROUTING_PARAMETER = "velociousSessionId"
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* Velocious's WebSocket client. The cross-platform connection/session/channel
|
|
@@ -28,6 +29,57 @@ export default class VelociousWebsocketClient extends SnapReqWebSocketClient {
|
|
|
28
29
|
this.runningReconnectTasks = new Set()
|
|
29
30
|
/** @type {Promise<void> | null} */
|
|
30
31
|
this.gracefulClosePromise = null
|
|
32
|
+
this.routingBaseUrl = this.url
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Restores a persisted session before opening the socket so the host can route
|
|
37
|
+
* the HTTP upgrade to the worker that owns its paused state.
|
|
38
|
+
* @returns {Promise<void>}
|
|
39
|
+
*/
|
|
40
|
+
async _restoreSessionIdForRouting() {
|
|
41
|
+
// SnapReq initializes these internal session fields in its constructor, but
|
|
42
|
+
// its declaration does not expose that definite-assignment lifecycle here.
|
|
43
|
+
const routingState = /** @type {{_sessionId: string | null, _sessionStore: {get: () => string | null | undefined | Promise<string | null | undefined>} | undefined, _sessionStoreRestored: boolean}} */ (/** @type {unknown} */ (this))
|
|
44
|
+
|
|
45
|
+
if (routingState._sessionId || routingState._sessionStoreRestored || !routingState._sessionStore) return
|
|
46
|
+
|
|
47
|
+
routingState._sessionStoreRestored = true
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
const storedId = await routingState._sessionStore.get()
|
|
51
|
+
|
|
52
|
+
if (typeof storedId === "string" && storedId.length > 0) routingState._sessionId = storedId
|
|
53
|
+
} catch (error) {
|
|
54
|
+
this._debug("sessionStore.get failed", error)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Builds the WebSocket URL carrying only the current resumable session routing hint.
|
|
60
|
+
* @returns {string} - WebSocket URL.
|
|
61
|
+
*/
|
|
62
|
+
_sessionRoutingUrl() {
|
|
63
|
+
const url = new URL(this.routingBaseUrl)
|
|
64
|
+
|
|
65
|
+
if (this._sessionId) {
|
|
66
|
+
url.searchParams.set(SESSION_ROUTING_PARAMETER, this._sessionId)
|
|
67
|
+
} else {
|
|
68
|
+
url.searchParams.delete(SESSION_ROUTING_PARAMETER)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return url.toString()
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Restores routing state before delegating socket creation to SnapReq.
|
|
76
|
+
* @param {Parameters<SnapReqWebSocketClient["_connect"]>[0]} [options] - Connect options.
|
|
77
|
+
* @returns {Promise<void>} - Resolves when the session is ready.
|
|
78
|
+
*/
|
|
79
|
+
async _connect(options) {
|
|
80
|
+
await this._restoreSessionIdForRouting()
|
|
81
|
+
this.url = this._sessionRoutingUrl()
|
|
82
|
+
await super._connect(options)
|
|
31
83
|
}
|
|
32
84
|
|
|
33
85
|
/**
|
|
@@ -314,6 +314,12 @@ export default class VeoliciousHttpServerClient {
|
|
|
314
314
|
this.websocketSession = undefined
|
|
315
315
|
this.events.emit("close")
|
|
316
316
|
})
|
|
317
|
+
this.websocketSession.events.on("ownershipClaimed", ({sessionId}) => {
|
|
318
|
+
this.events.emit("websocketSessionOwned", {sessionId})
|
|
319
|
+
})
|
|
320
|
+
this.websocketSession.events.on("ownershipReleased", ({sessionId}) => {
|
|
321
|
+
this.events.emit("websocketSessionReleased", {sessionId})
|
|
322
|
+
})
|
|
317
323
|
this.state = "websocket"
|
|
318
324
|
this.events.emit("output", response)
|
|
319
325
|
void this.websocketSession.initializeChannel()
|
|
@@ -215,6 +215,9 @@ export default class VelociousHttpServerClientWebsocketSession {
|
|
|
215
215
|
*/
|
|
216
216
|
this._resumeIdentityPromise = undefined
|
|
217
217
|
|
|
218
|
+
/** @type {string | null} */
|
|
219
|
+
this._claimedSessionId = null
|
|
220
|
+
|
|
218
221
|
/**
|
|
219
222
|
* Accumulates payloads for a fragmented websocket message per
|
|
220
223
|
* RFC 6455. Non-null while mid-fragment; cleared when the frame
|
|
@@ -266,6 +269,7 @@ export default class VelociousHttpServerClientWebsocketSession {
|
|
|
266
269
|
* @returns {void}
|
|
267
270
|
*/
|
|
268
271
|
sendSessionEstablished() {
|
|
272
|
+
this._claimOwnership()
|
|
269
273
|
this.sendJson({
|
|
270
274
|
type: "session-established",
|
|
271
275
|
sessionId: this.sessionId,
|
|
@@ -313,6 +317,7 @@ export default class VelociousHttpServerClientWebsocketSession {
|
|
|
313
317
|
}
|
|
314
318
|
|
|
315
319
|
destroy() {
|
|
320
|
+
this._releaseOwnership()
|
|
316
321
|
this._stopHeartbeat()
|
|
317
322
|
this._resetFragmentBuffer()
|
|
318
323
|
this._clearBufferedFrameChunks()
|
|
@@ -325,6 +330,25 @@ export default class VelociousHttpServerClientWebsocketSession {
|
|
|
325
330
|
this.events.removeAllListeners()
|
|
326
331
|
}
|
|
327
332
|
|
|
333
|
+
/** Claims this session id for host-side reconnect routing. */
|
|
334
|
+
_claimOwnership() {
|
|
335
|
+
if (this._claimedSessionId === this.sessionId) return
|
|
336
|
+
if (this._claimedSessionId) this._releaseOwnership()
|
|
337
|
+
|
|
338
|
+
this._claimedSessionId = this.sessionId
|
|
339
|
+
this.events.emit("ownershipClaimed", {sessionId: this.sessionId})
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** Releases the currently claimed session id exactly once. */
|
|
343
|
+
_releaseOwnership() {
|
|
344
|
+
const sessionId = this._claimedSessionId
|
|
345
|
+
|
|
346
|
+
if (!sessionId) return
|
|
347
|
+
|
|
348
|
+
this._claimedSessionId = null
|
|
349
|
+
this.events.emit("ownershipReleased", {sessionId})
|
|
350
|
+
}
|
|
351
|
+
|
|
328
352
|
/**
|
|
329
353
|
* Runs has subscription.
|
|
330
354
|
* @param {string} channel - Channel name.
|
|
@@ -1339,6 +1363,7 @@ export default class VelociousHttpServerClientWebsocketSession {
|
|
|
1339
1363
|
}
|
|
1340
1364
|
|
|
1341
1365
|
this._stopHeartbeat()
|
|
1366
|
+
this._releaseOwnership()
|
|
1342
1367
|
this.configuration._websocketSessions.delete(this)
|
|
1343
1368
|
void this._runMessageHandlerClose()
|
|
1344
1369
|
void this._teardownChannel()
|
|
@@ -1355,6 +1380,7 @@ export default class VelociousHttpServerClientWebsocketSession {
|
|
|
1355
1380
|
*/
|
|
1356
1381
|
_finalizeGraceExpiry() {
|
|
1357
1382
|
this._stopHeartbeat()
|
|
1383
|
+
this._releaseOwnership()
|
|
1358
1384
|
this._resetFragmentBuffer()
|
|
1359
1385
|
this._clearBufferedFrameChunks()
|
|
1360
1386
|
this._abandonInboundMessages()
|
|
@@ -1499,6 +1525,9 @@ export default class VelociousHttpServerClientWebsocketSession {
|
|
|
1499
1525
|
|
|
1500
1526
|
this.configuration._clearPausedWebsocketSession(resumeSessionId)
|
|
1501
1527
|
|
|
1528
|
+
this._releaseOwnership()
|
|
1529
|
+
paused._releaseOwnership()
|
|
1530
|
+
|
|
1502
1531
|
// Transfer resumable state onto this (live) session. The paused
|
|
1503
1532
|
// session shell is discarded after the transfer.
|
|
1504
1533
|
for (const [connectionId, connection] of paused._connections) {
|
|
@@ -1526,6 +1555,7 @@ export default class VelociousHttpServerClientWebsocketSession {
|
|
|
1526
1555
|
paused._paused = false
|
|
1527
1556
|
paused.destroy()
|
|
1528
1557
|
|
|
1558
|
+
this._claimOwnership()
|
|
1529
1559
|
this.sendJson({type: "session-resumed", sessionId: resumeSessionId})
|
|
1530
1560
|
for (const body of queued) this.sendJson(body)
|
|
1531
1561
|
await this._fireOnResume()
|