refactor: renable standard JS linter rules
[poolifier.git] / src / utils.ts
... / ...
CommitLineData
1import * as os from 'node:os'
2import { getRandomValues, randomInt } from 'node:crypto'
3import { Worker as ClusterWorker } from 'node:cluster'
4import { Worker as ThreadWorker } from 'node:worker_threads'
5import { cpus } from 'node:os'
6import type {
7 InternalWorkerChoiceStrategyOptions,
8 MeasurementStatisticsRequirements
9} from './pools/selection-strategies/selection-strategies-types.js'
10import type { KillBehavior } from './worker/worker-options.js'
11import { type IWorker, type WorkerType, WorkerTypes } from './pools/worker.js'
12
13/**
14 * Default task name.
15 */
16export const DEFAULT_TASK_NAME = 'default'
17
18/**
19 * An intentional empty function.
20 */
21export const EMPTY_FUNCTION: () => void = Object.freeze(() => {
22 /* Intentionally empty */
23})
24
25/**
26 * Gets default worker choice strategy options.
27 *
28 * @param retries - The number of worker choice retries.
29 * @returns The default worker choice strategy options.
30 */
31const getDefaultInternalWorkerChoiceStrategyOptions = (
32 retries: number
33): InternalWorkerChoiceStrategyOptions => {
34 return {
35 retries,
36 runTime: { median: false },
37 waitTime: { median: false },
38 elu: { median: false }
39 }
40}
41
42/**
43 * Default measurement statistics requirements.
44 */
45export const DEFAULT_MEASUREMENT_STATISTICS_REQUIREMENTS: MeasurementStatisticsRequirements =
46 {
47 aggregate: false,
48 average: false,
49 median: false
50 }
51
52/**
53 * Returns safe host OS optimized estimate of the default amount of parallelism a pool should use.
54 * Always returns a value greater than zero.
55 *
56 * @returns The host OS optimized maximum pool size.
57 */
58export const availableParallelism = (): number => {
59 let availableParallelism = 1
60 try {
61 availableParallelism = os.availableParallelism()
62 } catch {
63 const cpus = os.cpus()
64 if (Array.isArray(cpus) && cpus.length > 0) {
65 availableParallelism = cpus.length
66 }
67 }
68 return availableParallelism
69}
70
71/**
72 * Returns the worker type of the given worker.
73 *
74 * @param worker - The worker to get the type of.
75 * @returns The worker type of the given worker.
76 * @internal
77 */
78export const getWorkerType = (worker: IWorker): WorkerType | undefined => {
79 if (worker instanceof ThreadWorker) {
80 return WorkerTypes.thread
81 } else if (worker instanceof ClusterWorker) {
82 return WorkerTypes.cluster
83 }
84}
85
86/**
87 * Returns the worker id of the given worker.
88 *
89 * @param worker - The worker to get the id of.
90 * @returns The worker id of the given worker.
91 * @internal
92 */
93export const getWorkerId = (worker: IWorker): number | undefined => {
94 if (worker instanceof ThreadWorker) {
95 return worker.threadId
96 } else if (worker instanceof ClusterWorker) {
97 return worker.id
98 }
99}
100
101/**
102 * Sleeps for the given amount of milliseconds.
103 *
104 * @param ms - The amount of milliseconds to sleep.
105 * @returns A promise that resolves after the given amount of milliseconds.
106 * @internal
107 */
108export const sleep = async (ms: number): Promise<void> => {
109 await new Promise(resolve => {
110 setTimeout(resolve, ms)
111 })
112}
113
114/**
115 * Computes the retry delay in milliseconds using an exponential back off algorithm.
116 *
117 * @param retryNumber - The number of retries that have already been attempted
118 * @param delayFactor - The base delay factor in milliseconds
119 * @returns Delay in milliseconds
120 * @internal
121 */
122export const exponentialDelay = (
123 retryNumber = 0,
124 delayFactor = 100
125): number => {
126 const delay = Math.pow(2, retryNumber) * delayFactor
127 const randomSum = delay * 0.2 * secureRandom() // 0-20% of the delay
128 return delay + randomSum
129}
130
131/**
132 * Computes the average of the given data set.
133 *
134 * @param dataSet - Data set.
135 * @returns The average of the given data set.
136 * @internal
137 */
138export const average = (dataSet: number[]): number => {
139 if (Array.isArray(dataSet) && dataSet.length === 0) {
140 return 0
141 }
142 if (Array.isArray(dataSet) && dataSet.length === 1) {
143 return dataSet[0]
144 }
145 return (
146 dataSet.reduce((accumulator, number) => accumulator + number, 0) /
147 dataSet.length
148 )
149}
150
151/**
152 * Computes the median of the given data set.
153 *
154 * @param dataSet - Data set.
155 * @returns The median of the given data set.
156 * @internal
157 */
158export const median = (dataSet: number[]): number => {
159 if (Array.isArray(dataSet) && dataSet.length === 0) {
160 return 0
161 }
162 if (Array.isArray(dataSet) && dataSet.length === 1) {
163 return dataSet[0]
164 }
165 const sortedDataSet = dataSet.slice().sort((a, b) => a - b)
166 return (
167 (sortedDataSet[(sortedDataSet.length - 1) >> 1] +
168 sortedDataSet[sortedDataSet.length >> 1]) /
169 2
170 )
171}
172
173/**
174 * Rounds the given number to the given scale.
175 * The rounding is done using the "round half away from zero" method.
176 *
177 * @param num - The number to round.
178 * @param scale - The scale to round to.
179 * @returns The rounded number.
180 * @internal
181 */
182export const round = (num: number, scale = 2): number => {
183 const rounder = Math.pow(10, scale)
184 return Math.round(num * rounder * (1 + Number.EPSILON)) / rounder
185}
186
187/**
188 * Is the given object a plain object?
189 *
190 * @param obj - The object to check.
191 * @returns `true` if the given object is a plain object, `false` otherwise.
192 * @internal
193 */
194export const isPlainObject = (obj: unknown): boolean =>
195 typeof obj === 'object' &&
196 obj !== null &&
197 obj?.constructor === Object &&
198 Object.prototype.toString.call(obj) === '[object Object]'
199
200/**
201 * Detects whether the given value is a kill behavior or not.
202 *
203 * @typeParam KB - Which specific KillBehavior type to test against.
204 * @param killBehavior - Which kind of kill behavior to detect.
205 * @param value - Any value.
206 * @returns `true` if `value` was strictly equals to `killBehavior`, otherwise `false`.
207 * @internal
208 */
209export const isKillBehavior = <KB extends KillBehavior>(
210 killBehavior: KB,
211 value: unknown
212): value is KB => {
213 return value === killBehavior
214}
215
216/**
217 * Detects whether the given value is an asynchronous function or not.
218 *
219 * @param fn - Any value.
220 * @returns `true` if `fn` was an asynchronous function, otherwise `false`.
221 * @internal
222 */
223export const isAsyncFunction = (
224 fn: unknown
225): fn is (...args: unknown[]) => Promise<unknown> => {
226 return typeof fn === 'function' && fn.constructor.name === 'AsyncFunction'
227}
228
229/**
230 * Generates a cryptographically secure random number in the [0,1[ range
231 *
232 * @returns A number in the [0,1[ range
233 * @internal
234 */
235export const secureRandom = (): number => {
236 return getRandomValues(new Uint32Array(1))[0] / 0x100000000
237}
238
239/**
240 * Returns the minimum of the given numbers.
241 * If no numbers are given, `Infinity` is returned.
242 *
243 * @param args - The numbers to get the minimum of.
244 * @returns The minimum of the given numbers.
245 * @internal
246 */
247export const min = (...args: number[]): number =>
248 args.reduce((minimum, num) => (minimum < num ? minimum : num), Infinity)
249
250/**
251 * Returns the maximum of the given numbers.
252 * If no numbers are given, `-Infinity` is returned.
253 *
254 * @param args - The numbers to get the maximum of.
255 * @returns The maximum of the given numbers.
256 * @internal
257 */
258export const max = (...args: number[]): number =>
259 args.reduce((maximum, num) => (maximum > num ? maximum : num), -Infinity)
260
261/**
262 * Wraps a function so that it can only be called once.
263 *
264 * @param fn - The function to wrap.
265 * @param context - The context to bind the function to.
266 * @returns The wrapped function.
267 * @internal
268 */
269// eslint-disable-next-line @typescript-eslint/no-explicit-any
270export const once = <T, A extends any[], R>(
271 fn: (...args: A) => R,
272 context: T
273): ((...args: A) => R) => {
274 let result: R
275 return (...args: A) => {
276 if (fn != null) {
277 result = fn.apply<T, A, R>(context, args)
278 ;(fn as unknown as undefined) = (context as unknown as undefined) =
279 undefined
280 }
281 return result
282 }
283}
284
285const clone = <T extends object>(object: T): T => {
286 return JSON.parse(JSON.stringify(object)) as T
287}
288
289export const buildInternalWorkerChoiceStrategyOptions = (
290 poolMaxSize: number,
291 opts?: InternalWorkerChoiceStrategyOptions
292): InternalWorkerChoiceStrategyOptions => {
293 opts = clone(opts ?? {})
294 if (opts?.weights == null) {
295 opts.weights = getDefaultWeights(poolMaxSize)
296 }
297 return {
298 ...getDefaultInternalWorkerChoiceStrategyOptions(
299 poolMaxSize + Object.keys(opts.weights).length
300 ),
301 ...opts
302 }
303}
304
305const getDefaultWeights = (
306 poolMaxSize: number,
307 defaultWorkerWeight?: number
308): Record<number, number> => {
309 defaultWorkerWeight = defaultWorkerWeight ?? getDefaultWorkerWeight()
310 const weights: Record<number, number> = {}
311 for (let workerNodeKey = 0; workerNodeKey < poolMaxSize; workerNodeKey++) {
312 weights[workerNodeKey] = defaultWorkerWeight
313 }
314 return weights
315}
316
317const getDefaultWorkerWeight = (): number => {
318 const cpuSpeed = randomInt(500, 2500)
319 let cpusCycleTimeWeight = 0
320 for (const cpu of cpus()) {
321 if (cpu.speed == null || cpu.speed === 0) {
322 cpu.speed =
323 cpus().find(cpu => cpu.speed != null && cpu.speed !== 0)?.speed ??
324 cpuSpeed
325 }
326 // CPU estimated cycle time
327 const numberOfDigits = cpu.speed.toString().length - 1
328 const cpuCycleTime = 1 / (cpu.speed / Math.pow(10, numberOfDigits))
329 cpusCycleTimeWeight += cpuCycleTime * Math.pow(10, numberOfDigits)
330 }
331 return Math.round(cpusCycleTimeWeight / cpus().length)
332}