velocious 1.0.681 → 1.0.682

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "velocious": "build/bin/velocious.js"
4
4
  },
5
5
  "name": "velocious",
6
- "version": "1.0.681",
6
+ "version": "1.0.682",
7
7
  "main": "build/index.js",
8
8
  "types": "build/index.d.ts",
9
9
  "files": [
@@ -322,6 +322,28 @@ export default class BackgroundJobsWorker {
322
322
  // Monotonic dispatch counter for round-robin child selection: each dispatch stamps
323
323
  // the chosen child, and selection prefers the child dispatched least recently.
324
324
  this._pooledDispatchSeq = 0
325
+ // Waiters blocked in _runPooledJob because the pool is at its hard cap: a job may
326
+ // not spawn a child while total live children (working + draining) is at the cap.
327
+ /** @type {Set<() => void>} */
328
+ this._pooledSlotWaiters = new Set()
329
+ /** @type {ReturnType<typeof setInterval> | undefined} - Safety poll that re-checks the slot condition. */
330
+ this._pooledSlotWaitTimer = undefined
331
+ }
332
+
333
+ /** Starts the slot-waiter safety poll if it is not already running. */
334
+ _startPooledSlotWaitPoll() {
335
+ if (this._pooledSlotWaitTimer) return
336
+
337
+ this._pooledSlotWaitTimer = setInterval(() => this._wakePooledSlotWaiters(), 50)
338
+ this._pooledSlotWaitTimer.unref()
339
+ }
340
+
341
+ /** Stops the slot-waiter safety poll once no waiter is registered. */
342
+ _stopPooledSlotWaitPollIfIdle() {
343
+ if (this._pooledSlotWaiters.size > 0 || !this._pooledSlotWaitTimer) return
344
+
345
+ clearInterval(this._pooledSlotWaitTimer)
346
+ this._pooledSlotWaitTimer = undefined
325
347
  }
326
348
 
327
349
  /**
@@ -1079,27 +1101,72 @@ export default class BackgroundJobsWorker {
1079
1101
  }
1080
1102
  }
1081
1103
 
1104
+ /**
1105
+ * Hard cap on total live pooled children (working + draining): children the
1106
+ * pool may still spawn. Counting the whole live set — draining children
1107
+ * included — is what bounds pool memory: a draining child still holds its
1108
+ * RSS until its last in-flight job finishes, so it must occupy a cap slot.
1109
+ * @returns {number} - Number of children the pool may still spawn.
1110
+ */
1111
+ _spawnablePooledChildren() {
1112
+ return Math.max(0, this.pooledRunnerCount - this.pooledChildren.size)
1113
+ }
1114
+
1115
+ /**
1116
+ * Resolves once a non-retiring pooled child has a free concurrency slot or the
1117
+ * pool may spawn a new one. Pooled jobs admitted while the pool is at its
1118
+ * hard cap wait here instead of spawning an over-capacity child; the wake
1119
+ * points are the only capacity-freeing transitions (a job outcome and a child
1120
+ * exit), so no polling is needed.
1121
+ * @returns {Promise<void>} - Resolves when a slot is available.
1122
+ */
1123
+ _waitPooledSlot() {
1124
+ this._startPooledSlotWaitPoll()
1125
+
1126
+ return new Promise((resolve) => {
1127
+ const waiter = () => {
1128
+ this._pooledSlotWaiters.delete(waiter)
1129
+ this._stopPooledSlotWaitPollIfIdle()
1130
+ resolve()
1131
+ }
1132
+
1133
+ this._pooledSlotWaiters.add(waiter)
1134
+ })
1135
+ }
1136
+
1137
+ /**
1138
+ * Resolves every registered waiter; waiters re-check the slot condition
1139
+ * themselves and only proceed when it holds.
1140
+ * @returns {void}
1141
+ */
1142
+ _wakePooledSlotWaiters() {
1143
+ if (this._pooledSlotWaiters.size === 0) return
1144
+
1145
+ for (const resolve of [...this._pooledSlotWaiters]) resolve()
1146
+ }
1147
+
1082
1148
  /**
1083
1149
  * Free pooled slots across the pool: open slots in non-retiring children plus
1084
- * the slots we could add by spawning more children up to `pooledRunnerCount`.
1085
- * Retiring children (draining before replacement) never contribute capacity.
1150
+ * the slots we could add by spawning more children up to the hard cap on total
1151
+ * live children. Retiring children (draining before replacement) never
1152
+ * contribute capacity, and they count against the cap: while one is still
1153
+ * draining, no replacement is advertised (or spawned) — the pool advertises
1154
+ * exactly what it can serve instead of phantom capacity.
1086
1155
  * @returns {number} - Number of pooled jobs the worker can accept right now.
1087
1156
  */
1088
1157
  _availablePooledSlots() {
1089
1158
  let openInExisting = 0
1090
- let nonRetiringChildren = 0
1091
1159
  let queuedReservations = 0
1092
1160
 
1093
1161
  for (const child of this.pooledChildren) {
1094
1162
  const state = this.pooledChildStates.get(child)
1095
1163
  if (!state || state.retiring) continue
1096
- nonRetiringChildren += 1
1097
1164
  openInExisting += this.pooledRunnerConcurrency - state.inflight.size
1098
1165
  }
1099
1166
 
1100
1167
  for (const queue of this.pooledJobQueues.values()) queuedReservations += queue.length
1101
1168
 
1102
- const spawnableChildren = Math.max(0, this.pooledRunnerCount - nonRetiringChildren)
1169
+ const spawnableChildren = Math.max(0, this.pooledRunnerCount - this.pooledChildren.size)
1103
1170
 
1104
1171
  return Math.max(0, openInExisting + spawnableChildren * this.pooledRunnerConcurrency - queuedReservations)
1105
1172
  }
@@ -1109,11 +1176,32 @@ export default class BackgroundJobsWorker {
1109
1176
  * new child when every non-retiring child is full and the pool is below
1110
1177
  * `pooledRunnerCount`. Each child runs up to `pooledRunnerConcurrency` jobs at
1111
1178
  * once on its own event loop.
1179
+ *
1180
+ * When the pool is already at its hard cap (total live children, draining
1181
+ * included), the job waits for a slot instead of spawning: that is what keeps
1182
+ * the live-child count — and therefore the pool's total RSS — bounded. The
1183
+ * wait resolves on the only two capacity-freeing transitions (a job outcome,
1184
+ * a child exit); a safety poll covers anything missed.
1112
1185
  * @param {import("./types.js").BackgroundJobPayload & {id: string}} payload - Job payload.
1113
1186
  * @returns {Promise<void>} - Resolves after the durable report.
1114
1187
  */
1115
- _runPooledJob(payload) {
1116
- const child = this._selectPooledChild() || this._createPooledChild()
1188
+ async _runPooledJob(payload) {
1189
+ // At the hard cap (no free slot, no spawnable child) the job waits for a
1190
+ // slot instead of spawning an over-capacity child — that is what bounds
1191
+ // the live-child count and the pool's total RSS.
1192
+ let child = this._selectPooledChild()
1193
+ while (!child) {
1194
+ if (this._spawnablePooledChildren() === 0) {
1195
+ // Shutdown: main no longer dispatches and no slot will ever free —
1196
+ // stop waiting so the tracked job can settle and the drain completes.
1197
+ if (this.shouldStop) return
1198
+ await this._waitPooledSlot()
1199
+ child = this._selectPooledChild()
1200
+ continue
1201
+ }
1202
+ child = this._selectPooledChild() || this._createPooledChild()
1203
+ }
1204
+
1117
1205
  const state = this.pooledChildStates.get(child)
1118
1206
  if (!state) throw new Error("Pooled runner state missing")
1119
1207
 
@@ -1232,12 +1320,16 @@ export default class BackgroundJobsWorker {
1232
1320
  }
1233
1321
 
1234
1322
  /**
1235
- * Creates a reusable pooled child.
1236
- * @returns {import("node:child_process").ChildProcess} - New pooled child.
1323
+ * Creates a reusable pooled child, enforcing the hard cap on total live
1324
+ * children (working + draining). Returns undefined when the cap is already
1325
+ * met — the only way a new child may exist is a slot being open, so the
1326
+ * caller re-checks and waits again.
1327
+ * @returns {import("node:child_process").ChildProcess | undefined} - The new child, or undefined when the pool is at its cap.
1237
1328
  */
1238
1329
  _createPooledChild() {
1239
1330
  const configuration = this.configuration
1240
1331
  if (!configuration) throw new Error("Background jobs worker configuration not initialized")
1332
+ if (this.pooledChildren.size >= this.pooledRunnerCount) return undefined
1241
1333
  const child = fork(POOLED_RUNNER_ENTRY_PATH, [], {
1242
1334
  cwd: configuration.getDirectory(), execArgv: [], stdio: ["ignore", "ignore", "ignore", "ipc"],
1243
1335
  env: Object.assign({}, process.env, this._childBackgroundJobsEnvironment())
@@ -1332,6 +1424,9 @@ export default class BackgroundJobsWorker {
1332
1424
  this._beginRetirePooledChild(child)
1333
1425
  }
1334
1426
  this._terminateIfDrained(child)
1427
+ // A job outcome frees a concurrency slot and may drain a retiring child —
1428
+ // the only two transitions that free capacity for waiters at the hard cap.
1429
+ this._wakePooledSlotWaiters()
1335
1430
  }
1336
1431
 
1337
1432
  /**
@@ -1362,11 +1457,14 @@ export default class BackgroundJobsWorker {
1362
1457
  }
1363
1458
 
1364
1459
  /**
1365
- * Marks a pooled child for retirement and eagerly spawns a single replacement
1366
- * (1-for-1) so its capacity is restored immediately without waiting for it to
1367
- * finish draining. The retiring child stops receiving new jobs and is
1368
- * terminated only once its in-flight set drains, so a long-running job (e.g. a
1369
- * build) is never cut off.
1460
+ * Marks a pooled child for retirement and — when the pool is below its hard
1461
+ * cap — eagerly spawns a single replacement (1-for-1) so its capacity is
1462
+ * restored immediately without waiting for it to finish draining. The
1463
+ * replacement spawn is gated by the cap (the retiring child still counts as
1464
+ * live until it exits), so a full pool simply defers the replacement to the
1465
+ * retiring child's drain instead of spawning over capacity. The retiring
1466
+ * child stops receiving new jobs and is terminated only once its in-flight
1467
+ * set drains, so a long-running job (e.g. a build) is never cut off.
1370
1468
  * @param {import("node:child_process").ChildProcess} child - Child to retire.
1371
1469
  * @returns {void}
1372
1470
  */
@@ -1376,7 +1474,9 @@ export default class BackgroundJobsWorker {
1376
1474
 
1377
1475
  state.retiring = true
1378
1476
  // Best-effort pre-warm: skip when stopping (no new work) or before the
1379
- // worker is initialized (no configuration to fork a child from).
1477
+ // worker is initialized (no configuration to fork a child from). The cap
1478
+ // inside _createPooledChild refuses the spawn while the pool is full, in
1479
+ // which case the replacement is deferred to the drain path.
1380
1480
  if (!this.shouldStop && this.configuration) this._createPooledChild()
1381
1481
  }
1382
1482
 
@@ -1394,6 +1494,9 @@ export default class BackgroundJobsWorker {
1394
1494
 
1395
1495
  /**
1396
1496
  * Retires a drained pooled child (removes it from tracking, then SIGTERMs it).
1497
+ * Because the hard cap counts live children, the exit of this child frees a
1498
+ * slot: any deferred replacement (the pool was full when the child retired)
1499
+ * is spawned now, and capacity is re-advertised so main can dispatch into it.
1397
1500
  * @param {import("node:child_process").ChildProcess} child - Child process to retire.
1398
1501
  * @returns {void}
1399
1502
  */
@@ -1503,6 +1606,9 @@ export default class BackgroundJobsWorker {
1503
1606
  }
1504
1607
  this.pooledChildren.delete(child)
1505
1608
  this.inflightProcessChildren.delete(child)
1609
+ // Child exit frees a hard-cap slot even while its in-flight set is still
1610
+ // being reported — wake waiters now; their reports settle independently.
1611
+ this._wakePooledSlotWaiters()
1506
1612
 
1507
1613
  const entries = state ? [...state.inflight.values()] : []
1508
1614
  const runnerFailure = state