velocious 1.0.612 → 1.0.613

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.612",
6
+ "version": "1.0.613",
7
7
  "main": "build/index.js",
8
8
  "types": "build/index.d.ts",
9
9
  "files": [
@@ -141,3 +141,4 @@ function handleMessage(message) {
141
141
  process.on("message", (message) => handleMessage(message))
142
142
  process.once("disconnect", () => void shutdownRunner(0))
143
143
  for (const signal of ["SIGTERM", "SIGINT"]) process.once(signal, () => void shutdownRunner(1))
144
+ if (process.send) process.send({type: "ready"})
@@ -194,8 +194,10 @@ export default class BackgroundJobsWorker {
194
194
  this.inflightPooledJobs = new Set()
195
195
  /** @type {Set<import("node:child_process").ChildProcess>} */
196
196
  this.pooledChildren = new Set()
197
- /** @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}>, lastDispatchSeq: number, retiring: boolean, settling?: boolean, timeoutSigkillTimer?: ReturnType<typeof setTimeout> | null}>} */
197
+ /** @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, pooledJob?: Promise<void>, timeoutTimer?: ReturnType<typeof setTimeout> | null}>, lastDispatchSeq: number, retiring: boolean, started?: boolean, settling?: boolean, timeoutSigkillTimer?: ReturnType<typeof setTimeout> | null}>} */
198
198
  this.pooledChildStates = new Map()
199
+ /** @type {WeakSet<Promise<void>>} */
200
+ this._pooledStartupFailureJobs = new WeakSet()
199
201
  // Monotonic dispatch counter for round-robin child selection: each dispatch stamps
200
202
  // the chosen child, and selection prefers the child dispatched least recently.
201
203
  this._pooledDispatchSeq = 0
@@ -600,16 +602,16 @@ export default class BackgroundJobsWorker {
600
602
  }
601
603
 
602
604
  /**
603
- * Tells main we're ready for the next job — but only if we haven't been
604
- * asked to drain. Once we've sent `draining` we don't want to take more
605
- * work.
605
+ * Advertises current worker capacity unless the worker is draining.
606
+ * @param {object} [options] - Advertisement options.
607
+ * @param {boolean} [options.revokePooledAdmission] - Revoke pooled credits while preserving other execution modes.
606
608
  * @returns {void}
607
609
  */
608
- _sendReadyIfRunning() {
610
+ _sendReadyIfRunning({revokePooledAdmission = false} = {}) {
609
611
  if (this.shouldStop) return
610
612
  if (!this.jsonSocket) return
611
613
 
612
- const readyMessage = this._readyMessage()
614
+ const readyMessage = this._readyMessage({revokePooledAdmission})
613
615
 
614
616
  if (!readyMessage) return
615
617
  this.jsonSocket.send(readyMessage)
@@ -617,21 +619,24 @@ export default class BackgroundJobsWorker {
617
619
 
618
620
  /**
619
621
  * Runs ready message.
622
+ * @param {object} [options] - Advertisement options.
623
+ * @param {boolean} [options.revokePooledAdmission] - Revoke pooled credits while preserving other execution modes.
620
624
  * @returns {import("./types.js").BackgroundJobSocketMessage | null} - Ready message or null when the worker has no capacity.
621
625
  */
622
- _readyMessage() {
626
+ _readyMessage({revokePooledAdmission = false} = {}) {
623
627
  const acceptsProcessJob = this.inflightProcessJobs.size < this.maxConcurrentForkedJobs
624
628
  const acceptsInline = this.inflightInlineJobs.size < this.maxConcurrentInlineJobs
625
- const acceptsPooled = this._availablePooledSlots() > 0
629
+ const availablePooledSlots = revokePooledAdmission ? 0 : this._availablePooledSlots()
630
+ const acceptsPooled = availablePooledSlots > 0
626
631
 
627
- if (!acceptsProcessJob && !acceptsInline && !acceptsPooled) return null
632
+ if (!revokePooledAdmission && !acceptsProcessJob && !acceptsInline && !acceptsPooled) return null
628
633
 
629
634
  return {
630
635
  type: "ready",
631
636
  acceptsForked: acceptsProcessJob,
632
637
  acceptsInline,
633
638
  acceptsPooled,
634
- availablePooledSlots: this._availablePooledSlots(),
639
+ availablePooledSlots,
635
640
  acceptsSpawned: acceptsProcessJob
636
641
  }
637
642
  }
@@ -646,7 +651,7 @@ export default class BackgroundJobsWorker {
646
651
  let inflight
647
652
  inflight = pooledJob.finally(() => {
648
653
  this.inflightPooledJobs.delete(inflight)
649
- if (!this.shouldStop) this._sendReadyIfRunning()
654
+ if (!this.shouldStop && !this._pooledStartupFailureJobs.has(pooledJob)) this._sendReadyIfRunning()
650
655
  })
651
656
  this.inflightPooledJobs.add(inflight)
652
657
  }
@@ -689,16 +694,22 @@ export default class BackgroundJobsWorker {
689
694
  // Stamp the round-robin cursor so the next dispatch prefers a different child.
690
695
  state.lastDispatchSeq = ++this._pooledDispatchSeq
691
696
 
692
- return new Promise((resolve) => {
693
- const timeoutTimer = this._armPooledJobTimeout({child, payload})
697
+ /**
698
+ * Resolves the pooled job promise.
699
+ * @type {(value: void) => void}
700
+ */
701
+ let resolvePooledJob = () => {}
702
+ const pooledJob = new Promise((resolve) => { resolvePooledJob = resolve })
703
+ const timeoutTimer = this._armPooledJobTimeout({child, payload})
694
704
 
695
- state.inflight.set(payload.id, {payload, resolve, timeoutTimer})
696
- try {
697
- child.send({type: "job", payload, sharedTransactionBroker: this._pooledJobSharedTransactionBrokerConfig()})
698
- } catch (error) {
699
- void this._handlePooledChildFailure({child, error})
700
- }
701
- })
705
+ state.inflight.set(payload.id, {payload, resolve: resolvePooledJob, pooledJob, timeoutTimer})
706
+ try {
707
+ child.send({type: "job", payload, sharedTransactionBroker: this._pooledJobSharedTransactionBrokerConfig()})
708
+ } catch (error) {
709
+ void this._handlePooledChildFailure({child, error})
710
+ }
711
+
712
+ return pooledJob
702
713
  }
703
714
 
704
715
  /**
@@ -769,7 +780,8 @@ export default class BackgroundJobsWorker {
769
780
  * (SIGTERM, then SIGKILL after the grace) — a hung JS job cannot be cancelled
770
781
  * any other way. The non-clean exit flows through `_handlePooledChildFailure`,
771
782
  * which reports every in-flight job on the child failed (so they requeue) and
772
- * drops it from tracking; capacity is refilled on the next dispatch.
783
+ * drops it from tracking; the failure path immediately re-advertises the
784
+ * resulting capacity once the runner has completed startup.
773
785
  * @param {object} args - Options.
774
786
  * @param {import("node:child_process").ChildProcess} args.child - Pooled child.
775
787
  * @param {string} args.jobId - Job id that overran.
@@ -810,7 +822,7 @@ export default class BackgroundJobsWorker {
810
822
  })
811
823
  this.pooledChildren.add(child)
812
824
  this.inflightProcessChildren.add(child)
813
- this.pooledChildStates.set(child, {createdAtMs: Date.now(), jobsRun: 0, inflight: new Map(), lastDispatchSeq: 0, retiring: false})
825
+ this.pooledChildStates.set(child, {createdAtMs: Date.now(), jobsRun: 0, inflight: new Map(), lastDispatchSeq: 0, retiring: false, started: false})
814
826
  child.on("message", (message) => this._handlePooledChildMessage({child, message}))
815
827
  child.once("exit", (code, signal) => this._handlePooledChildFailure({child, error: new Error(`Pooled background job runner exited: code=${code} signal=${signal || "none"}`)}))
816
828
  child.once("error", (error) => this._handlePooledChildFailure({child, error}))
@@ -829,7 +841,12 @@ export default class BackgroundJobsWorker {
829
841
  if (!message || typeof message !== "object") return
830
842
  const record = /** @type {{type?: ReturnType<typeof JSON.parse>, jobId?: ReturnType<typeof JSON.parse>, acknowledged?: ReturnType<typeof JSON.parse>, rssBytes?: ReturnType<typeof JSON.parse>, error?: ReturnType<typeof JSON.parse>}} */ (message)
831
843
  const state = this.pooledChildStates.get(child)
844
+ if (record.type === "ready") {
845
+ if (state) state.started = true
846
+ return
847
+ }
832
848
  if (record.type !== "job-outcome" || !state || state.settling || typeof record.jobId !== "string") return
849
+ state.started = true
833
850
  const entry = state.inflight.get(record.jobId)
834
851
  if (!entry) return
835
852
 
@@ -907,9 +924,11 @@ export default class BackgroundJobsWorker {
907
924
  /**
908
925
  * Removes an exited/unhealthy pooled child and reports every job that was
909
926
  * in-flight on it as failed — a process-level crash's blast radius is the
910
- * child's whole in-flight set. Capacity is refilled lazily on the next
911
- * dispatch (a spawnable slot is still advertised), avoiding a tight respawn
912
- * loop when a child crashes on startup.
927
+ * child's whole in-flight set. Once the child has completed startup, its
928
+ * freed capacity is advertised immediately; the replacement itself is still
929
+ * spawned lazily by the next dispatch. A child that exits before its startup
930
+ * handshake does not re-announce, avoiding a tight respawn loop on startup
931
+ * failure.
913
932
  * @param {object} args - Failure details.
914
933
  * @param {import("node:child_process").ChildProcess} args.child - Pooled child.
915
934
  * @param {ReturnType<typeof JSON.parse>} args.error - Failure.
@@ -934,7 +953,7 @@ export default class BackgroundJobsWorker {
934
953
  if (state) state.inflight.clear()
935
954
  this.pooledChildStates.delete(child)
936
955
 
937
- await Promise.allSettled(entries.map(async (entry) => {
956
+ const failureReports = entries.map(async (entry) => {
938
957
  await this._reportJobResult({
939
958
  jobId: entry.payload.id,
940
959
  status: "failed",
@@ -944,7 +963,26 @@ export default class BackgroundJobsWorker {
944
963
  workerId: entry.payload.workerId || this.workerId
945
964
  })
946
965
  if (entry.resolve) entry.resolve(undefined)
947
- }))
966
+ })
967
+
968
+ // Start every fallback report before announcing capacity so the main cannot
969
+ // observe a replacement slot before the failed jobs' reports are in flight.
970
+ // The report promises remain tracked below; a slow retry must not hold the
971
+ // newly freed runner capacity hostage.
972
+ if (state && state.started !== false) {
973
+ this._sendReadyIfRunning()
974
+ } else if (state) {
975
+ for (const entry of entries) {
976
+ if (entry.pooledJob) this._pooledStartupFailureJobs.add(entry.pooledJob)
977
+ }
978
+ // A previous ready message may still have unconsumed pooled credits at the
979
+ // main. Revoke them authoritatively without suppressing valid inline or
980
+ // process-runner readiness; otherwise queued jobs can trigger a startup
981
+ // crash loop using the stale credits.
982
+ this._sendReadyIfRunning({revokePooledAdmission: true})
983
+ }
984
+
985
+ await Promise.allSettled(failureReports)
948
986
  }
949
987
 
950
988
  /**