velocious 1.0.529 → 1.0.532

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 (43) hide show
  1. package/README.md +5 -2
  2. package/build/background-jobs/job-runner.js +5 -3
  3. package/build/background-jobs/pooled-runner-child.js +53 -16
  4. package/build/background-jobs/worker.js +193 -42
  5. package/build/configuration-types.js +1 -0
  6. package/build/configuration.js +33 -4
  7. package/build/database/drivers/mysql/sql/alter-table.js +42 -0
  8. package/build/database/query/preloader/belongs-to.js +9 -9
  9. package/build/database/query/preloader/has-many.js +8 -8
  10. package/build/database/query/preloader/has-one.js +5 -5
  11. package/build/database/tenants/schema-cloner.js +46 -9
  12. package/build/src/background-jobs/job-runner.d.ts +3 -1
  13. package/build/src/background-jobs/job-runner.d.ts.map +1 -1
  14. package/build/src/background-jobs/job-runner.js +8 -4
  15. package/build/src/background-jobs/pooled-runner-child.js +49 -18
  16. package/build/src/background-jobs/worker.d.ts +85 -14
  17. package/build/src/background-jobs/worker.d.ts.map +1 -1
  18. package/build/src/background-jobs/worker.js +189 -45
  19. package/build/src/configuration-types.d.ts +5 -0
  20. package/build/src/configuration-types.d.ts.map +1 -1
  21. package/build/src/configuration-types.js +2 -1
  22. package/build/src/configuration.d.ts +6 -0
  23. package/build/src/configuration.d.ts.map +1 -1
  24. package/build/src/configuration.js +34 -4
  25. package/build/src/database/drivers/mysql/sql/alter-table.d.ts.map +1 -1
  26. package/build/src/database/drivers/mysql/sql/alter-table.js +36 -1
  27. package/build/src/database/query/preloader/belongs-to.js +10 -12
  28. package/build/src/database/query/preloader/has-many.js +9 -11
  29. package/build/src/database/query/preloader/has-one.js +6 -7
  30. package/build/src/database/tenants/schema-cloner.d.ts +10 -0
  31. package/build/src/database/tenants/schema-cloner.d.ts.map +1 -1
  32. package/build/src/database/tenants/schema-cloner.js +36 -9
  33. package/package.json +3 -1
  34. package/src/background-jobs/job-runner.js +5 -3
  35. package/src/background-jobs/pooled-runner-child.js +53 -16
  36. package/src/background-jobs/worker.js +193 -42
  37. package/src/configuration-types.js +1 -0
  38. package/src/configuration.js +33 -4
  39. package/src/database/drivers/mysql/sql/alter-table.js +42 -0
  40. package/src/database/query/preloader/belongs-to.js +9 -9
  41. package/src/database/query/preloader/has-many.js +8 -8
  42. package/src/database/query/preloader/has-one.js +5 -5
  43. package/src/database/tenants/schema-cloner.js +46 -9
package/README.md CHANGED
@@ -1977,6 +1977,7 @@ export default new Configuration({
1977
1977
  maxConcurrentForkedJobs: 4,
1978
1978
  maxConcurrentInlineJobs: 4,
1979
1979
  pooledRunnerCount: 4,
1980
+ pooledRunnerConcurrency: 1,
1980
1981
  pooledRunnerMaxJobs: 100,
1981
1982
  pooledRunnerMaxRssBytes: 536870912,
1982
1983
  pooledRunnerMaxLifetimeMs: 3600000,
@@ -2008,11 +2009,11 @@ VELOCIOUS_BACKGROUND_JOBS_JOB_TIMEOUT_MS=5400000
2008
2009
 
2009
2010
  `maxConcurrentInlineJobs` (default: `4`) caps how many `executionMode: "inline"` jobs a single `background-jobs-worker` process runs in parallel. Concurrency is at the JS event-loop level: every job in flight shares the worker's process and DB connection pool, so the cap should fit the pool, not the CPU count. Forking remains the right tool when you need memory isolation across long-running jobs or want to use more cores. The older `forked: false` option still maps to inline mode.
2010
2011
 
2011
- New jobs default to `executionMode: "pooled"`: a worker runs them serially in warm, reusable Node child runners. `pooledRunnerCount` (default: `4`) bounds this independent per-worker capacity. It and `pooledRunnerMaxJobs` must be finite positive integers; the RSS and lifetime limits must be finite positive numbers. A child is recycled after an acknowledged job when it reaches `pooledRunnerMaxJobs` (default: `100`), `pooledRunnerMaxRssBytes` (default: `536870912`, or 512 MiB), or `pooledRunnerMaxLifetimeMs` (default: `3600000`, or one hour). Pooled rows remain readable by older mains during rolling upgrades and run there with legacy one-shot fork behavior. `forked: true` remains an explicit alias for `"forked"`; `forked: false` remains inline. See [execution modes and pooled runners](docs/background-jobs.md#execution-modes-and-pooled-runners).
2012
+ New jobs default to `executionMode: "pooled"`: a worker runs them in warm, reusable Node child runners. `pooledRunnerCount` (default: `4`) bounds this independent per-worker pool, and `pooledRunnerConcurrency` (default: `1`) sets how many jobs each child runs at once on its own event loop, so total pooled capacity is `pooledRunnerCount × pooledRunnerConcurrency` — raise concurrency for I/O-bound jobs to get high throughput from a bounded, isolated set of processes. `pooledRunnerCount`, `pooledRunnerConcurrency`, and `pooledRunnerMaxJobs` must be finite positive integers; the RSS and lifetime limits must be finite positive numbers. A child is recycled after an acknowledged job when it reaches `pooledRunnerMaxJobs` (default: `100`), `pooledRunnerMaxRssBytes` (default: `536870912`, or 512 MiB), or `pooledRunnerMaxLifetimeMs` (default: `3600000`, or one hour). Pooled rows remain readable by older mains during rolling upgrades and run there with legacy one-shot fork behavior. `forked: true` remains an explicit alias for `"forked"`; `forked: false` remains inline. See [execution modes and pooled runners](docs/background-jobs.md#execution-modes-and-pooled-runners).
2012
2013
 
2013
2014
  `maxConcurrentForkedJobs` (default: `4`) caps how many out-of-process `executionMode: "forked"` or `executionMode: "spawned"` jobs one worker may keep in flight. Forked jobs use `child_process.fork()` with an attached IPC channel. After the main process acknowledges their durable status report, forked and spawned one-shot runners exit without waiting for graceful Beacon/database teardown; the OS closes their process-owned resources. A missing or rejected status acknowledgement makes the runner exit as failed instead of reporting clean success. Spawned jobs use the legacy `background-jobs-runner` CLI process via `child_process.spawn()` and are only for callers that intentionally want that spawned behavior.
2014
2015
 
2015
- `jobTimeoutMs` (or `VELOCIOUS_BACKGROUND_JOBS_JOB_TIMEOUT_MS`, milliseconds; default: disabled) is a wall-clock backstop for a single `"forked"` runner. A forked job still running after the timeout is terminated (`SIGTERM`, then `SIGKILL` after the reaping grace) and reported `failed`, so a genuinely-hung runner can't pin a worker — and its whole-app boot and DB connections — indefinitely (notably a retired-release worker draining after a deploy). It's a coarse safety net, not per-job tuning: it applies to every forked runner, so set it well above the longest legitimate forked job. Omit it, or set `null`/`<= 0`, to disable. `"inline"` jobs are not covered — they share the worker's process and can't be killed without killing the worker. See [docs/background-jobs.md](docs/background-jobs.md#forked-job-timeout-hung-runner-backstop).
2016
+ `jobTimeoutMs` (or `VELOCIOUS_BACKGROUND_JOBS_JOB_TIMEOUT_MS`, milliseconds; default: disabled) is a wall-clock backstop for `"forked"` and `"pooled"` jobs. A job still running after the timeout is terminated (`SIGTERM`, then `SIGKILL` after the reaping grace) and reported `failed`, so a genuinely-hung job can't pin a worker's capacity — and its whole-app boot and DB connections — indefinitely (notably a retired-release worker draining after a deploy). For a **pooled** job the whole child running it is killed, so its concurrent in-flight siblings on that child are also reported `failed` and requeued — a hung JS job can't be cancelled any other way — before a replacement child is spawned. It's a coarse safety net, not per-job tuning: it applies to every forked and pooled job, so set it well above the longest legitimate job. Omit it, or set `null`/`<= 0`, to disable. `"inline"` jobs are not covered — they share the worker's process and can't be killed without killing the worker. See [docs/background-jobs.md](docs/background-jobs.md#job-timeout-hung-runner-backstop).
2016
2017
 
2017
2018
  ### Dispatch strategy
2018
2019
 
@@ -2326,4 +2327,6 @@ npx velocious db:tenants:migrate projectTenant --parallel 20
2326
2327
 
2327
2328
  At runtime, the apartment-style `Tenant` façade (`velocious/build/src/tenants/tenant.js`) is the single entry point: `Tenant.with(tenant, callback)` / `Tenant.current()` to switch into and read a tenant context, `Tenant.each({identifier, callback, parallel?, filter?})` to run a callback within every provider-listed tenant, and `Tenant.drop({identifier, tenant})` (plus the `db:tenants:drop` CLI command) to drop a tenant's database through the provider's `dropDatabase` hook. `Tenant.with` and `Tenant.each` run their callbacks inside `ensureConnections`, so switching into a tenant establishes its database connections (global + tenant) and passes them to the callback — the tenant is immediately queryable without the caller wiring up connections (already-open connections are reused, so nesting does not double-connect). `Tenant.with` is generic over its callback's return type, so a value returned from inside the tenant context comes back to the caller with its type preserved (no cast needed). `Tenant.aggregateAcross({identifier, aggregates, keyColumns, subquery, tenants?, filter?})` runs one aggregate over the same table across many tenant databases and returns the merged result — grouping tenants by server and using a single cross-database `UNION ALL` where the driver supports two-part `` `database`.`table` `` references (MySQL/MariaDB) or one query per tenant otherwise (PostgreSQL/SQLite/MSSQL).
2328
2329
 
2330
+ `SchemaCloner` adds a missing auto-increment column and its separate source unique index in one schema alteration, including on MySQL/MariaDB where an auto-increment column must be keyed when it is created.
2331
+
2329
2332
  See [docs/tenant-databases.md](docs/tenant-databases.md) for the full configuration and migration pattern.
@@ -78,9 +78,10 @@ function runnerProcessTitle(JobClass, payload) {
78
78
  * @param {import("./types.js").BackgroundJobPayload} payload - Payload.
79
79
  * @param {object} [options] - Runner options.
80
80
  * @param {boolean} [options.closeConnections] - Whether to gracefully close framework connections after the job.
81
+ * @param {boolean} [options.manageProcessTitle] - Whether to set the per-job process title and restore it afterwards. Off for concurrent pooled runners, where interleaved snapshot/restore of the single process-wide `process.title` would corrupt it; the pooled child owns an aggregate title instead.
81
82
  * @returns {Promise<void>} - Resolves when complete.
82
83
  */
83
- export default async function runJobPayload(payload, {closeConnections = true} = {}) {
84
+ export default async function runJobPayload(payload, {closeConnections = true, manageProcessTitle = true} = {}) {
84
85
  const configuration = await configurationResolver()
85
86
  configuration.setCurrent()
86
87
  await configuration.initialize({type: "background-jobs-runner"})
@@ -98,8 +99,9 @@ export default async function runJobPayload(payload, {closeConnections = true} =
98
99
 
99
100
  // Name the process after the job it is running so `ps`/`top` show what each
100
101
  // runner is doing; restored in the `finally` below when the job finishes.
102
+ // Skipped for concurrent pooled runners, whose child owns an aggregate title.
101
103
  const previousTitle = process.title
102
- process.title = runnerProcessTitle(JobClass, payload)
104
+ if (manageProcessTitle) process.title = runnerProcessTitle(JobClass, payload)
103
105
 
104
106
  try {
105
107
  try {
@@ -136,7 +138,7 @@ export default async function runJobPayload(payload, {closeConnections = true} =
136
138
  } finally {
137
139
  // Restore the runner's base title so a lingering/idle runner (or a reused
138
140
  // one) doesn't misreport a finished job as still running.
139
- process.title = previousTitle
141
+ if (manageProcessTitle) process.title = previousTitle
140
142
  if (closeConnections) {
141
143
  try {
142
144
  await configuration.disconnectBeacon()
@@ -3,8 +3,32 @@
3
3
  import runJobPayload, { BackgroundJobPerformedFailure } from "./job-runner.js"
4
4
  import setRunnerProcessTitle from "./runner-process-title.js"
5
5
 
6
+ const BASE_PROCESS_TITLE = "velocious background-jobs-runner"
7
+
6
8
  setRunnerProcessTitle()
7
- let running = false
9
+
10
+ /**
11
+ * Ids of jobs currently running in this child. A pooled child runs up to
12
+ * `pooledRunnerConcurrency` jobs at once (the worker only dispatches within that
13
+ * bound); the set dedupes a redelivered job id and lets each job settle
14
+ * independently.
15
+ * @type {Set<string>}
16
+ */
17
+ const runningJobIds = new Set()
18
+
19
+ /**
20
+ * Sets an aggregate process title from the current in-flight count. A child runs
21
+ * jobs concurrently, so a per-job title (which `runJobPayload` would snapshot and
22
+ * restore around a single job) cannot represent the process — interleaved
23
+ * completions would leave a stale label. Recomputing from `runningJobIds.size` is
24
+ * concurrency-safe and honest: `ps`/`top` show how many jobs the child is running.
25
+ * @returns {void}
26
+ */
27
+ function updateProcessTitle() {
28
+ const count = runningJobIds.size
29
+
30
+ process.title = count > 0 ? `${BASE_PROCESS_TITLE}: ${count} ${count === 1 ? "job" : "jobs"}` : BASE_PROCESS_TITLE
31
+ }
8
32
 
9
33
  /**
10
34
  * Checks whether an IPC value is a runnable pooled job message.
@@ -46,33 +70,46 @@ function sendOutcome({jobId, acknowledged, status, error}) {
46
70
  }
47
71
 
48
72
  /**
49
- * Handles one serial job message.
50
- * @param {?} message - IPC message.
73
+ * Runs one job concurrently with any siblings and reports its own terminal
74
+ * outcome. A single job's unexpected failure reports that job for reclamation
75
+ * (`acknowledged: false`) but does NOT take down the child — its concurrent
76
+ * siblings keep running. Only a process-level fault (which escapes every
77
+ * per-job try/catch) ends the child, which the worker sees as an exit and
78
+ * reclaims for the whole in-flight set.
79
+ * @param {import("./types.js").BackgroundJobPayload & {id: string}} payload - Job payload.
51
80
  * @returns {Promise<void>} - Resolves after reporting.
52
81
  */
53
- async function handleMessage(message) {
54
- if (running || !isJobMessage(message)) return
55
-
56
- running = true
82
+ async function runJob(payload) {
57
83
  try {
58
- await runJobPayload(message.payload, {closeConnections: false})
59
- await sendOutcome({jobId: message.payload.id, acknowledged: true, status: "completed"})
84
+ await runJobPayload(payload, {closeConnections: false, manageProcessTitle: false})
85
+ await sendOutcome({jobId: payload.id, acknowledged: true, status: "completed"})
60
86
  } catch (error) {
61
87
  if (error instanceof BackgroundJobPerformedFailure) {
62
- await sendOutcome({jobId: message.payload.id, acknowledged: true, status: "failed"})
88
+ await sendOutcome({jobId: payload.id, acknowledged: true, status: "failed"})
63
89
  } else {
64
90
  const reportError = error instanceof Error ? error : new Error(String(error))
65
91
  console.error("Pooled background job runner failed before terminal acknowledgement:", reportError)
66
- await sendOutcome({jobId: message.payload.id, acknowledged: false, error: reportError})
67
- process.exitCode = 1
68
- if (process.disconnect) process.disconnect()
69
- setImmediate(() => process.exit(1))
92
+ await sendOutcome({jobId: payload.id, acknowledged: false, error: reportError})
70
93
  }
71
94
  } finally {
72
- running = false
95
+ runningJobIds.delete(payload.id)
96
+ updateProcessTitle()
73
97
  }
74
98
  }
75
99
 
76
- process.on("message", (message) => { void handleMessage(message) })
100
+ /**
101
+ * Handles a job message, starting it alongside any concurrent siblings.
102
+ * @param {?} message - IPC message.
103
+ * @returns {void}
104
+ */
105
+ function handleMessage(message) {
106
+ if (!isJobMessage(message) || runningJobIds.has(message.payload.id)) return
107
+
108
+ runningJobIds.add(message.payload.id)
109
+ updateProcessTitle()
110
+ void runJob(message.payload)
111
+ }
112
+
113
+ process.on("message", (message) => handleMessage(message))
77
114
  process.once("disconnect", () => process.exit(0))
78
115
  for (const signal of ["SIGTERM", "SIGINT"]) process.once(signal, () => process.exit(1))
@@ -66,14 +66,15 @@ export default class BackgroundJobsWorker {
66
66
  * @param {number} [args.maxConcurrentForkedJobs] - Override the process runner concurrency cap from `configuration.getBackgroundJobsConfig()`.
67
67
  * @param {number} [args.maxConcurrentInlineJobs] - Override the inline-job concurrency cap from `configuration.getBackgroundJobsConfig()`.
68
68
  * @param {number} [args.pooledRunnerCount] - Override the pooled runner count.
69
+ * @param {number} [args.pooledRunnerConcurrency] - Override the per-runner concurrency.
69
70
  * @param {number} [args.pooledRunnerMaxJobs] - Override the per-runner recycle job count.
70
71
  * @param {number} [args.pooledRunnerMaxRssBytes] - Override the per-runner recycle RSS limit.
71
72
  * @param {number} [args.pooledRunnerMaxLifetimeMs] - Override the per-runner recycle lifetime.
72
73
  * @param {number} [args.forkedChildSigkillGraceMs] - Override the grace period between SIGTERM and SIGKILL when reaping lingering process runners on stop.
73
74
  * @param {number} [args.heartbeatIntervalMs] - Override the liveness heartbeat interval (default 15000ms).
74
- * @param {number} [args.jobTimeoutMs] - Override the forked-job wall-clock timeout from `configuration.getBackgroundJobsConfig()`. `0` disables it.
75
+ * @param {number} [args.jobTimeoutMs] - Override the wall-clock timeout for forked and pooled jobs from `configuration.getBackgroundJobsConfig()`. `0` disables it.
75
76
  */
76
- constructor({configuration, host, port, maxConcurrentForkedJobs, maxConcurrentInlineJobs, pooledRunnerCount, pooledRunnerMaxJobs, pooledRunnerMaxRssBytes, pooledRunnerMaxLifetimeMs, forkedChildSigkillGraceMs, heartbeatIntervalMs, jobTimeoutMs} = {}) {
77
+ constructor({configuration, host, port, maxConcurrentForkedJobs, maxConcurrentInlineJobs, pooledRunnerCount, pooledRunnerConcurrency, pooledRunnerMaxJobs, pooledRunnerMaxRssBytes, pooledRunnerMaxLifetimeMs, forkedChildSigkillGraceMs, heartbeatIntervalMs, jobTimeoutMs} = {}) {
77
78
  /**
78
79
  * Narrows the runtime value to the documented type.
79
80
  * @type {Promise<import("../configuration.js").default>} */
@@ -110,10 +111,12 @@ export default class BackgroundJobsWorker {
110
111
  * @type {number} */
111
112
  this.maxConcurrentForkedJobs = this.maxConcurrentForkedJobsOverride || 4
112
113
  this.pooledRunnerCountOverride = positiveInteger(pooledRunnerCount)
114
+ this.pooledRunnerConcurrencyOverride = positiveInteger(pooledRunnerConcurrency)
113
115
  this.pooledRunnerMaxJobsOverride = positiveInteger(pooledRunnerMaxJobs)
114
116
  this.pooledRunnerMaxRssBytesOverride = positiveNumber(pooledRunnerMaxRssBytes)
115
117
  this.pooledRunnerMaxLifetimeMsOverride = positiveNumber(pooledRunnerMaxLifetimeMs)
116
118
  this.pooledRunnerCount = this.pooledRunnerCountOverride || 4
119
+ this.pooledRunnerConcurrency = this.pooledRunnerConcurrencyOverride || 1
117
120
  this.pooledRunnerMaxJobs = this.pooledRunnerMaxJobsOverride || 100
118
121
  this.pooledRunnerMaxRssBytes = this.pooledRunnerMaxRssBytesOverride || 512 * 1024 * 1024
119
122
  this.pooledRunnerMaxLifetimeMs = this.pooledRunnerMaxLifetimeMsOverride || 60 * 60 * 1000
@@ -126,7 +129,7 @@ export default class BackgroundJobsWorker {
126
129
  ? forkedChildSigkillGraceMs
127
130
  : FORKED_CHILD_SIGKILL_GRACE_MS
128
131
  /**
129
- * Constructor override for the forked-job wall-clock timeout. When unset the
132
+ * Constructor override for the forked and pooled wall-clock job timeout. When unset the
130
133
  * timeout is read from `configuration.getBackgroundJobsConfig().jobTimeoutMs`
131
134
  * at fork time (default: disabled).
132
135
  * @type {number | undefined}
@@ -183,7 +186,7 @@ export default class BackgroundJobsWorker {
183
186
  this.inflightPooledJobs = new Set()
184
187
  /** @type {Set<import("node:child_process").ChildProcess>} */
185
188
  this.pooledChildren = new Set()
186
- /** @type {Map<import("node:child_process").ChildProcess, {createdAtMs: number, jobsRun: number, payload?: import("./types.js").BackgroundJobPayload & {id: string}, resolve?: (value: void) => void, settling?: boolean}>} */
189
+ /** @type {Map<import("node:child_process").ChildProcess, {createdAtMs: number, jobsRun: number, inflight: Map<string, {payload: import("./types.js").BackgroundJobPayload & {id: string}, resolve?: (value: void) => void, timeoutTimer?: ReturnType<typeof setTimeout> | null}>, retiring: boolean, settling?: boolean, timeoutSigkillTimer?: ReturnType<typeof setTimeout> | null}>} */
187
190
  this.pooledChildStates = new Map()
188
191
  }
189
192
 
@@ -210,6 +213,7 @@ export default class BackgroundJobsWorker {
210
213
  }
211
214
  const poolConfig = this.configuration.getBackgroundJobsConfig()
212
215
  if (typeof this.pooledRunnerCountOverride !== "number") this.pooledRunnerCount = poolConfig.pooledRunnerCount
216
+ if (typeof this.pooledRunnerConcurrencyOverride !== "number") this.pooledRunnerConcurrency = poolConfig.pooledRunnerConcurrency
213
217
  if (typeof this.pooledRunnerMaxJobsOverride !== "number") this.pooledRunnerMaxJobs = poolConfig.pooledRunnerMaxJobs
214
218
  if (typeof this.pooledRunnerMaxRssBytesOverride !== "number") this.pooledRunnerMaxRssBytes = poolConfig.pooledRunnerMaxRssBytes
215
219
  if (typeof this.pooledRunnerMaxLifetimeMsOverride !== "number") this.pooledRunnerMaxLifetimeMs = poolConfig.pooledRunnerMaxLifetimeMs
@@ -582,7 +586,7 @@ export default class BackgroundJobsWorker {
582
586
  _readyMessage() {
583
587
  const acceptsProcessJob = this.inflightProcessJobs.size < this.maxConcurrentForkedJobs
584
588
  const acceptsInline = this.inflightInlineJobs.size < this.maxConcurrentInlineJobs
585
- const acceptsPooled = this.inflightPooledJobs.size < this.pooledRunnerCount
589
+ const acceptsPooled = this._availablePooledSlots() > 0
586
590
 
587
591
  if (!acceptsProcessJob && !acceptsInline && !acceptsPooled) return null
588
592
 
@@ -612,27 +616,112 @@ export default class BackgroundJobsWorker {
612
616
  }
613
617
 
614
618
  /**
615
- * Runs a payload in an idle pooled child.
619
+ * Free pooled slots across the pool: open slots in non-retiring children plus
620
+ * the slots we could add by spawning more children up to `pooledRunnerCount`.
621
+ * Retiring children (draining before replacement) never contribute capacity.
622
+ * @returns {number} - Number of pooled jobs the worker can accept right now.
623
+ */
624
+ _availablePooledSlots() {
625
+ let openInExisting = 0
626
+ let nonRetiringChildren = 0
627
+
628
+ for (const child of this.pooledChildren) {
629
+ const state = this.pooledChildStates.get(child)
630
+ if (!state || state.retiring) continue
631
+ nonRetiringChildren += 1
632
+ openInExisting += this.pooledRunnerConcurrency - state.inflight.size
633
+ }
634
+
635
+ const spawnableChildren = Math.max(0, this.pooledRunnerCount - nonRetiringChildren)
636
+
637
+ return openInExisting + spawnableChildren * this.pooledRunnerConcurrency
638
+ }
639
+
640
+ /**
641
+ * Runs a payload on a pooled child with a free concurrency slot, spawning a
642
+ * new child when every non-retiring child is full and the pool is below
643
+ * `pooledRunnerCount`. Each child runs up to `pooledRunnerConcurrency` jobs at
644
+ * once on its own event loop.
616
645
  * @param {import("./types.js").BackgroundJobPayload & {id: string}} payload - Job payload.
617
646
  * @returns {Promise<void>} - Resolves after the durable report.
618
647
  */
619
648
  _runPooledJob(payload) {
620
- let child = [...this.pooledChildren].find((candidate) => !this.pooledChildStates.get(candidate)?.payload)
649
+ let child
650
+ for (const candidate of this.pooledChildren) {
651
+ const state = this.pooledChildStates.get(candidate)
652
+ if (state && !state.retiring && state.inflight.size < this.pooledRunnerConcurrency) {
653
+ child = candidate
654
+ break
655
+ }
656
+ }
621
657
  if (!child) child = this._createPooledChild()
622
658
  const state = this.pooledChildStates.get(child)
623
659
  if (!state) throw new Error("Pooled runner state missing")
624
660
 
625
661
  return new Promise((resolve) => {
626
- state.payload = payload
627
- state.resolve = resolve
662
+ const timeoutTimer = this._armPooledJobTimeout({child, jobId: payload.id})
663
+
664
+ state.inflight.set(payload.id, {payload, resolve, timeoutTimer})
628
665
  try {
629
666
  child.send({type: "job", payload})
630
667
  } catch (error) {
631
- this._handlePooledChildFailure({child, error})
668
+ void this._handlePooledChildFailure({child, error})
632
669
  }
633
670
  })
634
671
  }
635
672
 
673
+ /**
674
+ * Arms a per-job wall-clock backstop for a pooled job. A pooled child hosts many
675
+ * concurrent jobs, so a single genuinely-hung job would otherwise pin its
676
+ * runner's concurrency slot forever — the lifetime recycle only retires a child
677
+ * once its in-flight set drains, which a hung job never does. On overrun the
678
+ * whole child is terminated so the hung job (and its siblings) requeue. Returns
679
+ * the timer, or null when no timeout is configured.
680
+ * @param {object} args - Options.
681
+ * @param {import("node:child_process").ChildProcess} args.child - Pooled child.
682
+ * @param {string} args.jobId - Job id whose overrun is guarded.
683
+ * @returns {ReturnType<typeof setTimeout> | null} - The armed timer, or null.
684
+ */
685
+ _armPooledJobTimeout({child, jobId}) {
686
+ const timeoutMs = this._resolveJobTimeoutMs()
687
+
688
+ if (!(typeof timeoutMs === "number" && timeoutMs > 0)) return null
689
+
690
+ return setTimeout(() => this._onPooledJobTimeout({child, jobId}), timeoutMs)
691
+ }
692
+
693
+ /**
694
+ * Fired when a pooled job overruns its timeout. Terminates the child running it
695
+ * (SIGTERM, then SIGKILL after the grace) — a hung JS job cannot be cancelled
696
+ * any other way. The non-clean exit flows through `_handlePooledChildFailure`,
697
+ * which reports every in-flight job on the child failed (so they requeue) and
698
+ * drops it from tracking; capacity is refilled on the next dispatch.
699
+ * @param {object} args - Options.
700
+ * @param {import("node:child_process").ChildProcess} args.child - Pooled child.
701
+ * @param {string} args.jobId - Job id that overran.
702
+ * @returns {void}
703
+ */
704
+ _onPooledJobTimeout({child, jobId}) {
705
+ const state = this.pooledChildStates.get(child)
706
+
707
+ // Already settling/gone, or the job finished in the race with this timer.
708
+ if (!state || state.settling || !state.inflight.has(jobId)) return
709
+
710
+ try {
711
+ child.kill("SIGTERM")
712
+ } catch {
713
+ // Child already exited; nothing to do.
714
+ }
715
+
716
+ state.timeoutSigkillTimer = setTimeout(() => {
717
+ try {
718
+ child.kill("SIGKILL")
719
+ } catch {
720
+ // Child already exited; nothing to do.
721
+ }
722
+ }, this.forkedChildSigkillGraceMs)
723
+ }
724
+
636
725
  /**
637
726
  * Creates a reusable pooled child.
638
727
  * @returns {import("node:child_process").ChildProcess} - New pooled child.
@@ -647,7 +736,7 @@ export default class BackgroundJobsWorker {
647
736
  })
648
737
  this.pooledChildren.add(child)
649
738
  this.inflightProcessChildren.add(child)
650
- this.pooledChildStates.set(child, {createdAtMs: Date.now(), jobsRun: 0})
739
+ this.pooledChildStates.set(child, {createdAtMs: Date.now(), jobsRun: 0, inflight: new Map(), retiring: false})
651
740
  child.on("message", (message) => this._handlePooledChildMessage({child, message}))
652
741
  child.once("exit", (code, signal) => this._handlePooledChildFailure({child, error: new Error(`Pooled background job runner exited: code=${code} signal=${signal || "none"}`)}))
653
742
  child.once("error", (error) => this._handlePooledChildFailure({child, error}))
@@ -655,7 +744,8 @@ export default class BackgroundJobsWorker {
655
744
  }
656
745
 
657
746
  /**
658
- * Handles a pooled child's durable-report acknowledgement.
747
+ * Handles a pooled child's per-job durable-report acknowledgement. A child
748
+ * runs jobs concurrently and reports one `job-outcome` per job id.
659
749
  * @param {object} args - Message details.
660
750
  * @param {import("node:child_process").ChildProcess} args.child - Pooled child.
661
751
  * @param {?} args.message - IPC message.
@@ -665,23 +755,71 @@ export default class BackgroundJobsWorker {
665
755
  if (!message || typeof message !== "object") return
666
756
  const record = /** @type {{type?: ?, jobId?: ?, acknowledged?: ?, rssBytes?: ?, error?: ?}} */ (message)
667
757
  const state = this.pooledChildStates.get(child)
668
- if (record.type !== "job-outcome" || !state?.payload || record.jobId !== state.payload.id || state.settling) return
669
- if (record.acknowledged !== true) {
670
- void this._handlePooledChildFailure({child, error: new Error(typeof record.error === "string" ? record.error : "Pooled runner terminal report was not acknowledged")})
671
- return
672
- }
673
- const resolve = state.resolve
674
- state.payload = undefined
675
- state.resolve = undefined
758
+ if (record.type !== "job-outcome" || !state || state.settling || typeof record.jobId !== "string") return
759
+ const entry = state.inflight.get(record.jobId)
760
+ if (!entry) return
761
+
762
+ if (entry.timeoutTimer) clearTimeout(entry.timeoutTimer)
763
+ state.inflight.delete(record.jobId)
676
764
  state.jobsRun += 1
677
- if (resolve) resolve(undefined)
765
+ const resolve = entry.resolve
766
+
767
+ if (record.acknowledged === true) {
768
+ if (resolve) resolve(undefined)
769
+ } else {
770
+ // The child stayed alive but could not confirm this one job's terminal
771
+ // report; reclaim just this job — its concurrent siblings are unaffected.
772
+ void this._reportJobResult({
773
+ jobId: entry.payload.id,
774
+ status: "failed",
775
+ error: new Error(typeof record.error === "string" ? record.error : "Pooled runner terminal report was not acknowledged"),
776
+ handoffId: entry.payload.handoffId,
777
+ handedOffAtMs: entry.payload.handedOffAtMs,
778
+ workerId: entry.payload.workerId || this.workerId
779
+ }).finally(() => { if (resolve) resolve(undefined) })
780
+ }
781
+
678
782
  const rssBytes = typeof record.rssBytes === "number" ? record.rssBytes : Number.POSITIVE_INFINITY
679
783
  const runnerAgeMs = Date.now() - state.createdAtMs
680
- if (state.jobsRun >= this.pooledRunnerMaxJobs || rssBytes >= this.pooledRunnerMaxRssBytes || runnerAgeMs >= this.pooledRunnerMaxLifetimeMs || this.shouldStop) this._retirePooledChild(child)
784
+ if (!state.retiring && (state.jobsRun >= this.pooledRunnerMaxJobs || rssBytes >= this.pooledRunnerMaxRssBytes || runnerAgeMs >= this.pooledRunnerMaxLifetimeMs || this.shouldStop)) {
785
+ this._beginRetirePooledChild(child)
786
+ }
787
+ this._terminateIfDrained(child)
788
+ }
789
+
790
+ /**
791
+ * Marks a pooled child for retirement and eagerly spawns a single replacement
792
+ * (1-for-1) so its capacity is restored immediately without waiting for it to
793
+ * finish draining. The retiring child stops receiving new jobs and is
794
+ * terminated only once its in-flight set drains, so a long-running job (e.g. a
795
+ * build) is never cut off.
796
+ * @param {import("node:child_process").ChildProcess} child - Child to retire.
797
+ * @returns {void}
798
+ */
799
+ _beginRetirePooledChild(child) {
800
+ const state = this.pooledChildStates.get(child)
801
+ if (!state || state.retiring) return
802
+
803
+ state.retiring = true
804
+ // Best-effort pre-warm: skip when stopping (no new work) or before the
805
+ // worker is initialized (no configuration to fork a child from).
806
+ if (!this.shouldStop && this.configuration) this._createPooledChild()
681
807
  }
682
808
 
683
809
  /**
684
- * Retires an idle pooled child.
810
+ * Terminates a retiring pooled child once it has no in-flight jobs left.
811
+ * @param {import("node:child_process").ChildProcess} child - Child to check.
812
+ * @returns {void}
813
+ */
814
+ _terminateIfDrained(child) {
815
+ const state = this.pooledChildStates.get(child)
816
+ if (!state || !state.retiring || state.inflight.size > 0) return
817
+
818
+ this._retirePooledChild(child)
819
+ }
820
+
821
+ /**
822
+ * Retires a drained pooled child (removes it from tracking, then SIGTERMs it).
685
823
  * @param {import("node:child_process").ChildProcess} child - Child process to retire.
686
824
  * @returns {void}
687
825
  */
@@ -693,7 +831,11 @@ export default class BackgroundJobsWorker {
693
831
  }
694
832
 
695
833
  /**
696
- * Removes and reports an unhealthy pooled child.
834
+ * Removes an exited/unhealthy pooled child and reports every job that was
835
+ * in-flight on it as failed — a process-level crash's blast radius is the
836
+ * child's whole in-flight set. Capacity is refilled lazily on the next
837
+ * dispatch (a spawnable slot is still advertised), avoiding a tight respawn
838
+ * loop when a child crashes on startup.
697
839
  * @param {object} args - Failure details.
698
840
  * @param {import("node:child_process").ChildProcess} args.child - Pooled child.
699
841
  * @param {?} args.error - Failure.
@@ -702,24 +844,33 @@ export default class BackgroundJobsWorker {
702
844
  async _handlePooledChildFailure({child, error}) {
703
845
  const state = this.pooledChildStates.get(child)
704
846
  if (state?.settling) return
705
- if (state) state.settling = true
847
+ if (state) {
848
+ state.settling = true
849
+ // Cancel this child's pending timers before its in-flight set is reported —
850
+ // the SIGKILL grace from a timeout kill, and every armed per-job backstop.
851
+ if (state.timeoutSigkillTimer) clearTimeout(state.timeoutSigkillTimer)
852
+ for (const inflightEntry of state.inflight.values()) {
853
+ if (inflightEntry.timeoutTimer) clearTimeout(inflightEntry.timeoutTimer)
854
+ }
855
+ }
706
856
  this.pooledChildren.delete(child)
707
857
  this.inflightProcessChildren.delete(child)
708
- if (!state?.payload) {
709
- this.pooledChildStates.delete(child)
710
- return
711
- }
712
- const payload = state.payload
713
- await this._reportJobResult({
714
- jobId: payload.id,
715
- status: "failed",
716
- error,
717
- handoffId: payload.handoffId,
718
- handedOffAtMs: payload.handedOffAtMs,
719
- workerId: payload.workerId || this.workerId
720
- })
858
+
859
+ const entries = state ? [...state.inflight.values()] : []
860
+ if (state) state.inflight.clear()
721
861
  this.pooledChildStates.delete(child)
722
- if (state.resolve) state.resolve(undefined)
862
+
863
+ await Promise.allSettled(entries.map(async (entry) => {
864
+ await this._reportJobResult({
865
+ jobId: entry.payload.id,
866
+ status: "failed",
867
+ error,
868
+ handoffId: entry.payload.handoffId,
869
+ handedOffAtMs: entry.payload.handedOffAtMs,
870
+ workerId: entry.payload.workerId || this.workerId
871
+ })
872
+ if (entry.resolve) entry.resolve(undefined)
873
+ }))
723
874
  }
724
875
 
725
876
  /**
@@ -820,7 +971,7 @@ export default class BackgroundJobsWorker {
820
971
  * @returns {ForkedJobTimeoutState} - Timeout state.
821
972
  */
822
973
  _armForkedJobTimeout({child}) {
823
- const timeoutMs = this._resolveForkedJobTimeoutMs()
974
+ const timeoutMs = this._resolveJobTimeoutMs()
824
975
  /** @type {ForkedJobTimeoutState} */
825
976
  const state = {timedOut: false, timeoutMs, timer: null, sigkillTimer: null}
826
977
 
@@ -832,12 +983,12 @@ export default class BackgroundJobsWorker {
832
983
  }
833
984
 
834
985
  /**
835
- * Resolves the effective forked-job timeout in ms, or null when disabled. The
986
+ * Resolves the effective wall-clock job timeout in ms (shared by forked and pooled jobs), or null when disabled. The
836
987
  * constructor override wins; otherwise the value comes from the background-jobs
837
988
  * configuration. A non-positive value disables the backstop.
838
989
  * @returns {number | null} - Timeout in ms, or null when disabled.
839
990
  */
840
- _resolveForkedJobTimeoutMs() {
991
+ _resolveJobTimeoutMs() {
841
992
  const raw = typeof this.jobTimeoutMsOverride === "number"
842
993
  ? this.jobTimeoutMsOverride
843
994
  : (this.configuration ? this.configuration.getBackgroundJobsConfig().jobTimeoutMs : null)
@@ -165,6 +165,7 @@
165
165
  * for workload-shaped limits use per-queue caps (`queues`) instead, which are
166
166
  * enforced cluster-wide and are immune to duplicate worker processes.
167
167
  * @property {number} [pooledRunnerCount] - Number of warm, reusable child runners owned by each worker. Pooled capacity is separate from inline and forked/spawned capacity. Default: `4`.
168
+ * @property {number} [pooledRunnerConcurrency] - Number of jobs each pooled child runs concurrently on its own event loop. Total per-worker pooled capacity is `pooledRunnerCount × pooledRunnerConcurrency`. `1` (default) keeps each child serial; raise it for I/O-bound jobs so a bounded set of isolated processes handles high concurrency (like the inline lane) without one process per concurrent job. Default: `1`.
168
169
  * @property {number} [pooledRunnerMaxJobs] - Number of sequential jobs a pooled child runs before it is replaced, bounding process-level resource accumulation. Default: `100`.
169
170
  * @property {number} [pooledRunnerMaxRssBytes] - RSS bytes after an acknowledged job at which a pooled child is replaced. Default: `536870912` (512 MiB).
170
171
  * @property {number} [pooledRunnerMaxLifetimeMs] - Age after an acknowledged job at which a pooled child is replaced. Default: `3600000` (one hour).
@@ -192,6 +192,12 @@ export default class VelociousConfiguration {
192
192
  }
193
193
 
194
194
  this._isInitialized = false
195
+ /**
196
+ * In-progress `initialize()` promise, memoized so concurrent callers await
197
+ * the same bootstrap. Reset to undefined if initialization fails.
198
+ * @type {Promise<void> | undefined}
199
+ */
200
+ this._initializePromise = undefined
195
201
  this.httpServer = httpServer || {}
196
202
  /**
197
203
  * Stores the http server instance value.
@@ -1269,6 +1275,7 @@ export default class VelociousConfiguration {
1269
1275
  const envMaxConcurrentForkedRaw = process.env.VELOCIOUS_BACKGROUND_JOBS_MAX_CONCURRENT_FORKED_JOBS
1270
1276
  const envMaxConcurrentRaw = process.env.VELOCIOUS_BACKGROUND_JOBS_MAX_CONCURRENT_INLINE_JOBS
1271
1277
  const envPooledRunnerCountRaw = process.env.VELOCIOUS_BACKGROUND_JOBS_POOLED_RUNNER_COUNT
1278
+ const envPooledRunnerConcurrencyRaw = process.env.VELOCIOUS_BACKGROUND_JOBS_POOLED_RUNNER_CONCURRENCY
1272
1279
  const envPooledRunnerMaxJobsRaw = process.env.VELOCIOUS_BACKGROUND_JOBS_POOLED_RUNNER_MAX_JOBS
1273
1280
  const envPooledRunnerMaxRssBytesRaw = process.env.VELOCIOUS_BACKGROUND_JOBS_POOLED_RUNNER_MAX_RSS_BYTES
1274
1281
  const envPooledRunnerMaxLifetimeMsRaw = process.env.VELOCIOUS_BACKGROUND_JOBS_POOLED_RUNNER_MAX_LIFETIME_MS
@@ -1279,6 +1286,7 @@ export default class VelociousConfiguration {
1279
1286
  const envMaxConcurrentForked = envMaxConcurrentForkedRaw ? Number(envMaxConcurrentForkedRaw) : undefined
1280
1287
  const envMaxConcurrent = envMaxConcurrentRaw ? Number(envMaxConcurrentRaw) : undefined
1281
1288
  const envPooledRunnerCount = envPooledRunnerCountRaw ? Number(envPooledRunnerCountRaw) : undefined
1289
+ const envPooledRunnerConcurrency = envPooledRunnerConcurrencyRaw ? Number(envPooledRunnerConcurrencyRaw) : undefined
1282
1290
  const envPooledRunnerMaxJobs = envPooledRunnerMaxJobsRaw ? Number(envPooledRunnerMaxJobsRaw) : undefined
1283
1291
  const envPooledRunnerMaxRssBytes = envPooledRunnerMaxRssBytesRaw ? Number(envPooledRunnerMaxRssBytesRaw) : undefined
1284
1292
  const envPooledRunnerMaxLifetimeMs = envPooledRunnerMaxLifetimeMsRaw ? Number(envPooledRunnerMaxLifetimeMsRaw) : undefined
@@ -1299,6 +1307,9 @@ export default class VelociousConfiguration {
1299
1307
  const pooledRunnerCount = typeof configured.pooledRunnerCount === "number" && Number.isFinite(configured.pooledRunnerCount) && Number.isInteger(configured.pooledRunnerCount) && configured.pooledRunnerCount >= 1
1300
1308
  ? configured.pooledRunnerCount
1301
1309
  : (!("pooledRunnerCount" in configured) && typeof envPooledRunnerCount === "number" && Number.isFinite(envPooledRunnerCount) && Number.isInteger(envPooledRunnerCount) && envPooledRunnerCount >= 1 ? envPooledRunnerCount : 4)
1310
+ const pooledRunnerConcurrency = typeof configured.pooledRunnerConcurrency === "number" && Number.isFinite(configured.pooledRunnerConcurrency) && Number.isInteger(configured.pooledRunnerConcurrency) && configured.pooledRunnerConcurrency >= 1
1311
+ ? configured.pooledRunnerConcurrency
1312
+ : (!("pooledRunnerConcurrency" in configured) && typeof envPooledRunnerConcurrency === "number" && Number.isFinite(envPooledRunnerConcurrency) && Number.isInteger(envPooledRunnerConcurrency) && envPooledRunnerConcurrency >= 1 ? envPooledRunnerConcurrency : 1)
1302
1313
  const pooledRunnerMaxJobs = typeof configured.pooledRunnerMaxJobs === "number" && Number.isFinite(configured.pooledRunnerMaxJobs) && Number.isInteger(configured.pooledRunnerMaxJobs) && configured.pooledRunnerMaxJobs >= 1
1303
1314
  ? configured.pooledRunnerMaxJobs
1304
1315
  : (!("pooledRunnerMaxJobs" in configured) && typeof envPooledRunnerMaxJobs === "number" && Number.isFinite(envPooledRunnerMaxJobs) && Number.isInteger(envPooledRunnerMaxJobs) && envPooledRunnerMaxJobs >= 1 ? envPooledRunnerMaxJobs : 100)
@@ -1336,7 +1347,7 @@ export default class VelociousConfiguration {
1336
1347
  : 60 * 60 * 1000
1337
1348
  }
1338
1349
 
1339
- return {host, port, databaseIdentifier, maxConcurrentForkedJobs, maxConcurrentInlineJobs, pooledRunnerCount, pooledRunnerMaxJobs, pooledRunnerMaxRssBytes, pooledRunnerMaxLifetimeMs, dispatchStrategy, pollIntervalMs, queues, jobTimeoutMs, retention}
1350
+ return {host, port, databaseIdentifier, maxConcurrentForkedJobs, maxConcurrentInlineJobs, pooledRunnerCount, pooledRunnerConcurrency, pooledRunnerMaxJobs, pooledRunnerMaxRssBytes, pooledRunnerMaxLifetimeMs, dispatchStrategy, pollIntervalMs, queues, jobTimeoutMs, retention}
1340
1351
  }
1341
1352
 
1342
1353
  /**
@@ -1934,9 +1945,16 @@ export default class VelociousConfiguration {
1934
1945
  * @returns {Promise<void>} - Resolves when complete.
1935
1946
  */
1936
1947
  async initialize({type} = {type: "undefined"}) {
1937
- if (!this.isInitialized()) {
1938
- this._isInitialized = true
1939
-
1948
+ if (this._isInitialized) return
1949
+ // Memoize the in-progress initialization so concurrent callers await the same
1950
+ // bootstrap instead of racing. `_isInitialized` was previously set to `true`
1951
+ // up front, so a second caller (e.g. a pooled runner with
1952
+ // `pooledRunnerConcurrency > 1` starting several jobs on a cold child) could
1953
+ // skip initialization and load models / perform a job while the first call
1954
+ // was still awaiting model discovery and initializers. Mirrors connectBeacon.
1955
+ if (this._initializePromise) return await this._initializePromise
1956
+
1957
+ this._initializePromise = (async () => {
1940
1958
  await this.initializeModels({type})
1941
1959
  await this.getEnvironmentHandler().autoDiscoverResources(this)
1942
1960
  this._mergeDiscoveredAbilityResources()
@@ -1957,6 +1975,17 @@ export default class VelociousConfiguration {
1957
1975
  }
1958
1976
  }
1959
1977
  }
1978
+
1979
+ this._isInitialized = true
1980
+ })()
1981
+
1982
+ try {
1983
+ await this._initializePromise
1984
+ } catch (error) {
1985
+ // Let a later call retry a failed initialization instead of every future
1986
+ // caller awaiting the same cached rejection.
1987
+ this._initializePromise = undefined
1988
+ throw error
1960
1989
  }
1961
1990
  }
1962
1991