test: less strict expectation for CI
[poolifier.git] / src / utils.ts
1 import * as os from 'node:os'
2 import { getRandomValues } from 'node:crypto'
3 import { Worker as ClusterWorker } from 'node:cluster'
4 import { Worker as ThreadWorker } from 'node:worker_threads'
5 import { cpus } from 'node:os'
6 import type {
7 InternalWorkerChoiceStrategyOptions,
8 MeasurementStatisticsRequirements
9 } from './pools/selection-strategies/selection-strategies-types'
10 import type { KillBehavior } from './worker/worker-options'
11 import { type IWorker, type WorkerType, WorkerTypes } from './pools/worker'
12
13 /**
14 * Default task name.
15 */
16 export const DEFAULT_TASK_NAME = 'default'
17
18 /**
19 * An intentional empty function.
20 */
21 export 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 */
31 const 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 */
45 export 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 */
58 export 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 */
78 export 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 */
93 export 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 */
108 export 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 */
122 export 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 */
138 export 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 */
158 export 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 */
182 export 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 */
194 export 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 */
209 export 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 */
223 export 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 */
235 export 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 */
247 export 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 */
258 export 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
270 export 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
285 const clone = <T extends object>(object: T): T => {
286 return JSON.parse(JSON.stringify(object)) as T
287 }
288
289 export 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
305 const getDefaultWeights = (
306 poolMaxSize: number,
307 defaultWorkerWeight: number = getDefaultWorkerWeight()
308 ): Record<number, number> => {
309 const weights: Record<number, number> = {}
310 for (let workerNodeKey = 0; workerNodeKey < poolMaxSize; workerNodeKey++) {
311 weights[workerNodeKey] = defaultWorkerWeight
312 }
313 return weights
314 }
315
316 const getDefaultWorkerWeight = (): number => {
317 let cpusCycleTimeWeight = 0
318 for (const cpu of cpus()) {
319 // CPU estimated cycle time
320 const numberOfDigits = cpu.speed.toString().length - 1
321 const cpuCycleTime = 1 / (cpu.speed / Math.pow(10, numberOfDigits))
322 cpusCycleTimeWeight += cpuCycleTime * Math.pow(10, numberOfDigits)
323 }
324 return Math.round(cpusCycleTimeWeight / cpus().length)
325 }