velocious 1.0.528 → 1.0.529

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 (60) hide show
  1. package/README.md +11 -1
  2. package/build/background-jobs/forked-runner-child.js +2 -1
  3. package/build/background-jobs/job-runner.js +14 -2
  4. package/build/background-jobs/json-socket.js +2 -0
  5. package/build/background-jobs/main.js +47 -5
  6. package/build/background-jobs/pooled-runner-child.js +78 -0
  7. package/build/background-jobs/runner-process-title.js +6 -0
  8. package/build/background-jobs/scheduler.js +28 -5
  9. package/build/background-jobs/store.js +79 -15
  10. package/build/background-jobs/types.js +5 -5
  11. package/build/background-jobs/worker.js +184 -5
  12. package/build/configuration-types.js +4 -0
  13. package/build/configuration.js +21 -1
  14. package/build/src/background-jobs/forked-runner-child.js +3 -2
  15. package/build/src/background-jobs/job-runner.d.ts +7 -0
  16. package/build/src/background-jobs/job-runner.d.ts.map +1 -1
  17. package/build/src/background-jobs/job-runner.js +14 -3
  18. package/build/src/background-jobs/json-socket.d.ts +2 -0
  19. package/build/src/background-jobs/json-socket.d.ts.map +1 -1
  20. package/build/src/background-jobs/json-socket.js +3 -1
  21. package/build/src/background-jobs/main.d.ts +16 -0
  22. package/build/src/background-jobs/main.d.ts.map +1 -1
  23. package/build/src/background-jobs/main.js +46 -5
  24. package/build/src/background-jobs/pooled-runner-child.d.ts +2 -0
  25. package/build/src/background-jobs/pooled-runner-child.d.ts.map +1 -0
  26. package/build/src/background-jobs/pooled-runner-child.js +77 -0
  27. package/build/src/background-jobs/runner-process-title.d.ts +3 -0
  28. package/build/src/background-jobs/runner-process-title.d.ts.map +1 -0
  29. package/build/src/background-jobs/runner-process-title.js +6 -0
  30. package/build/src/background-jobs/scheduler.d.ts +16 -2
  31. package/build/src/background-jobs/scheduler.d.ts.map +1 -1
  32. package/build/src/background-jobs/scheduler.js +27 -6
  33. package/build/src/background-jobs/store.d.ts +24 -0
  34. package/build/src/background-jobs/store.d.ts.map +1 -1
  35. package/build/src/background-jobs/store.js +81 -15
  36. package/build/src/background-jobs/types.d.ts +10 -8
  37. package/build/src/background-jobs/types.d.ts.map +1 -1
  38. package/build/src/background-jobs/types.js +6 -6
  39. package/build/src/background-jobs/worker.d.ts +78 -1
  40. package/build/src/background-jobs/worker.d.ts.map +1 -1
  41. package/build/src/background-jobs/worker.js +193 -6
  42. package/build/src/configuration-types.d.ts +20 -0
  43. package/build/src/configuration-types.d.ts.map +1 -1
  44. package/build/src/configuration-types.js +5 -1
  45. package/build/src/configuration.d.ts.map +1 -1
  46. package/build/src/configuration.js +22 -2
  47. package/build/tsconfig.tsbuildinfo +1 -1
  48. package/package.json +1 -1
  49. package/src/background-jobs/forked-runner-child.js +2 -1
  50. package/src/background-jobs/job-runner.js +14 -2
  51. package/src/background-jobs/json-socket.js +2 -0
  52. package/src/background-jobs/main.js +47 -5
  53. package/src/background-jobs/pooled-runner-child.js +78 -0
  54. package/src/background-jobs/runner-process-title.js +6 -0
  55. package/src/background-jobs/scheduler.js +28 -5
  56. package/src/background-jobs/store.js +79 -15
  57. package/src/background-jobs/types.js +5 -5
  58. package/src/background-jobs/worker.js +184 -5
  59. package/src/configuration-types.js +4 -0
  60. package/src/configuration.js +21 -1
@@ -18,8 +18,25 @@ const ORPHANED_AFTER_MS = 2 * 60 * 60 * 1000
18
18
  /**
19
19
  * Execution modes.
20
20
  * @type {import("./types.js").BackgroundJobExecutionMode[]} */
21
- const EXECUTION_MODES = ["inline", "forked", "spawned"]
22
- const DEFAULT_EXECUTION_MODE = "forked"
21
+ const EXECUTION_MODES = ["inline", "forked", "pooled", "spawned"]
22
+ /**
23
+ * Execution mode for a new enqueue that names neither `executionMode` nor the
24
+ * legacy `forked` flag. Pooled routes the job to a warm, reused local runner
25
+ * process — the same isolation as forked without paying a fresh process per job.
26
+ * @type {import("./types.js").BackgroundJobExecutionMode} */
27
+ const DEFAULT_EXECUTION_MODE = "pooled"
28
+ /**
29
+ * Execution mode a legacy `forked = true` row (persisted before the
30
+ * `execution_mode` column existed) backfills to. It must stay `"forked"` so an
31
+ * upgrade never silently reinterprets already-persisted forked jobs as pooled —
32
+ * distinct from {@link DEFAULT_EXECUTION_MODE}, which only governs new enqueues.
33
+ * @type {import("./types.js").BackgroundJobExecutionMode} */
34
+ const LEGACY_FORKED_EXECUTION_MODE = "forked"
35
+ // Pooled jobs persist with the legacy-safe `forked` mode so a pre-pool main can
36
+ // deserialize and run them during a rolling upgrade. Old versions ignore the
37
+ // nullable handoff id on queued rows, making it a backward-compatible marker.
38
+ const POOLED_HANDOFF_ID_PREFIX = "velocious-pooled:"
39
+ const POOLED_QUEUED_HANDOFF_ID = `${POOLED_HANDOFF_ID_PREFIX}queued`
23
40
  const DEFAULT_QUEUE = "default"
24
41
  // Queue-derived durable concurrency keys are namespaced so they can't collide
25
42
  // with explicit caller-supplied concurrencyKeys.
@@ -138,7 +155,7 @@ export default class BackgroundJobsStore {
138
155
  job_name: jobName,
139
156
  args_json: argsJson,
140
157
  forked: executionMode !== "inline",
141
- execution_mode: executionMode,
158
+ execution_mode: executionMode === "pooled" ? LEGACY_FORKED_EXECUTION_MODE : executionMode,
142
159
  queue,
143
160
  max_retries: maxRetries,
144
161
  attempts: 0,
@@ -146,7 +163,8 @@ export default class BackgroundJobsStore {
146
163
  scheduled_at_ms: scheduledAtMs,
147
164
  created_at_ms: now,
148
165
  concurrency_key: concurrency?.concurrencyKey || null,
149
- max_concurrency: concurrency?.maxConcurrency || null
166
+ max_concurrency: concurrency?.maxConcurrency || null,
167
+ handoff_id: executionMode === "pooled" ? POOLED_QUEUED_HANDOFF_ID : null
150
168
  }
151
169
  })
152
170
  })
@@ -221,11 +239,7 @@ export default class BackgroundJobsStore {
221
239
  if (typeof forked === "boolean") {
222
240
  query = query.where({forked})
223
241
  }
224
- if (executionMode) {
225
- const executionModes = Array.isArray(executionMode) ? executionMode : [executionMode]
226
-
227
- query = query.where({execution_mode: executionModes})
228
- }
242
+ if (executionMode) query = this._whereExecutionMode({db, executionMode, query})
229
243
 
230
244
  if (scheduledAtOperator === "<=") {
231
245
  const priorityOrder = this._queuePriorityOrderSql(db)
@@ -401,12 +415,14 @@ export default class BackgroundJobsStore {
401
415
  await this.ensureReady()
402
416
 
403
417
  const handedOffAtMs = Date.now()
404
- const handoffId = randomUUID()
405
418
 
406
419
  return await this._withDb(async (db) => await this._transactionResult(db, async () => {
407
420
  const queuedJob = await this._getJobRowById(db, jobId)
408
421
  if (!queuedJob || queuedJob.status !== "queued") return null
409
422
  if (queuedJob.concurrencyKey && !(await this._reserveConcurrency(db, queuedJob.concurrencyKey))) return null
423
+ const handoffId = queuedJob.executionMode === "pooled"
424
+ ? `${POOLED_HANDOFF_ID_PREFIX}${randomUUID()}`
425
+ : randomUUID()
410
426
 
411
427
  const affectedRows = await this._updateAffectedRows(db, {
412
428
  tableName: JOBS_TABLE,
@@ -480,7 +496,7 @@ export default class BackgroundJobsStore {
480
496
  status: "queued",
481
497
  scheduled_at_ms: Date.now(),
482
498
  handed_off_at_ms: null,
483
- handoff_id: null,
499
+ handoff_id: job.executionMode === "pooled" ? POOLED_QUEUED_HANDOFF_ID : null,
484
500
  worker_id: null
485
501
  },
486
502
  conditions: {handoff_id: handoffId, id: jobId, status: "handed_off"}
@@ -990,7 +1006,7 @@ export default class BackgroundJobsStore {
990
1006
  const executionModeColumnSql = db.quoteColumn("execution_mode")
991
1007
 
992
1008
  await db.query(
993
- `UPDATE ${tableNameSql} SET ${executionModeColumnSql} = ${db.quote(DEFAULT_EXECUTION_MODE)} ` +
1009
+ `UPDATE ${tableNameSql} SET ${executionModeColumnSql} = ${db.quote(LEGACY_FORKED_EXECUTION_MODE)} ` +
994
1010
  `WHERE ${forkedColumnSql} = ${db.quote(true)} AND ${executionModeColumnSql} IS NULL`
995
1011
  )
996
1012
  await db.query(
@@ -1081,6 +1097,7 @@ export default class BackgroundJobsStore {
1081
1097
  scheduledAt,
1082
1098
  shouldRetry
1083
1099
  })
1100
+ if (shouldRetry && job.executionMode === "pooled") update.handoff_id = POOLED_QUEUED_HANDOFF_ID
1084
1101
 
1085
1102
  const affectedRows = await this._updateAffectedRows(db, {
1086
1103
  tableName: JOBS_TABLE,
@@ -1189,8 +1206,10 @@ export default class BackgroundJobsStore {
1189
1206
  * @returns {import("./types.js").BackgroundJobRow} - Normalized job row.
1190
1207
  */
1191
1208
  _normalizeJobRow(row) {
1192
- const executionMode = row.execution_mode
1193
- ? this._normalizeExecutionModeName(String(row.execution_mode))
1209
+ const persistedExecutionMode = row.execution_mode ? String(row.execution_mode) : null
1210
+ const handoffId = row.handoff_id ? String(row.handoff_id) : null
1211
+ const executionMode = persistedExecutionMode
1212
+ ? this._normalizePersistedExecutionMode({executionMode: persistedExecutionMode, handoffId})
1194
1213
  : this._normalizeExecutionMode({forked: this._normalizeBoolean(row.forked)})
1195
1214
 
1196
1215
  return {
@@ -1206,7 +1225,7 @@ export default class BackgroundJobsStore {
1206
1225
  scheduledAtMs: this._normalizeNumber(row.scheduled_at_ms),
1207
1226
  createdAtMs: this._normalizeNumber(row.created_at_ms),
1208
1227
  handedOffAtMs: this._normalizeNumber(row.handed_off_at_ms),
1209
- handoffId: row.handoff_id ? String(row.handoff_id) : null,
1228
+ handoffId,
1210
1229
  completedAtMs: this._normalizeNumber(row.completed_at_ms),
1211
1230
  failedAtMs: this._normalizeNumber(row.failed_at_ms),
1212
1231
  orphanedAtMs: this._normalizeNumber(row.orphaned_at_ms),
@@ -1506,7 +1525,11 @@ export default class BackgroundJobsStore {
1506
1525
  if (executionMode) {
1507
1526
  return this._normalizeExecutionModeName(executionMode)
1508
1527
  }
1528
+ // The legacy `forked` flag stays authoritative when supplied: `false` is
1529
+ // inline and `true` is forked (never the pooled default). Only an enqueue
1530
+ // that names neither option falls through to the pooled default.
1509
1531
  if (options?.forked === false) return "inline"
1532
+ if (options?.forked === true) return LEGACY_FORKED_EXECUTION_MODE
1510
1533
 
1511
1534
  return DEFAULT_EXECUTION_MODE
1512
1535
  }
@@ -1524,6 +1547,47 @@ export default class BackgroundJobsStore {
1524
1547
  throw new Error(`Invalid background job executionMode: ${executionMode}`)
1525
1548
  }
1526
1549
 
1550
+ /**
1551
+ * Normalizes a legacy-safe persisted execution mode.
1552
+ * @param {object} args - Options.
1553
+ * @param {string} args.executionMode - Persisted legacy execution mode.
1554
+ * @param {string | null} args.handoffId - Persisted handoff id or pooled marker.
1555
+ * @returns {import("./types.js").BackgroundJobExecutionMode} - Runtime execution mode.
1556
+ */
1557
+ _normalizePersistedExecutionMode({executionMode, handoffId}) {
1558
+ if (executionMode === LEGACY_FORKED_EXECUTION_MODE && handoffId?.startsWith(POOLED_HANDOFF_ID_PREFIX)) return "pooled"
1559
+
1560
+ return this._normalizeExecutionModeName(executionMode)
1561
+ }
1562
+
1563
+ /**
1564
+ * Filters persisted legacy-safe execution modes.
1565
+ * @param {object} args - Options.
1566
+ * @param {import("../database/drivers/base.js").default} args.db - Database connection.
1567
+ * @param {import("./types.js").BackgroundJobExecutionMode | import("./types.js").BackgroundJobExecutionMode[]} args.executionMode - Runtime modes.
1568
+ * @param {import("../database/query/index.js").default} args.query - Query to filter.
1569
+ * @returns {import("../database/query/index.js").default} - Filtered query.
1570
+ */
1571
+ _whereExecutionMode({db, executionMode, query}) {
1572
+ const executionModes = Array.isArray(executionMode) ? executionMode : [executionMode]
1573
+ /** @type {string[]} */
1574
+ const conditions = []
1575
+ const executionModeColumn = db.quoteColumn("execution_mode")
1576
+ const handoffIdColumn = db.quoteColumn("handoff_id")
1577
+
1578
+ for (const mode of executionModes) {
1579
+ if (mode === "pooled") {
1580
+ conditions.push(`(${executionModeColumn} = ${db.quote(LEGACY_FORKED_EXECUTION_MODE)} AND ${handoffIdColumn} = ${db.quote(POOLED_QUEUED_HANDOFF_ID)})`)
1581
+ } else if (mode === LEGACY_FORKED_EXECUTION_MODE) {
1582
+ conditions.push(`(${executionModeColumn} = ${db.quote(mode)} AND (${handoffIdColumn} IS NULL OR ${handoffIdColumn} <> ${db.quote(POOLED_QUEUED_HANDOFF_ID)}))`)
1583
+ } else {
1584
+ conditions.push(`${executionModeColumn} = ${db.quote(mode)}`)
1585
+ }
1586
+ }
1587
+
1588
+ return query.where(`(${conditions.join(" OR ")})`)
1589
+ }
1590
+
1527
1591
  /**
1528
1592
  * Runs parse args.
1529
1593
  * @param {?} value - Input value.
@@ -1,7 +1,7 @@
1
1
  // @ts-check
2
2
 
3
3
  /**
4
- * @typedef {"inline" | "forked" | "spawned"} BackgroundJobExecutionMode
4
+ * @typedef {"inline" | "forked" | "pooled" | "spawned"} BackgroundJobExecutionMode
5
5
  */
6
6
  /**
7
7
  * @typedef {object} BackgroundJobHandoff
@@ -10,8 +10,8 @@
10
10
  */
11
11
  /**
12
12
  * @typedef {object} BackgroundJobOptions
13
- * @property {BackgroundJobExecutionMode} [executionMode] - How the job should run. Defaults to `"forked"`.
14
- * @property {boolean} [forked] - Compatibility alias: `false` maps to `"inline"` and `true` maps to `"forked"`.
13
+ * @property {BackgroundJobExecutionMode} [executionMode] - How the job should run. Defaults to `"pooled"` (a warm, reused local runner process). `"forked"` runs the job in a fresh `child_process.fork()` child, `"spawned"` in a detached CLI runner, and `"inline"` inside the worker process.
14
+ * @property {boolean} [forked] - Compatibility alias: `false` maps to `"inline"` and `true` maps to `"forked"`. Omitting both `forked` and `executionMode` uses the default `"pooled"` mode.
15
15
  * @property {number} [maxRetries] - Max retries for a failed job before it is marked failed.
16
16
  * @property {string} [queue] - Queue name. Defaults to `"default"`. When the queue has a configured cap in `backgroundJobs.queues`, that cap is enforced cluster-wide.
17
17
  * @property {string} [concurrencyKey] - Opaque non-empty key used to share a concurrency cap. Overrides any queue-derived cap.
@@ -67,8 +67,8 @@
67
67
  * @typedef {"worker" | "client" | "reporter"} BackgroundJobSocketRole
68
68
  */
69
69
  /**
70
- * @typedef {{type: "hello", role: BackgroundJobSocketRole, supportsHandoffIdReporting?: boolean, supportsHeartbeat?: boolean, workerId?: string}} BackgroundJobHelloMessage
71
- * @typedef {{type: "ready", acceptsForked?: boolean, acceptsInline?: boolean, acceptsSpawned?: boolean}} BackgroundJobReadyMessage
70
+ * @typedef {{type: "hello", role: BackgroundJobSocketRole, supportsHandoffIdReporting?: boolean, supportsHeartbeat?: boolean, supportsPooled?: boolean, workerId?: string}} BackgroundJobHelloMessage
71
+ * @typedef {{type: "ready", acceptsForked?: boolean, acceptsInline?: boolean, acceptsPooled?: boolean, acceptsSpawned?: boolean}} BackgroundJobReadyMessage
72
72
  * @typedef {{type: "draining"}} BackgroundJobDrainingMessage
73
73
  * @typedef {{type: "heartbeat", workerId?: string}} BackgroundJobHeartbeatMessage
74
74
  * @typedef {{type: "enqueue", jobName: string, args?: Array<?>, options?: BackgroundJobOptions}} BackgroundJobEnqueueMessage
@@ -19,6 +19,7 @@ const FORKED_CHILD_SIGKILL_GRACE_MS = 5000
19
19
  */
20
20
  const MAX_FORKED_JOB_TIMEOUT_MS = 2_147_483_647
21
21
  const FORKED_RUNNER_ENTRY_PATH = fileURLToPath(new URL("./forked-runner-child.js", import.meta.url))
22
+ const POOLED_RUNNER_ENTRY_PATH = fileURLToPath(new URL("./pooled-runner-child.js", import.meta.url))
22
23
  /** How often the worker sends a liveness heartbeat to the main. */
23
24
  const HEARTBEAT_INTERVAL_MS = 15000
24
25
  /** TCP keepalive so a half-open connection to the main surfaces as a close. */
@@ -26,7 +27,25 @@ const SOCKET_KEEPALIVE_MS = 10000
26
27
  /**
27
28
  * Execution modes.
28
29
  * @type {import("./types.js").BackgroundJobExecutionMode[]} */
29
- const EXECUTION_MODES = ["inline", "forked", "spawned"]
30
+ const EXECUTION_MODES = ["inline", "forked", "pooled", "spawned"]
31
+
32
+ /**
33
+ * Normalizes a candidate pooled-runner count or job limit.
34
+ * @param {number | undefined} value - Candidate positive integer.
35
+ * @returns {number | undefined} - Normalized value.
36
+ */
37
+ function positiveInteger(value) {
38
+ return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : undefined
39
+ }
40
+
41
+ /**
42
+ * Normalizes a candidate pooled-runner resource limit.
43
+ * @param {number | undefined} value - Candidate positive number.
44
+ * @returns {number | undefined} - Normalized value.
45
+ */
46
+ function positiveNumber(value) {
47
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined
48
+ }
30
49
 
31
50
  /**
32
51
  * Per-forked-child timeout bookkeeping.
@@ -46,11 +65,15 @@ export default class BackgroundJobsWorker {
46
65
  * @param {number} [args.port] - Port.
47
66
  * @param {number} [args.maxConcurrentForkedJobs] - Override the process runner concurrency cap from `configuration.getBackgroundJobsConfig()`.
48
67
  * @param {number} [args.maxConcurrentInlineJobs] - Override the inline-job concurrency cap from `configuration.getBackgroundJobsConfig()`.
68
+ * @param {number} [args.pooledRunnerCount] - Override the pooled runner count.
69
+ * @param {number} [args.pooledRunnerMaxJobs] - Override the per-runner recycle job count.
70
+ * @param {number} [args.pooledRunnerMaxRssBytes] - Override the per-runner recycle RSS limit.
71
+ * @param {number} [args.pooledRunnerMaxLifetimeMs] - Override the per-runner recycle lifetime.
49
72
  * @param {number} [args.forkedChildSigkillGraceMs] - Override the grace period between SIGTERM and SIGKILL when reaping lingering process runners on stop.
50
73
  * @param {number} [args.heartbeatIntervalMs] - Override the liveness heartbeat interval (default 15000ms).
51
74
  * @param {number} [args.jobTimeoutMs] - Override the forked-job wall-clock timeout from `configuration.getBackgroundJobsConfig()`. `0` disables it.
52
75
  */
53
- constructor({configuration, host, port, maxConcurrentForkedJobs, maxConcurrentInlineJobs, forkedChildSigkillGraceMs, heartbeatIntervalMs, jobTimeoutMs} = {}) {
76
+ constructor({configuration, host, port, maxConcurrentForkedJobs, maxConcurrentInlineJobs, pooledRunnerCount, pooledRunnerMaxJobs, pooledRunnerMaxRssBytes, pooledRunnerMaxLifetimeMs, forkedChildSigkillGraceMs, heartbeatIntervalMs, jobTimeoutMs} = {}) {
54
77
  /**
55
78
  * Narrows the runtime value to the documented type.
56
79
  * @type {Promise<import("../configuration.js").default>} */
@@ -86,6 +109,14 @@ export default class BackgroundJobsWorker {
86
109
  * Narrows the runtime value to the documented type.
87
110
  * @type {number} */
88
111
  this.maxConcurrentForkedJobs = this.maxConcurrentForkedJobsOverride || 4
112
+ this.pooledRunnerCountOverride = positiveInteger(pooledRunnerCount)
113
+ this.pooledRunnerMaxJobsOverride = positiveInteger(pooledRunnerMaxJobs)
114
+ this.pooledRunnerMaxRssBytesOverride = positiveNumber(pooledRunnerMaxRssBytes)
115
+ this.pooledRunnerMaxLifetimeMsOverride = positiveNumber(pooledRunnerMaxLifetimeMs)
116
+ this.pooledRunnerCount = this.pooledRunnerCountOverride || 4
117
+ this.pooledRunnerMaxJobs = this.pooledRunnerMaxJobsOverride || 100
118
+ this.pooledRunnerMaxRssBytes = this.pooledRunnerMaxRssBytesOverride || 512 * 1024 * 1024
119
+ this.pooledRunnerMaxLifetimeMs = this.pooledRunnerMaxLifetimeMsOverride || 60 * 60 * 1000
89
120
  /**
90
121
  * Grace period between SIGTERM and SIGKILL when reaping process runners that
91
122
  * outlast a bounded shutdown drain.
@@ -148,6 +179,12 @@ export default class BackgroundJobsWorker {
148
179
  * @type {Set<import("node:child_process").ChildProcess>}
149
180
  */
150
181
  this.inflightProcessChildren = new Set()
182
+ /** @type {Set<Promise<void>>} */
183
+ this.inflightPooledJobs = new Set()
184
+ /** @type {Set<import("node:child_process").ChildProcess>} */
185
+ 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}>} */
187
+ this.pooledChildStates = new Map()
151
188
  }
152
189
 
153
190
  /**
@@ -171,6 +208,11 @@ export default class BackgroundJobsWorker {
171
208
 
172
209
  this.maxConcurrentForkedJobs = config.maxConcurrentForkedJobs || this.maxConcurrentForkedJobs
173
210
  }
211
+ const poolConfig = this.configuration.getBackgroundJobsConfig()
212
+ if (typeof this.pooledRunnerCountOverride !== "number") this.pooledRunnerCount = poolConfig.pooledRunnerCount
213
+ if (typeof this.pooledRunnerMaxJobsOverride !== "number") this.pooledRunnerMaxJobs = poolConfig.pooledRunnerMaxJobs
214
+ if (typeof this.pooledRunnerMaxRssBytesOverride !== "number") this.pooledRunnerMaxRssBytes = poolConfig.pooledRunnerMaxRssBytes
215
+ if (typeof this.pooledRunnerMaxLifetimeMsOverride !== "number") this.pooledRunnerMaxLifetimeMs = poolConfig.pooledRunnerMaxLifetimeMs
174
216
 
175
217
  this.statusReporter = new BackgroundJobsStatusReporter({
176
218
  configuration: this.configuration,
@@ -211,6 +253,7 @@ export default class BackgroundJobsWorker {
211
253
  }
212
254
 
213
255
  await this._drainInflight(this.inflightInlineJobs, timeoutMs)
256
+ await this._drainInflight(this.inflightPooledJobs, timeoutMs)
214
257
  await this._drainInflight(this.inflightProcessJobs, timeoutMs)
215
258
  await this._terminateProcessChildren()
216
259
  // Give in-flight result reports (now decoupled from job slots) a bounded
@@ -311,7 +354,7 @@ export default class BackgroundJobsWorker {
311
354
  })
312
355
 
313
356
  socket.on("connect", () => {
314
- jsonSocket.send({type: "hello", role: "worker", supportsHandoffIdReporting: true, supportsHeartbeat: true, workerId: this.workerId})
357
+ jsonSocket.send({type: "hello", role: "worker", supportsHandoffIdReporting: true, supportsHeartbeat: true, supportsPooled: true, workerId: this.workerId})
315
358
  this._sendReadyIfRunning()
316
359
  this._startHeartbeat()
317
360
  })
@@ -364,6 +407,11 @@ export default class BackgroundJobsWorker {
364
407
 
365
408
  const executionMode = this._executionModeForPayload(identifiedPayload)
366
409
 
410
+ if (executionMode === "pooled") {
411
+ this._trackPooledJob(this._runPooledJob(identifiedPayload))
412
+ return
413
+ }
414
+
367
415
  if (executionMode !== "inline") {
368
416
  this._trackProcessJob(this._startProcessJob({executionMode, payload: identifiedPayload}))
369
417
  return
@@ -433,7 +481,9 @@ export default class BackgroundJobsWorker {
433
481
 
434
482
  if (executionMode) return this._normalizeExecutionMode(executionMode)
435
483
 
436
- return options.forked === false ? "inline" : "forked"
484
+ if (options.forked === false) return "inline"
485
+ if (options.forked === true) return "forked"
486
+ return "pooled"
437
487
  }
438
488
 
439
489
  /**
@@ -532,17 +582,146 @@ export default class BackgroundJobsWorker {
532
582
  _readyMessage() {
533
583
  const acceptsProcessJob = this.inflightProcessJobs.size < this.maxConcurrentForkedJobs
534
584
  const acceptsInline = this.inflightInlineJobs.size < this.maxConcurrentInlineJobs
585
+ const acceptsPooled = this.inflightPooledJobs.size < this.pooledRunnerCount
535
586
 
536
- if (!acceptsProcessJob && !acceptsInline) return null
587
+ if (!acceptsProcessJob && !acceptsInline && !acceptsPooled) return null
537
588
 
538
589
  return {
539
590
  type: "ready",
540
591
  acceptsForked: acceptsProcessJob,
541
592
  acceptsInline,
593
+ acceptsPooled,
542
594
  acceptsSpawned: acceptsProcessJob
543
595
  }
544
596
  }
545
597
 
598
+ /**
599
+ * Tracks a pooled job and re-advertises capacity.
600
+ * @param {Promise<void>} pooledJob - Pooled job promise.
601
+ * @returns {void}
602
+ */
603
+ _trackPooledJob(pooledJob) {
604
+ /** @type {Promise<void>} */
605
+ let inflight
606
+ inflight = pooledJob.finally(() => {
607
+ this.inflightPooledJobs.delete(inflight)
608
+ if (!this.shouldStop) this._sendReadyIfRunning()
609
+ })
610
+ this.inflightPooledJobs.add(inflight)
611
+ this._sendReadyIfRunning()
612
+ }
613
+
614
+ /**
615
+ * Runs a payload in an idle pooled child.
616
+ * @param {import("./types.js").BackgroundJobPayload & {id: string}} payload - Job payload.
617
+ * @returns {Promise<void>} - Resolves after the durable report.
618
+ */
619
+ _runPooledJob(payload) {
620
+ let child = [...this.pooledChildren].find((candidate) => !this.pooledChildStates.get(candidate)?.payload)
621
+ if (!child) child = this._createPooledChild()
622
+ const state = this.pooledChildStates.get(child)
623
+ if (!state) throw new Error("Pooled runner state missing")
624
+
625
+ return new Promise((resolve) => {
626
+ state.payload = payload
627
+ state.resolve = resolve
628
+ try {
629
+ child.send({type: "job", payload})
630
+ } catch (error) {
631
+ this._handlePooledChildFailure({child, error})
632
+ }
633
+ })
634
+ }
635
+
636
+ /**
637
+ * Creates a reusable pooled child.
638
+ * @returns {import("node:child_process").ChildProcess} - New pooled child.
639
+ */
640
+ _createPooledChild() {
641
+ const configuration = this.configuration
642
+ if (!configuration) throw new Error("Background jobs worker configuration not initialized")
643
+ const config = configuration.getBackgroundJobsConfig()
644
+ const child = fork(POOLED_RUNNER_ENTRY_PATH, [], {
645
+ cwd: configuration.getDirectory(), execArgv: [], stdio: ["ignore", "ignore", "ignore", "ipc"],
646
+ env: Object.assign({}, process.env, {VELOCIOUS_ENV: configuration.getEnvironment(), VELOCIOUS_BACKGROUND_JOBS_HOST: config.host, VELOCIOUS_BACKGROUND_JOBS_PORT: `${config.port}`})
647
+ })
648
+ this.pooledChildren.add(child)
649
+ this.inflightProcessChildren.add(child)
650
+ this.pooledChildStates.set(child, {createdAtMs: Date.now(), jobsRun: 0})
651
+ child.on("message", (message) => this._handlePooledChildMessage({child, message}))
652
+ child.once("exit", (code, signal) => this._handlePooledChildFailure({child, error: new Error(`Pooled background job runner exited: code=${code} signal=${signal || "none"}`)}))
653
+ child.once("error", (error) => this._handlePooledChildFailure({child, error}))
654
+ return child
655
+ }
656
+
657
+ /**
658
+ * Handles a pooled child's durable-report acknowledgement.
659
+ * @param {object} args - Message details.
660
+ * @param {import("node:child_process").ChildProcess} args.child - Pooled child.
661
+ * @param {?} args.message - IPC message.
662
+ * @returns {void}
663
+ */
664
+ _handlePooledChildMessage({child, message}) {
665
+ if (!message || typeof message !== "object") return
666
+ const record = /** @type {{type?: ?, jobId?: ?, acknowledged?: ?, rssBytes?: ?, error?: ?}} */ (message)
667
+ 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
676
+ state.jobsRun += 1
677
+ if (resolve) resolve(undefined)
678
+ const rssBytes = typeof record.rssBytes === "number" ? record.rssBytes : Number.POSITIVE_INFINITY
679
+ const runnerAgeMs = Date.now() - state.createdAtMs
680
+ if (state.jobsRun >= this.pooledRunnerMaxJobs || rssBytes >= this.pooledRunnerMaxRssBytes || runnerAgeMs >= this.pooledRunnerMaxLifetimeMs || this.shouldStop) this._retirePooledChild(child)
681
+ }
682
+
683
+ /**
684
+ * Retires an idle pooled child.
685
+ * @param {import("node:child_process").ChildProcess} child - Child process to retire.
686
+ * @returns {void}
687
+ */
688
+ _retirePooledChild(child) {
689
+ this.pooledChildren.delete(child)
690
+ this.pooledChildStates.delete(child)
691
+ this.inflightProcessChildren.delete(child)
692
+ child.kill("SIGTERM")
693
+ }
694
+
695
+ /**
696
+ * Removes and reports an unhealthy pooled child.
697
+ * @param {object} args - Failure details.
698
+ * @param {import("node:child_process").ChildProcess} args.child - Pooled child.
699
+ * @param {?} args.error - Failure.
700
+ * @returns {Promise<void>}
701
+ */
702
+ async _handlePooledChildFailure({child, error}) {
703
+ const state = this.pooledChildStates.get(child)
704
+ if (state?.settling) return
705
+ if (state) state.settling = true
706
+ this.pooledChildren.delete(child)
707
+ 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
+ })
721
+ this.pooledChildStates.delete(child)
722
+ if (state.resolve) state.resolve(undefined)
723
+ }
724
+
546
725
  /**
547
726
  * Runs run job inline.
548
727
  * @param {import("./types.js").BackgroundJobPayload} payload - Payload.
@@ -164,6 +164,10 @@
164
164
  * allowed to keep in flight. Default: `4`. This is a per-worker safety cap;
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
+ * @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} [pooledRunnerMaxJobs] - Number of sequential jobs a pooled child runs before it is replaced, bounding process-level resource accumulation. Default: `100`.
169
+ * @property {number} [pooledRunnerMaxRssBytes] - RSS bytes after an acknowledged job at which a pooled child is replaced. Default: `536870912` (512 MiB).
170
+ * @property {number} [pooledRunnerMaxLifetimeMs] - Age after an acknowledged job at which a pooled child is replaced. Default: `3600000` (one hour).
167
171
  * @property {Record<string, {maxConcurrent?: number, priority?: number}>} [queues] - Per-queue
168
172
  * concurrency caps and dispatch priorities, Sidekiq-style. A job declares its queue (static
169
173
  * `queue` on the job class, or the `queue` enqueue option; defaults to `"default"`), and
@@ -1268,12 +1268,20 @@ export default class VelociousConfiguration {
1268
1268
  const envDatabaseIdentifier = process.env.VELOCIOUS_BACKGROUND_JOBS_DATABASE_IDENTIFIER
1269
1269
  const envMaxConcurrentForkedRaw = process.env.VELOCIOUS_BACKGROUND_JOBS_MAX_CONCURRENT_FORKED_JOBS
1270
1270
  const envMaxConcurrentRaw = process.env.VELOCIOUS_BACKGROUND_JOBS_MAX_CONCURRENT_INLINE_JOBS
1271
+ const envPooledRunnerCountRaw = process.env.VELOCIOUS_BACKGROUND_JOBS_POOLED_RUNNER_COUNT
1272
+ const envPooledRunnerMaxJobsRaw = process.env.VELOCIOUS_BACKGROUND_JOBS_POOLED_RUNNER_MAX_JOBS
1273
+ const envPooledRunnerMaxRssBytesRaw = process.env.VELOCIOUS_BACKGROUND_JOBS_POOLED_RUNNER_MAX_RSS_BYTES
1274
+ const envPooledRunnerMaxLifetimeMsRaw = process.env.VELOCIOUS_BACKGROUND_JOBS_POOLED_RUNNER_MAX_LIFETIME_MS
1271
1275
  const envDispatchStrategy = process.env.VELOCIOUS_BACKGROUND_JOBS_DISPATCH_STRATEGY
1272
1276
  const envPollIntervalRaw = process.env.VELOCIOUS_BACKGROUND_JOBS_POLL_INTERVAL_MS
1273
1277
  const envJobTimeoutRaw = process.env.VELOCIOUS_BACKGROUND_JOBS_JOB_TIMEOUT_MS
1274
1278
  const envPort = envPortRaw ? Number(envPortRaw) : undefined
1275
1279
  const envMaxConcurrentForked = envMaxConcurrentForkedRaw ? Number(envMaxConcurrentForkedRaw) : undefined
1276
1280
  const envMaxConcurrent = envMaxConcurrentRaw ? Number(envMaxConcurrentRaw) : undefined
1281
+ const envPooledRunnerCount = envPooledRunnerCountRaw ? Number(envPooledRunnerCountRaw) : undefined
1282
+ const envPooledRunnerMaxJobs = envPooledRunnerMaxJobsRaw ? Number(envPooledRunnerMaxJobsRaw) : undefined
1283
+ const envPooledRunnerMaxRssBytes = envPooledRunnerMaxRssBytesRaw ? Number(envPooledRunnerMaxRssBytesRaw) : undefined
1284
+ const envPooledRunnerMaxLifetimeMs = envPooledRunnerMaxLifetimeMsRaw ? Number(envPooledRunnerMaxLifetimeMsRaw) : undefined
1277
1285
  const envPollInterval = envPollIntervalRaw ? Number(envPollIntervalRaw) : undefined
1278
1286
  const envJobTimeout = envJobTimeoutRaw ? Number(envJobTimeoutRaw) : undefined
1279
1287
  const configured = this._backgroundJobs || {}
@@ -1288,6 +1296,18 @@ export default class VelociousConfiguration {
1288
1296
  const maxConcurrentForkedJobs = typeof configured.maxConcurrentForkedJobs === "number" && configured.maxConcurrentForkedJobs >= 1
1289
1297
  ? configured.maxConcurrentForkedJobs
1290
1298
  : (typeof envMaxConcurrentForked === "number" && Number.isFinite(envMaxConcurrentForked) && envMaxConcurrentForked >= 1 ? envMaxConcurrentForked : 4)
1299
+ const pooledRunnerCount = typeof configured.pooledRunnerCount === "number" && Number.isFinite(configured.pooledRunnerCount) && Number.isInteger(configured.pooledRunnerCount) && configured.pooledRunnerCount >= 1
1300
+ ? configured.pooledRunnerCount
1301
+ : (!("pooledRunnerCount" in configured) && typeof envPooledRunnerCount === "number" && Number.isFinite(envPooledRunnerCount) && Number.isInteger(envPooledRunnerCount) && envPooledRunnerCount >= 1 ? envPooledRunnerCount : 4)
1302
+ const pooledRunnerMaxJobs = typeof configured.pooledRunnerMaxJobs === "number" && Number.isFinite(configured.pooledRunnerMaxJobs) && Number.isInteger(configured.pooledRunnerMaxJobs) && configured.pooledRunnerMaxJobs >= 1
1303
+ ? configured.pooledRunnerMaxJobs
1304
+ : (!("pooledRunnerMaxJobs" in configured) && typeof envPooledRunnerMaxJobs === "number" && Number.isFinite(envPooledRunnerMaxJobs) && Number.isInteger(envPooledRunnerMaxJobs) && envPooledRunnerMaxJobs >= 1 ? envPooledRunnerMaxJobs : 100)
1305
+ const pooledRunnerMaxRssBytes = typeof configured.pooledRunnerMaxRssBytes === "number" && Number.isFinite(configured.pooledRunnerMaxRssBytes) && configured.pooledRunnerMaxRssBytes >= 1
1306
+ ? configured.pooledRunnerMaxRssBytes
1307
+ : (!("pooledRunnerMaxRssBytes" in configured) && typeof envPooledRunnerMaxRssBytes === "number" && Number.isFinite(envPooledRunnerMaxRssBytes) && envPooledRunnerMaxRssBytes >= 1 ? envPooledRunnerMaxRssBytes : 512 * 1024 * 1024)
1308
+ const pooledRunnerMaxLifetimeMs = typeof configured.pooledRunnerMaxLifetimeMs === "number" && Number.isFinite(configured.pooledRunnerMaxLifetimeMs) && configured.pooledRunnerMaxLifetimeMs >= 1
1309
+ ? configured.pooledRunnerMaxLifetimeMs
1310
+ : (!("pooledRunnerMaxLifetimeMs" in configured) && typeof envPooledRunnerMaxLifetimeMs === "number" && Number.isFinite(envPooledRunnerMaxLifetimeMs) && envPooledRunnerMaxLifetimeMs >= 1 ? envPooledRunnerMaxLifetimeMs : 60 * 60 * 1000)
1291
1311
  const dispatchStrategyRaw = configured.dispatchStrategy || envDispatchStrategy
1292
1312
  const dispatchStrategy = dispatchStrategyRaw === "polling" ? "polling" : "beacon"
1293
1313
  const pollIntervalMs = typeof configured.pollIntervalMs === "number" && configured.pollIntervalMs >= 1
@@ -1316,7 +1336,7 @@ export default class VelociousConfiguration {
1316
1336
  : 60 * 60 * 1000
1317
1337
  }
1318
1338
 
1319
- return {host, port, databaseIdentifier, maxConcurrentForkedJobs, maxConcurrentInlineJobs, dispatchStrategy, pollIntervalMs, queues, jobTimeoutMs, retention}
1339
+ return {host, port, databaseIdentifier, maxConcurrentForkedJobs, maxConcurrentInlineJobs, pooledRunnerCount, pooledRunnerMaxJobs, pooledRunnerMaxRssBytes, pooledRunnerMaxLifetimeMs, dispatchStrategy, pollIntervalMs, queues, jobTimeoutMs, retention}
1320
1340
  }
1321
1341
 
1322
1342
  /**