5
5
* Options for controlling the parallelism of asynchronous operations.
6
6
*
7
7
* @remarks
8
- * Used with {@link Async.mapAsync} and {@link Async.forEachAsync}.
8
+ * Used with {@link (Async:class).(mapAsync:1)}, {@link (Async:class).(mapAsync:2)} and
9
+ * {@link (Async:class).(forEachAsync:1)}, and {@link (Async:class).(forEachAsync:2)}.
9
10
*
10
11
* @public
11
12
*/
12
13
export interface IAsyncParallelismOptions {
13
14
/**
14
- * Optionally used with the {@link Async.mapAsync} and {@link Async.forEachAsync}
15
- * to limit the maximum number of concurrent promises to the specified number.
15
+ * Optionally used with the {@link (Async:class).(mapAsync:1)}, {@link (Async:class).(mapAsync:2)} and
16
+ * {@link (Async:class).(forEachAsync:1)}, and {@link (Async:class).(forEachAsync:2)} to limit the maximum
17
+ * number of concurrent promises to the specified number.
16
18
*/
17
19
concurrency ?: number ;
20
+
21
+ /**
22
+ * Optionally used with the {@link (Async:class).(forEachAsync:2)} to enable weighted operations where an operation can
23
+ * take up more or less than one concurrency unit.
24
+ */
25
+ weighted ?: boolean ;
18
26
}
19
27
20
28
/**
@@ -29,6 +37,42 @@ export interface IRunWithRetriesOptions<TResult> {
29
37
retryDelayMs ?: number ;
30
38
}
31
39
40
+ /**
41
+ * @remarks
42
+ * Used with {@link (Async:class).(forEachAsync:2)} and {@link (Async:class).(mapAsync:2)}.
43
+ *
44
+ * @public
45
+ */
46
+ export interface IWeighted {
47
+ /**
48
+ * The weight of the element, used to determine the concurrency units that it will take up.
49
+ * Must be a whole number greater than or equal to 0.
50
+ */
51
+ weight : number ;
52
+ }
53
+
54
+ function toWeightedIterator < TEntry > (
55
+ iterable : Iterable < TEntry > | AsyncIterable < TEntry > ,
56
+ useWeights ?: boolean
57
+ ) : AsyncIterable < { element : TEntry ; weight : number } > {
58
+ const iterator : Iterator < TEntry > | AsyncIterator < TEntry , TEntry > = (
59
+ ( iterable as Iterable < TEntry > ) [ Symbol . iterator ] ||
60
+ ( iterable as AsyncIterable < TEntry > ) [ Symbol . asyncIterator ]
61
+ ) . call ( iterable ) ;
62
+ return {
63
+ [ Symbol . asyncIterator ] : ( ) => ( {
64
+ next : async ( ) => {
65
+ // The await is necessary here, but TS will complain - it's a false positive.
66
+ const { value, done } = await iterator . next ( ) ;
67
+ return {
68
+ value : { element : value , weight : useWeights ? value ?. weight : 1 } ,
69
+ done : ! ! done
70
+ } ;
71
+ }
72
+ } )
73
+ } ;
74
+ }
75
+
32
76
/**
33
77
* Utilities for parallel asynchronous operations, for use with the system `Promise` APIs.
34
78
*
@@ -58,29 +102,18 @@ export class Async {
58
102
public static async mapAsync < TEntry , TRetVal > (
59
103
iterable : Iterable < TEntry > | AsyncIterable < TEntry > ,
60
104
callback : ( entry : TEntry , arrayIndex : number ) => Promise < TRetVal > ,
61
- options ?: IAsyncParallelismOptions | undefined
62
- ) : Promise < TRetVal [ ] > {
63
- const result : TRetVal [ ] = [ ] ;
64
-
65
- await Async . forEachAsync (
66
- iterable ,
67
- async ( item : TEntry , arrayIndex : number ) : Promise < void > => {
68
- result [ arrayIndex ] = await callback ( item , arrayIndex ) ;
69
- } ,
70
- options
71
- ) ;
72
-
73
- return result ;
74
- }
105
+ options ?: ( IAsyncParallelismOptions & { weighted ?: false } ) | undefined
106
+ ) : Promise < TRetVal [ ] > ;
75
107
76
108
/**
77
109
* Given an input array and a `callback` function, invoke the callback to start a
78
- * promise for each element in the array.
110
+ * promise for each element in the array. Returns an array containing the results.
79
111
*
80
112
* @remarks
81
- * This API is similar to the system `Array#forEach`, except that the loop is asynchronous,
82
- * and the maximum number of concurrent promises can be throttled
83
- * using {@link IAsyncParallelismOptions.concurrency}.
113
+ * This API is similar to the system `Array#map`, except that the loop is asynchronous,
114
+ * and the maximum number of concurrent units can be throttled
115
+ * using {@link IAsyncParallelismOptions.concurrency}. Using the {@link IAsyncParallelismOptions.weighted}
116
+ * option, the weight of each operation can be specified, which determines how many concurrent units it takes up.
84
117
*
85
118
* If `callback` throws a synchronous exception, or if it returns a promise that rejects,
86
119
* then the loop stops immediately. Any remaining array items will be skipped, and
@@ -90,16 +123,42 @@ export class Async {
90
123
* @param callback - a function that starts an asynchronous promise for an element
91
124
* from the array
92
125
* @param options - options for customizing the control flow
126
+ * @returns an array containing the result for each callback, in the same order
127
+ * as the original input `array`
93
128
*/
94
- public static async forEachAsync < TEntry > (
129
+ public static async mapAsync < TEntry extends IWeighted , TRetVal > (
95
130
iterable : Iterable < TEntry > | AsyncIterable < TEntry > ,
96
- callback : ( entry : TEntry , arrayIndex : number ) => Promise < void > ,
131
+ callback : ( entry : TEntry , arrayIndex : number ) => Promise < TRetVal > ,
132
+ options : IAsyncParallelismOptions & { weighted : true }
133
+ ) : Promise < TRetVal [ ] > ;
134
+ public static async mapAsync < TEntry , TRetVal > (
135
+ iterable : Iterable < TEntry > | AsyncIterable < TEntry > ,
136
+ callback : ( entry : TEntry , arrayIndex : number ) => Promise < TRetVal > ,
137
+ options ?: IAsyncParallelismOptions | undefined
138
+ ) : Promise < TRetVal [ ] > {
139
+ const result : TRetVal [ ] = [ ] ;
140
+
141
+ // @ts -expect-error https://github.com/microsoft/TypeScript/issues/22609, it succeeds against the implementation but fails against the overloads
142
+ await Async . forEachAsync (
143
+ iterable ,
144
+ async ( item : TEntry , arrayIndex : number ) : Promise < void > => {
145
+ result [ arrayIndex ] = await callback ( item , arrayIndex ) ;
146
+ } ,
147
+ options
148
+ ) ;
149
+
150
+ return result ;
151
+ }
152
+
153
+ private static async _forEachWeightedAsync < TReturn , TEntry extends { weight : number ; element : TReturn } > (
154
+ iterable : Iterable < TEntry > | AsyncIterable < TEntry > ,
155
+ callback : ( entry : TReturn , arrayIndex : number ) => Promise < void > ,
97
156
options ?: IAsyncParallelismOptions | undefined
98
157
) : Promise < void > {
99
158
await new Promise < void > ( ( resolve : ( ) => void , reject : ( error : Error ) => void ) => {
100
159
const concurrency : number =
101
160
options ?. concurrency && options . concurrency > 0 ? options . concurrency : Infinity ;
102
- let operationsInProgress : number = 0 ;
161
+ let concurrentUnitsInProgress : number = 0 ;
103
162
104
163
const iterator : Iterator < TEntry > | AsyncIterator < TEntry > = (
105
164
( iterable as Iterable < TEntry > ) [ Symbol . iterator ] ||
@@ -111,18 +170,29 @@ export class Async {
111
170
let promiseHasResolvedOrRejected : boolean = false ;
112
171
113
172
async function queueOperationsAsync ( ) : Promise < void > {
114
- while ( operationsInProgress < concurrency && ! iteratorIsComplete && ! promiseHasResolvedOrRejected ) {
173
+ while (
174
+ concurrentUnitsInProgress < concurrency &&
175
+ ! iteratorIsComplete &&
176
+ ! promiseHasResolvedOrRejected
177
+ ) {
115
178
// Increment the concurrency while waiting for the iterator.
116
179
// This function is reentrant, so this ensures that at most `concurrency` executions are waiting
117
- operationsInProgress ++ ;
180
+ concurrentUnitsInProgress ++ ;
118
181
const currentIteratorResult : IteratorResult < TEntry > = await iterator . next ( ) ;
119
182
// eslint-disable-next-line require-atomic-updates
120
183
iteratorIsComplete = ! ! currentIteratorResult . done ;
121
184
122
185
if ( ! iteratorIsComplete ) {
123
- Promise . resolve ( callback ( currentIteratorResult . value , arrayIndex ++ ) )
186
+ const currentIteratorValue : TEntry = currentIteratorResult . value ;
187
+ Async . validateWeightedIterable ( currentIteratorValue ) ;
188
+ const weight : number = Math . min ( currentIteratorValue . weight , concurrency ) ;
189
+ // If it's a weighted operation then add the rest of the weight, removing concurrent units if weight < 1.
190
+ // Cap it to the concurrency limit, otherwise higher weights can cause issues in the case where 0 weighted
191
+ // operations are present.
192
+ concurrentUnitsInProgress += weight - 1 ;
193
+ Promise . resolve ( callback ( currentIteratorValue . element , arrayIndex ++ ) )
124
194
. then ( async ( ) => {
125
- operationsInProgress -- ;
195
+ concurrentUnitsInProgress -= weight ;
126
196
await onOperationCompletionAsync ( ) ;
127
197
} )
128
198
. catch ( ( error ) => {
@@ -131,7 +201,7 @@ export class Async {
131
201
} ) ;
132
202
} else {
133
203
// The iterator is complete and there wasn't a value, so untrack the waiting state.
134
- operationsInProgress -- ;
204
+ concurrentUnitsInProgress -- ;
135
205
}
136
206
}
137
207
@@ -142,7 +212,7 @@ export class Async {
142
212
143
213
async function onOperationCompletionAsync ( ) : Promise < void > {
144
214
if ( ! promiseHasResolvedOrRejected ) {
145
- if ( operationsInProgress === 0 && iteratorIsComplete ) {
215
+ if ( concurrentUnitsInProgress === 0 && iteratorIsComplete ) {
146
216
promiseHasResolvedOrRejected = true ;
147
217
resolve ( ) ;
148
218
} else if ( ! iteratorIsComplete ) {
@@ -158,6 +228,68 @@ export class Async {
158
228
} ) ;
159
229
}
160
230
231
+ /**
232
+ * Given an input array and a `callback` function, invoke the callback to start a
233
+ * promise for each element in the array.
234
+ *
235
+ * @remarks
236
+ * This API is similar to the system `Array#forEach`, except that the loop is asynchronous,
237
+ * and the maximum number of concurrent promises can be throttled
238
+ * using {@link IAsyncParallelismOptions.concurrency}.
239
+ *
240
+ * If `callback` throws a synchronous exception, or if it returns a promise that rejects,
241
+ * then the loop stops immediately. Any remaining array items will be skipped, and
242
+ * overall operation will reject with the first error that was encountered.
243
+ *
244
+ * @param iterable - the array of inputs for the callback function
245
+ * @param callback - a function that starts an asynchronous promise for an element
246
+ * from the array
247
+ * @param options - options for customizing the control flow
248
+ */
249
+ public static async forEachAsync < TEntry > (
250
+ iterable : Iterable < TEntry > | AsyncIterable < TEntry > ,
251
+ callback : ( entry : TEntry , arrayIndex : number ) => Promise < void > ,
252
+ options ?: ( IAsyncParallelismOptions & { weighted ?: false } ) | undefined
253
+ ) : Promise < void > ;
254
+
255
+ /**
256
+ * Given an input array and a `callback` function, invoke the callback to start a
257
+ * promise for each element in the array.
258
+ *
259
+ * @remarks
260
+ * This API is similar to the other `Array#forEachAsync`, except that each item can have
261
+ * a weight that determines how many concurrent operations are allowed. The unweighted
262
+ * `Array#forEachAsync` is a special case of this method where weight = 1 for all items.
263
+ *
264
+ * The maximum number of concurrent operations can still be throttled using
265
+ * {@link IAsyncParallelismOptions.concurrency}, however it no longer determines the
266
+ * maximum number of operations that can be in progress at once. Instead, it determines the
267
+ * number of concurrency units that can be in progress at once. The weight of each operation
268
+ * determines how many concurrency units it takes up. For example, if the concurrency is 2
269
+ * and the first operation has a weight of 2, then only one more operation can be in progress.
270
+ *
271
+ * If `callback` throws a synchronous exception, or if it returns a promise that rejects,
272
+ * then the loop stops immediately. Any remaining array items will be skipped, and
273
+ * overall operation will reject with the first error that was encountered.
274
+ *
275
+ * @param iterable - the array of inputs for the callback function
276
+ * @param callback - a function that starts an asynchronous promise for an element
277
+ * from the array
278
+ * @param options - options for customizing the control flow
279
+ */
280
+ public static async forEachAsync < TEntry extends IWeighted > (
281
+ iterable : Iterable < TEntry > | AsyncIterable < TEntry > ,
282
+ callback : ( entry : TEntry , arrayIndex : number ) => Promise < void > ,
283
+ options : IAsyncParallelismOptions & { weighted : true }
284
+ ) : Promise < void > ;
285
+ public static async forEachAsync < TEntry > (
286
+ iterable : Iterable < TEntry > | AsyncIterable < TEntry > ,
287
+ callback : ( entry : TEntry , arrayIndex : number ) => Promise < void > ,
288
+ options ?: IAsyncParallelismOptions
289
+ ) : Promise < void > {
290
+ await Async . _forEachWeightedAsync ( toWeightedIterator ( iterable , options ?. weighted ) , callback , options ) ;
291
+ }
292
+
161
293
/**
162
294
* Return a promise that resolves after the specified number of milliseconds.
163
295
*/
@@ -190,6 +322,19 @@ export class Async {
190
322
}
191
323
}
192
324
325
+ /**
326
+ * Ensures that the argument is a valid {@link IWeighted}, with a `weight` argument that
327
+ * is a positive integer or 0.
328
+ */
329
+ public static validateWeightedIterable ( operation : IWeighted ) : void {
330
+ if ( operation . weight < 0 ) {
331
+ throw new Error ( 'Weight must be a whole number greater than or equal to 0' ) ;
332
+ }
333
+ if ( operation . weight % 1 !== 0 ) {
334
+ throw new Error ( 'Weight must be a whole number greater than or equal to 0' ) ;
335
+ }
336
+ }
337
+
193
338
/**
194
339
* Returns a Signal, a.k.a. a "deferred promise".
195
340
*/
0 commit comments