velocious 1.0.527 → 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 +13 -2
  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 +63 -6
  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 +81 -16
  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 +60 -6
  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 +83 -16
  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 +63 -6
  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 +81 -16
  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
package/README.md CHANGED
@@ -25,6 +25,7 @@
25
25
  * Cross-process broadcast bus for `broadcastToChannel` via `velocious beacon`, including background job runner processes (see [docs/beacon.md](docs/beacon.md))
26
26
  * Configurable HTTP server worker handlers plus backpressured, descriptor-only file responses with completion callbacks (see [docs/http-server.md](docs/http-server.md))
27
27
  * Background jobs with failure events for production reporting (see [docs/background-jobs.md](docs/background-jobs.md))
28
+ * Durable one-off background-job scheduling with exact epoch timestamps (see [docs/scheduled-background-job-enqueue.md](docs/scheduled-background-job-enqueue.md))
28
29
  * Rails-style request and database query logging (see [docs/logging.md](docs/logging.md))
29
30
  * EJS-backed mailers with delivery, queueing, and payload rendering support (see [docs/mailers.md](docs/mailers.md))
30
31
  * Trusted reverse proxy handling for `request.remoteAddress()` (see [docs/trusted-proxies.md](docs/trusted-proxies.md))
@@ -1975,6 +1976,10 @@ export default new Configuration({
1975
1976
  databaseIdentifier: "default",
1976
1977
  maxConcurrentForkedJobs: 4,
1977
1978
  maxConcurrentInlineJobs: 4,
1979
+ pooledRunnerCount: 4,
1980
+ pooledRunnerMaxJobs: 100,
1981
+ pooledRunnerMaxRssBytes: 536870912,
1982
+ pooledRunnerMaxLifetimeMs: 3600000,
1978
1983
  dispatchStrategy: "beacon",
1979
1984
  jobTimeoutMs: null
1980
1985
  }
@@ -1989,6 +1994,10 @@ VELOCIOUS_BACKGROUND_JOBS_PORT=7331
1989
1994
  VELOCIOUS_BACKGROUND_JOBS_DATABASE_IDENTIFIER=default
1990
1995
  VELOCIOUS_BACKGROUND_JOBS_MAX_CONCURRENT_FORKED_JOBS=4
1991
1996
  VELOCIOUS_BACKGROUND_JOBS_MAX_CONCURRENT_INLINE_JOBS=4
1997
+ VELOCIOUS_BACKGROUND_JOBS_POOLED_RUNNER_COUNT=4
1998
+ VELOCIOUS_BACKGROUND_JOBS_POOLED_RUNNER_MAX_JOBS=100
1999
+ VELOCIOUS_BACKGROUND_JOBS_POOLED_RUNNER_MAX_RSS_BYTES=536870912
2000
+ VELOCIOUS_BACKGROUND_JOBS_POOLED_RUNNER_MAX_LIFETIME_MS=3600000
1992
2001
  VELOCIOUS_BACKGROUND_JOBS_DISPATCH_STRATEGY=beacon
1993
2002
  VELOCIOUS_BACKGROUND_JOBS_POLL_INTERVAL_MS=1000
1994
2003
  VELOCIOUS_BACKGROUND_JOBS_WORKER_SHUTDOWN_TIMEOUT_MS=indefinite
@@ -1999,6 +2008,8 @@ VELOCIOUS_BACKGROUND_JOBS_JOB_TIMEOUT_MS=5400000
1999
2008
 
2000
2009
  `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.
2001
2010
 
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
+
2002
2013
  `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.
2003
2014
 
2004
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).
@@ -2052,7 +2063,7 @@ await MyJob.performLaterWithOptions({
2052
2063
  })
2053
2064
  ```
2054
2065
 
2055
- Schedule a one-off job for a specific epoch timestamp in milliseconds:
2066
+ [Schedule a one-off job](docs/scheduled-background-job-enqueue.md) for a specific epoch timestamp in milliseconds:
2056
2067
 
2057
2068
  ```js
2058
2069
  await MyJob.performLaterWithOptions({
@@ -2129,7 +2140,7 @@ Cron fields are: `minute hour day-of-month month day-of-week`. Supported syntax:
2129
2140
 
2130
2141
  Each job must define exactly one of `every` or `cron`. Cron times are evaluated in the **server's local timezone**, at minute granularity.
2131
2142
 
2132
- `background-jobs-main` owns the schedule and enqueues the configured jobs into the normal Velocious background-jobs queue. The HTTP server does not run scheduled jobs itself.
2143
+ `background-jobs-main` owns the schedule and enqueues the configured jobs into the normal Velocious background-jobs queue. The HTTP server does not run scheduled jobs itself. Graceful main shutdown waits for scheduled enqueues already in flight before closing database connections, so stopped schedulers cannot write into a later application or test lifecycle.
2133
2144
 
2134
2145
  ## Persistence and retries
2135
2146
 
@@ -1,12 +1,13 @@
1
1
  // @ts-check
2
2
 
3
3
  import runJobPayload from "./job-runner.js"
4
+ import setRunnerProcessTitle from "./runner-process-title.js"
4
5
 
5
6
  // Name the process so `ps`/`top`/`htop` can identify forked job runners at a
6
7
  // glance instead of a wall of generic "node" entries. Updated to the specific
7
8
  // job name once one arrives (see runJobMessage), so operators can see exactly
8
9
  // which jobs are running, how many of each, and which are eating resources.
9
- process.title = "velocious background-jobs-runner"
10
+ setRunnerProcessTitle()
10
11
 
11
12
  let finishing = false
12
13
 
@@ -6,6 +6,17 @@ import BackgroundJobsStatusReporter from "./status-reporter.js"
6
6
 
7
7
  const BEACON_READY_TIMEOUT_MS = 5000
8
8
 
9
+ export class BackgroundJobPerformedFailure extends Error {
10
+ /**
11
+ * Creates a performed-job failure after its terminal report is acknowledged.
12
+ * @param {Error} cause - A job perform error whose failed terminal report was acknowledged.
13
+ */
14
+ constructor(cause) {
15
+ super(cause.message, {cause})
16
+ this.name = "BackgroundJobPerformedFailure"
17
+ }
18
+ }
19
+
9
20
  /**
10
21
  * Runs report beacon ready error.
11
22
  * @param {import("../configuration.js").default} configuration - Configuration.
@@ -96,11 +107,12 @@ export default async function runJobPayload(payload, {closeConnections = true} =
96
107
  await perform.apply(jobInstance, payload.args || [])
97
108
  })
98
109
  } catch (error) {
110
+ const performedError = error instanceof Error ? error : new Error(String(error))
99
111
  if (payload.id) {
100
112
  await reporter.reportWithRetry({
101
113
  jobId: payload.id,
102
114
  status: "failed",
103
- error,
115
+ error: performedError,
104
116
  handoffId: payload.handoffId,
105
117
  workerId: payload.workerId,
106
118
  handedOffAtMs: payload.handedOffAtMs,
@@ -108,7 +120,7 @@ export default async function runJobPayload(payload, {closeConnections = true} =
108
120
  })
109
121
  }
110
122
 
111
- throw error
123
+ throw new BackgroundJobPerformedFailure(performedError)
112
124
  }
113
125
 
114
126
  if (payload.id) {
@@ -26,6 +26,8 @@ export default class JsonSocket extends EventEmitter {
26
26
  * Narrows the runtime value to the documented type.
27
27
  * @type {boolean} */
28
28
  this.acceptsForkedJobs = true
29
+ /** @type {boolean} */
30
+ this.acceptsPooledJobs = false
29
31
  /**
30
32
  * Narrows the runtime value to the documented type.
31
33
  * @type {boolean} */
@@ -6,6 +6,7 @@ import BackgroundJobsScheduler from "./scheduler.js"
6
6
  import BackgroundJobsStore from "./store.js"
7
7
  import Logger from "../logger.js"
8
8
  import PruneTerminalBackgroundJobsJob from "../jobs/prune-terminal-background-jobs.js"
9
+ import VelociousError from "../velocious-error.js"
9
10
 
10
11
  /**
11
12
  * Channel used by `background-jobs-main` to coordinate dispatch wake-ups
@@ -38,6 +39,12 @@ const WORKER_LIVENESS_SWEEP_MS = 15000
38
39
  const WORKER_EXECUTION_MODE_CAPABILITIES = [
39
40
  {executionMode: "inline", accepts: (worker) => worker.acceptsInlineJobs !== false},
40
41
  {executionMode: "forked", accepts: (worker) => worker.acceptsForkedJobs !== false},
42
+ // Pooled is opt-in: only workers that explicitly advertise `acceptsPooled`
43
+ // receive pooled jobs. The `=== true` (rather than `!== false`) check keeps a
44
+ // pre-pooled worker — which never sends the field — out of the pooled-capable
45
+ // set, so the main never dispatches a pooled job to a worker that cannot run
46
+ // one. This is the conservative half of the extended readiness protocol.
47
+ {executionMode: "pooled", accepts: (worker) => worker.acceptsPooledJobs === true},
41
48
  {executionMode: "spawned", accepts: (worker) => worker.acceptsSpawnedJobs !== false}
42
49
  ]
43
50
  const WORKER_EXECUTION_MODE_CAPABILITIES_BY_MODE = new Map(
@@ -80,6 +87,11 @@ export default class BackgroundJobsMain {
80
87
  * Active durable handoffs keyed by the exact worker socket that received them.
81
88
  * @type {Map<JsonSocket, Map<string, string>>} */
82
89
  this.workerHandoffs = new Map()
90
+ /**
91
+ * Handoff-adoption queries started by worker hello messages. Shutdown must
92
+ * wait for these before closing the configuration's database pools.
93
+ * @type {Set<Promise<void>>} */
94
+ this.inflightWorkerHandoffAdoptions = new Set()
83
95
  /**
84
96
  * Narrows the runtime value to the documented type.
85
97
  * @type {net.Server | undefined} */
@@ -208,9 +220,12 @@ export default class BackgroundJobsMain {
208
220
  this._closeWorkers()
209
221
  this._clearTimers()
210
222
  this._disconnectBeaconHandlers()
211
- this.scheduler?.stop()
212
-
213
- await this._stopBeaconAndServer()
223
+ await this.scheduler?.stop()
224
+ try {
225
+ await this._drainWorkerHandoffAdoptions()
226
+ } finally {
227
+ await this._stopBeaconAndServer()
228
+ }
214
229
  }
215
230
 
216
231
  /**
@@ -416,18 +431,45 @@ export default class BackgroundJobsMain {
416
431
  if (message?.type !== "hello") return null
417
432
 
418
433
  if (message.role === "worker") {
434
+ if (this._stopped) {
435
+ jsonSocket.close()
436
+ return message.role
437
+ }
438
+
419
439
  jsonSocket.workerId = message.workerId
420
440
  jsonSocket.supportsHandoffIdReporting = message.supportsHandoffIdReporting === true
421
441
  jsonSocket.supportsHeartbeat = message.supportsHeartbeat === true
422
442
  jsonSocket.lastSeenAt = Date.now()
423
443
  this.workers.add(jsonSocket)
424
444
  this.workerHandoffs.set(jsonSocket, new Map())
425
- void this._adoptWorkerHandoffs(jsonSocket)
445
+ this._trackWorkerHandoffAdoption(jsonSocket)
426
446
  }
427
447
 
428
448
  return message.role
429
449
  }
430
450
 
451
+ /**
452
+ * Tracks a worker handoff-adoption query through shutdown.
453
+ * @param {JsonSocket} jsonSocket - Reconnecting worker socket.
454
+ * @returns {void}
455
+ */
456
+ _trackWorkerHandoffAdoption(jsonSocket) {
457
+ const adoption = this._adoptWorkerHandoffs(jsonSocket)
458
+ this.inflightWorkerHandoffAdoptions.add(adoption)
459
+ const removeAdoption = () => this.inflightWorkerHandoffAdoptions.delete(adoption)
460
+ void adoption.then(removeAdoption, removeAdoption)
461
+ }
462
+
463
+ /**
464
+ * Waits for worker handoff-adoption queries to finish.
465
+ * @returns {Promise<void>} - Resolves when no adoption query remains.
466
+ */
467
+ async _drainWorkerHandoffAdoptions() {
468
+ while (this.inflightWorkerHandoffAdoptions.size > 0) {
469
+ await Promise.all([...this.inflightWorkerHandoffAdoptions])
470
+ }
471
+ }
472
+
431
473
  /**
432
474
  * Adopts a reconnecting worker's still-active `handed_off` jobs into its new
433
475
  * socket's handoff map. A fresh main (e.g. after a deploy restart) holds no
@@ -533,6 +575,7 @@ export default class BackgroundJobsMain {
533
575
  _handleWorkerReady({jsonSocket, message}) {
534
576
  jsonSocket.acceptsSpawnedJobs = message.acceptsSpawned !== false && message.acceptsForked !== false
535
577
  jsonSocket.acceptsForkedJobs = message.acceptsForked !== false
578
+ jsonSocket.acceptsPooledJobs = message.acceptsPooled === true
536
579
  jsonSocket.acceptsInlineJobs = message.acceptsInline !== false
537
580
  if (jsonSocket.supportsHandoffIdReporting) {
538
581
  this.readyWorkers.add(jsonSocket)
@@ -683,7 +726,21 @@ export default class BackgroundJobsMain {
683
726
  this._notifyEnqueued()
684
727
  await this._drain()
685
728
  } catch (error) {
686
- this.logger.error(() => ["Failed to enqueue background job:", error])
729
+ if (error instanceof VelociousError && error.safeToExpose) {
730
+ jsonSocket.send({type: "enqueue-error", error: error.message})
731
+ return
732
+ }
733
+
734
+ const normalizedError = error instanceof Error ? error : new Error(String(error))
735
+ const payload = {
736
+ context: {jobName: message.jobName, stage: "background-job-enqueue"},
737
+ error: normalizedError
738
+ }
739
+ const errorEvents = this.configuration.getErrorEvents()
740
+
741
+ this.logger.error(() => ["Failed to enqueue background job:", normalizedError])
742
+ errorEvents.emit("framework-error", payload)
743
+ errorEvents.emit("all-error", {...payload, errorType: "framework-error"})
687
744
  jsonSocket.send({type: "enqueue-error", error: "Failed to enqueue job"})
688
745
  }
689
746
  }
@@ -1110,7 +1167,7 @@ export default class BackgroundJobsMain {
1110
1167
  const executionModes = this.readyWorkerExecutionModes()
1111
1168
 
1112
1169
  if (executionModes.length === 0) return null
1113
- if (executionModes.length === 3) return await this.store.nextAvailableJob()
1170
+ if (executionModes.length === WORKER_EXECUTION_MODE_CAPABILITIES.length) return await this.store.nextAvailableJob()
1114
1171
 
1115
1172
  return await this.store.nextAvailableJob({executionMode: executionModes})
1116
1173
  }
@@ -0,0 +1,78 @@
1
+ // @ts-check
2
+
3
+ import runJobPayload, { BackgroundJobPerformedFailure } from "./job-runner.js"
4
+ import setRunnerProcessTitle from "./runner-process-title.js"
5
+
6
+ setRunnerProcessTitle()
7
+ let running = false
8
+
9
+ /**
10
+ * Checks whether an IPC value is a runnable pooled job message.
11
+ * @param {?} message - IPC message.
12
+ * @returns {message is {type: "job", payload: import("./types.js").BackgroundJobPayload & {id: string}}} - Whether this is a valid job message.
13
+ */
14
+ function isJobMessage(message) {
15
+ if (!message || typeof message !== "object") return false
16
+ const record = /** @type {{type?: ?, payload?: ?}} */ (message)
17
+
18
+ return record.type === "job" && !!record.payload && typeof record.payload === "object" && typeof record.payload.id === "string"
19
+ }
20
+
21
+ /**
22
+ * Sends the terminal outcome after the main/DB report has been acknowledged or rejected.
23
+ * @param {object} args - Outcome.
24
+ * @param {string} args.jobId - Job id.
25
+ * @param {boolean} args.acknowledged - Whether the terminal report was acknowledged.
26
+ * @param {"completed" | "failed"} [args.status] - Acknowledged terminal status.
27
+ * @param {Error} [args.error] - Reporting error when acknowledgement was not obtained.
28
+ * @returns {Promise<void>} - Resolves after IPC accepts the message.
29
+ */
30
+ function sendOutcome({jobId, acknowledged, status, error}) {
31
+ return new Promise((resolve) => {
32
+ if (!process.send) {
33
+ resolve(undefined)
34
+ return
35
+ }
36
+
37
+ process.send({
38
+ type: "job-outcome",
39
+ jobId,
40
+ acknowledged,
41
+ status,
42
+ rssBytes: process.memoryUsage().rss,
43
+ error: error?.message
44
+ }, () => resolve(undefined))
45
+ })
46
+ }
47
+
48
+ /**
49
+ * Handles one serial job message.
50
+ * @param {?} message - IPC message.
51
+ * @returns {Promise<void>} - Resolves after reporting.
52
+ */
53
+ async function handleMessage(message) {
54
+ if (running || !isJobMessage(message)) return
55
+
56
+ running = true
57
+ try {
58
+ await runJobPayload(message.payload, {closeConnections: false})
59
+ await sendOutcome({jobId: message.payload.id, acknowledged: true, status: "completed"})
60
+ } catch (error) {
61
+ if (error instanceof BackgroundJobPerformedFailure) {
62
+ await sendOutcome({jobId: message.payload.id, acknowledged: true, status: "failed"})
63
+ } else {
64
+ const reportError = error instanceof Error ? error : new Error(String(error))
65
+ 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))
70
+ }
71
+ } finally {
72
+ running = false
73
+ }
74
+ }
75
+
76
+ process.on("message", (message) => { void handleMessage(message) })
77
+ process.once("disconnect", () => process.exit(0))
78
+ for (const signal of ["SIGTERM", "SIGINT"]) process.once(signal, () => process.exit(1))
@@ -0,0 +1,6 @@
1
+ // @ts-check
2
+
3
+ /** Sets the idle base process title shared by child runner entrypoints. */
4
+ export default function setRunnerProcessTitle() {
5
+ process.title = "velocious background-jobs-runner"
6
+ }
@@ -81,6 +81,8 @@ export default class BackgroundJobsScheduler {
81
81
  * Narrows the runtime value to the documented type.
82
82
  * @type {Array<ReturnType<typeof setTimeout>>} */
83
83
  this.timeoutIds = []
84
+ /** @type {Set<Promise<void>>} - Scheduled enqueues that shutdown must drain. */
85
+ this.pendingEnqueues = new Set()
84
86
  /**
85
87
  * Narrows the runtime value to the documented type.
86
88
  * @type {boolean} - True between stop() and the next start(); cron self-rescheduler checks this so a stop() during an in-flight enqueue doesn't immediately re-arm.
@@ -113,8 +115,9 @@ export default class BackgroundJobsScheduler {
113
115
 
114
116
  /**
115
117
  * Runs stop.
116
- * @returns {void} */
117
- stop() {
118
+ * @returns {Promise<void>} Resolves after in-flight scheduled enqueues finish.
119
+ */
120
+ async stop() {
118
121
  this.stopped = true
119
122
 
120
123
  for (const intervalId of this.intervalIds) {
@@ -127,6 +130,8 @@ export default class BackgroundJobsScheduler {
127
130
 
128
131
  this.intervalIds = []
129
132
  this.timeoutIds = []
133
+
134
+ await Promise.all(this.pendingEnqueues)
130
135
  }
131
136
 
132
137
  /**
@@ -176,10 +181,10 @@ export default class BackgroundJobsScheduler {
176
181
  }
177
182
 
178
183
  const timeoutId = setTimeout(() => {
179
- void this.enqueueScheduledJob({jobConfiguration, jobKey})
184
+ void this.runScheduledJob({jobConfiguration, jobKey})
180
185
 
181
186
  const intervalId = setInterval(() => {
182
- void this.enqueueScheduledJob({jobConfiguration, jobKey})
187
+ void this.runScheduledJob({jobConfiguration, jobKey})
183
188
  }, intervalMs)
184
189
 
185
190
  this.intervalIds.push(intervalId)
@@ -214,7 +219,7 @@ export default class BackgroundJobsScheduler {
214
219
  const timeoutId = setTimeout(async () => {
215
220
  if (this.stopped) return
216
221
 
217
- await this.enqueueScheduledJob({jobConfiguration, jobKey})
222
+ await this.runScheduledJob({jobConfiguration, jobKey})
218
223
 
219
224
  // The await above can yield to a stop() call. Re-check before
220
225
  // re-arming so we don't keep firing after shutdown.
@@ -229,6 +234,24 @@ export default class BackgroundJobsScheduler {
229
234
  scheduleNext()
230
235
  }
231
236
 
237
+ /**
238
+ * Tracks a scheduled enqueue so stop() cannot return while it can still mutate the store.
239
+ * @param {object} args - Options.
240
+ * @param {import("../configuration-types.js").ScheduledBackgroundJobConfiguration} args.jobConfiguration - Job configuration.
241
+ * @param {string} args.jobKey - Job key.
242
+ * @returns {Promise<void>} - Resolves after the enqueue attempt finishes.
243
+ */
244
+ async runScheduledJob({jobConfiguration, jobKey}) {
245
+ const pendingEnqueue = this.enqueueScheduledJob({jobConfiguration, jobKey})
246
+ this.pendingEnqueues.add(pendingEnqueue)
247
+
248
+ try {
249
+ await pendingEnqueue
250
+ } finally {
251
+ this.pendingEnqueues.delete(pendingEnqueue)
252
+ }
253
+ }
254
+
232
255
  /**
233
256
  * Runs enqueue scheduled job.
234
257
  * @param {object} args - Options.
@@ -3,6 +3,7 @@
3
3
  import {randomUUID} from "crypto"
4
4
  import Logger from "../logger.js"
5
5
  import TableData from "../database/table-data/index.js"
6
+ import VelociousError from "../velocious-error.js"
6
7
  import BackgroundJobRecord from "./job-record.js"
7
8
  import normalizeBackgroundJobError from "./normalize-error.js"
8
9
 
@@ -17,8 +18,25 @@ const ORPHANED_AFTER_MS = 2 * 60 * 60 * 1000
17
18
  /**
18
19
  * Execution modes.
19
20
  * @type {import("./types.js").BackgroundJobExecutionMode[]} */
20
- const EXECUTION_MODES = ["inline", "forked", "spawned"]
21
- 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`
22
40
  const DEFAULT_QUEUE = "default"
23
41
  // Queue-derived durable concurrency keys are namespaced so they can't collide
24
42
  // with explicit caller-supplied concurrencyKeys.
@@ -137,7 +155,7 @@ export default class BackgroundJobsStore {
137
155
  job_name: jobName,
138
156
  args_json: argsJson,
139
157
  forked: executionMode !== "inline",
140
- execution_mode: executionMode,
158
+ execution_mode: executionMode === "pooled" ? LEGACY_FORKED_EXECUTION_MODE : executionMode,
141
159
  queue,
142
160
  max_retries: maxRetries,
143
161
  attempts: 0,
@@ -145,7 +163,8 @@ export default class BackgroundJobsStore {
145
163
  scheduled_at_ms: scheduledAtMs,
146
164
  created_at_ms: now,
147
165
  concurrency_key: concurrency?.concurrencyKey || null,
148
- max_concurrency: concurrency?.maxConcurrency || null
166
+ max_concurrency: concurrency?.maxConcurrency || null,
167
+ handoff_id: executionMode === "pooled" ? POOLED_QUEUED_HANDOFF_ID : null
149
168
  }
150
169
  })
151
170
  })
@@ -220,11 +239,7 @@ export default class BackgroundJobsStore {
220
239
  if (typeof forked === "boolean") {
221
240
  query = query.where({forked})
222
241
  }
223
- if (executionMode) {
224
- const executionModes = Array.isArray(executionMode) ? executionMode : [executionMode]
225
-
226
- query = query.where({execution_mode: executionModes})
227
- }
242
+ if (executionMode) query = this._whereExecutionMode({db, executionMode, query})
228
243
 
229
244
  if (scheduledAtOperator === "<=") {
230
245
  const priorityOrder = this._queuePriorityOrderSql(db)
@@ -400,12 +415,14 @@ export default class BackgroundJobsStore {
400
415
  await this.ensureReady()
401
416
 
402
417
  const handedOffAtMs = Date.now()
403
- const handoffId = randomUUID()
404
418
 
405
419
  return await this._withDb(async (db) => await this._transactionResult(db, async () => {
406
420
  const queuedJob = await this._getJobRowById(db, jobId)
407
421
  if (!queuedJob || queuedJob.status !== "queued") return null
408
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()
409
426
 
410
427
  const affectedRows = await this._updateAffectedRows(db, {
411
428
  tableName: JOBS_TABLE,
@@ -479,7 +496,7 @@ export default class BackgroundJobsStore {
479
496
  status: "queued",
480
497
  scheduled_at_ms: Date.now(),
481
498
  handed_off_at_ms: null,
482
- handoff_id: null,
499
+ handoff_id: job.executionMode === "pooled" ? POOLED_QUEUED_HANDOFF_ID : null,
483
500
  worker_id: null
484
501
  },
485
502
  conditions: {handoff_id: handoffId, id: jobId, status: "handed_off"}
@@ -737,7 +754,7 @@ export default class BackgroundJobsStore {
737
754
  if (scheduledAtMs === undefined) return defaultScheduledAtMs
738
755
  if (Number.isSafeInteger(scheduledAtMs) && scheduledAtMs >= 0) return scheduledAtMs
739
756
 
740
- throw new Error("background job scheduledAtMs must be a non-negative safe integer")
757
+ throw VelociousError.safe("background job scheduledAtMs must be a non-negative safe integer")
741
758
  }
742
759
 
743
760
  async _ensureSchema() {
@@ -989,7 +1006,7 @@ export default class BackgroundJobsStore {
989
1006
  const executionModeColumnSql = db.quoteColumn("execution_mode")
990
1007
 
991
1008
  await db.query(
992
- `UPDATE ${tableNameSql} SET ${executionModeColumnSql} = ${db.quote(DEFAULT_EXECUTION_MODE)} ` +
1009
+ `UPDATE ${tableNameSql} SET ${executionModeColumnSql} = ${db.quote(LEGACY_FORKED_EXECUTION_MODE)} ` +
993
1010
  `WHERE ${forkedColumnSql} = ${db.quote(true)} AND ${executionModeColumnSql} IS NULL`
994
1011
  )
995
1012
  await db.query(
@@ -1080,6 +1097,7 @@ export default class BackgroundJobsStore {
1080
1097
  scheduledAt,
1081
1098
  shouldRetry
1082
1099
  })
1100
+ if (shouldRetry && job.executionMode === "pooled") update.handoff_id = POOLED_QUEUED_HANDOFF_ID
1083
1101
 
1084
1102
  const affectedRows = await this._updateAffectedRows(db, {
1085
1103
  tableName: JOBS_TABLE,
@@ -1188,8 +1206,10 @@ export default class BackgroundJobsStore {
1188
1206
  * @returns {import("./types.js").BackgroundJobRow} - Normalized job row.
1189
1207
  */
1190
1208
  _normalizeJobRow(row) {
1191
- const executionMode = row.execution_mode
1192
- ? 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})
1193
1213
  : this._normalizeExecutionMode({forked: this._normalizeBoolean(row.forked)})
1194
1214
 
1195
1215
  return {
@@ -1205,7 +1225,7 @@ export default class BackgroundJobsStore {
1205
1225
  scheduledAtMs: this._normalizeNumber(row.scheduled_at_ms),
1206
1226
  createdAtMs: this._normalizeNumber(row.created_at_ms),
1207
1227
  handedOffAtMs: this._normalizeNumber(row.handed_off_at_ms),
1208
- handoffId: row.handoff_id ? String(row.handoff_id) : null,
1228
+ handoffId,
1209
1229
  completedAtMs: this._normalizeNumber(row.completed_at_ms),
1210
1230
  failedAtMs: this._normalizeNumber(row.failed_at_ms),
1211
1231
  orphanedAtMs: this._normalizeNumber(row.orphaned_at_ms),
@@ -1505,7 +1525,11 @@ export default class BackgroundJobsStore {
1505
1525
  if (executionMode) {
1506
1526
  return this._normalizeExecutionModeName(executionMode)
1507
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.
1508
1531
  if (options?.forked === false) return "inline"
1532
+ if (options?.forked === true) return LEGACY_FORKED_EXECUTION_MODE
1509
1533
 
1510
1534
  return DEFAULT_EXECUTION_MODE
1511
1535
  }
@@ -1523,6 +1547,47 @@ export default class BackgroundJobsStore {
1523
1547
  throw new Error(`Invalid background job executionMode: ${executionMode}`)
1524
1548
  }
1525
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
+
1526
1591
  /**
1527
1592
  * Runs parse args.
1528
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