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.
Files changed (81) hide show
  1. package/README.md +15 -3
  2. package/build/background-jobs/job-runner.js +9 -1
  3. package/build/background-jobs/local-dispatcher.js +23 -1
  4. package/build/background-jobs/main.js +6 -4
  5. package/build/background-jobs/perform-job.js +10 -1
  6. package/build/background-jobs/platform-job.js +62 -0
  7. package/build/background-jobs/runtime.js +6 -3
  8. package/build/background-jobs/types.js +8 -0
  9. package/build/background-jobs/worker.js +3 -1
  10. package/build/database/pool/async-tracked-multi-connection.js +46 -9
  11. package/build/database/pool/base.js +3 -0
  12. package/build/database/pool/checkout-timeout-error.js +16 -0
  13. package/build/http-client/websocket-client.js +52 -0
  14. package/build/http-server/client/index.js +6 -0
  15. package/build/http-server/client/websocket-session.js +30 -0
  16. package/build/http-server/index.js +202 -38
  17. package/build/http-server/worker-handler/index.js +15 -1
  18. package/build/http-server/worker-handler/worker-thread.js +8 -0
  19. package/build/src/background-jobs/job-runner.d.ts.map +1 -1
  20. package/build/src/background-jobs/job-runner.js +10 -2
  21. package/build/src/background-jobs/local-dispatcher.d.ts.map +1 -1
  22. package/build/src/background-jobs/local-dispatcher.js +24 -2
  23. package/build/src/background-jobs/main.d.ts.map +1 -1
  24. package/build/src/background-jobs/main.js +7 -5
  25. package/build/src/background-jobs/perform-job.d.ts +5 -1
  26. package/build/src/background-jobs/perform-job.d.ts.map +1 -1
  27. package/build/src/background-jobs/perform-job.js +11 -2
  28. package/build/src/background-jobs/platform-job.d.ts +34 -0
  29. package/build/src/background-jobs/platform-job.d.ts.map +1 -1
  30. package/build/src/background-jobs/platform-job.js +56 -1
  31. package/build/src/background-jobs/runtime.d.ts.map +1 -1
  32. package/build/src/background-jobs/runtime.js +7 -4
  33. package/build/src/background-jobs/types.d.ts +30 -0
  34. package/build/src/background-jobs/types.d.ts.map +1 -1
  35. package/build/src/background-jobs/types.js +9 -1
  36. package/build/src/background-jobs/worker.d.ts.map +1 -1
  37. package/build/src/background-jobs/worker.js +4 -2
  38. package/build/src/database/pool/async-tracked-multi-connection.d.ts +22 -2
  39. package/build/src/database/pool/async-tracked-multi-connection.d.ts.map +1 -1
  40. package/build/src/database/pool/async-tracked-multi-connection.js +44 -10
  41. package/build/src/database/pool/base.d.ts +15 -0
  42. package/build/src/database/pool/base.d.ts.map +1 -1
  43. package/build/src/database/pool/base.js +4 -1
  44. package/build/src/database/pool/checkout-timeout-error.d.ts +13 -0
  45. package/build/src/database/pool/checkout-timeout-error.d.ts.map +1 -0
  46. package/build/src/database/pool/checkout-timeout-error.js +15 -0
  47. package/build/src/http-client/websocket-client.d.ts +18 -0
  48. package/build/src/http-client/websocket-client.d.ts.map +1 -1
  49. package/build/src/http-client/websocket-client.js +48 -1
  50. package/build/src/http-server/client/index.d.ts.map +1 -1
  51. package/build/src/http-server/client/index.js +7 -1
  52. package/build/src/http-server/client/websocket-session.d.ts +6 -0
  53. package/build/src/http-server/client/websocket-session.d.ts.map +1 -1
  54. package/build/src/http-server/client/websocket-session.js +27 -1
  55. package/build/src/http-server/index.d.ts +82 -10
  56. package/build/src/http-server/index.d.ts.map +1 -1
  57. package/build/src/http-server/index.js +186 -33
  58. package/build/src/http-server/worker-handler/index.d.ts +28 -1
  59. package/build/src/http-server/worker-handler/index.d.ts.map +1 -1
  60. package/build/src/http-server/worker-handler/index.js +23 -2
  61. package/build/src/http-server/worker-handler/worker-thread.d.ts.map +1 -1
  62. package/build/src/http-server/worker-handler/worker-thread.js +7 -1
  63. package/build/tsconfig.tsbuildinfo +1 -1
  64. package/package.json +1 -1
  65. package/src/background-jobs/job-runner.js +9 -1
  66. package/src/background-jobs/local-dispatcher.js +23 -1
  67. package/src/background-jobs/main.js +6 -4
  68. package/src/background-jobs/perform-job.js +10 -1
  69. package/src/background-jobs/platform-job.js +62 -0
  70. package/src/background-jobs/runtime.js +6 -3
  71. package/src/background-jobs/types.js +8 -0
  72. package/src/background-jobs/worker.js +3 -1
  73. package/src/database/pool/async-tracked-multi-connection.js +46 -9
  74. package/src/database/pool/base.js +3 -0
  75. package/src/database/pool/checkout-timeout-error.js +16 -0
  76. package/src/http-client/websocket-client.js +52 -0
  77. package/src/http-server/client/index.js +6 -0
  78. package/src/http-server/client/websocket-session.js +30 -0
  79. package/src/http-server/index.js +202 -38
  80. package/src/http-server/worker-handler/index.js +15 -1
  81. 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`, and queued `pendingCheckouts[].waitingForMs` so production diagnostics can distinguish long-held checkouts from pool-capacity waits.
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, payload.args || [])
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
- name: `Local background job: ${job.jobName}`
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
- // Fold in the job class's static `queue` (as performLater* do) so a
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(jobOptions)
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: JobClass._withQueue(jobOptions)
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: JobClass._withQueue(jobOptions)
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
- name: `Background job worker inline: ${payload.jobName}`
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
- if (this.pendingCheckoutDrainPromise) {
606
- await this.pendingCheckoutDrainPromise
607
- return
608
- }
609
+ this.pendingCheckoutDrainRequested = true
609
610
 
610
- this.pendingCheckoutDrainPromise = this.drainPendingCheckoutsActual()
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
- await this.pendingCheckoutDrainPromise
614
- } finally {
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 {Error} - Timeout error.
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 Error(`Timed out after ${checkout.timeoutMillis}ms waiting for database connection checkout from pool "${this.identifier}".${checkoutName} ${diagnostics}`)
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()