Skip to content

Commit 21afaae

Browse files
authored
Merge pull request #4672 from aramissennyeydd/sennyeya/weighted-graph
feat(rush,node-core-library): allow weighted async concurrency
2 parents 0c41a82 + a526067 commit 21afaae

15 files changed

+484
-41
lines changed
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"changes": [
3+
{
4+
"packageName": "@microsoft/rush",
5+
"comment": "Add a `\"weight\"` property to the `\"operation\"` object in the project `config/rush-project.json` file that defines an integer weight for how much of the allowed parallelism the operation uses.",
6+
"type": "none"
7+
}
8+
],
9+
"packageName": "@microsoft/rush"
10+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"changes": [
3+
{
4+
"packageName": "@rushstack/node-core-library",
5+
"comment": "Add a new `weighted: true` option to the `Async.forEachAsync` method that allows each element to specify how much of the allowed parallelism the callback uses.",
6+
"type": "minor"
7+
}
8+
],
9+
"packageName": "@rushstack/node-core-library"
10+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"changes": [
3+
{
4+
"packageName": "@rushstack/node-core-library",
5+
"comment": "Add a new `weighted: true` option to the `Async.mapAsync` method that allows each element to specify how much of the allowed parallelism the callback uses.",
6+
"type": "patch"
7+
}
8+
],
9+
"packageName": "@rushstack/node-core-library"
10+
}

common/reviews/api/node-core-library.api.md

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,22 @@ export class AlreadyReportedError extends Error {
2525

2626
// @public
2727
export class Async {
28-
static forEachAsync<TEntry>(iterable: Iterable<TEntry> | AsyncIterable<TEntry>, callback: (entry: TEntry, arrayIndex: number) => Promise<void>, options?: IAsyncParallelismOptions | undefined): Promise<void>;
28+
static forEachAsync<TEntry>(iterable: Iterable<TEntry> | AsyncIterable<TEntry>, callback: (entry: TEntry, arrayIndex: number) => Promise<void>, options?: (IAsyncParallelismOptions & {
29+
weighted?: false;
30+
}) | undefined): Promise<void>;
31+
static forEachAsync<TEntry extends IWeighted>(iterable: Iterable<TEntry> | AsyncIterable<TEntry>, callback: (entry: TEntry, arrayIndex: number) => Promise<void>, options: IAsyncParallelismOptions & {
32+
weighted: true;
33+
}): Promise<void>;
2934
static getSignal(): [Promise<void>, () => void, (err: Error) => void];
30-
static mapAsync<TEntry, TRetVal>(iterable: Iterable<TEntry> | AsyncIterable<TEntry>, callback: (entry: TEntry, arrayIndex: number) => Promise<TRetVal>, options?: IAsyncParallelismOptions | undefined): Promise<TRetVal[]>;
35+
static mapAsync<TEntry, TRetVal>(iterable: Iterable<TEntry> | AsyncIterable<TEntry>, callback: (entry: TEntry, arrayIndex: number) => Promise<TRetVal>, options?: (IAsyncParallelismOptions & {
36+
weighted?: false;
37+
}) | undefined): Promise<TRetVal[]>;
38+
static mapAsync<TEntry extends IWeighted, TRetVal>(iterable: Iterable<TEntry> | AsyncIterable<TEntry>, callback: (entry: TEntry, arrayIndex: number) => Promise<TRetVal>, options: IAsyncParallelismOptions & {
39+
weighted: true;
40+
}): Promise<TRetVal[]>;
3141
static runWithRetriesAsync<TResult>({ action, maxRetries, retryDelayMs }: IRunWithRetriesOptions<TResult>): Promise<TResult>;
3242
static sleep(ms: number): Promise<void>;
43+
static validateWeightedIterable(operation: IWeighted): void;
3344
}
3445

3546
// @public
@@ -225,6 +236,7 @@ export type FolderItem = fs.Dirent;
225236
// @public
226237
export interface IAsyncParallelismOptions {
227238
concurrency?: number;
239+
weighted?: boolean;
228240
}
229241

230242
// @public
@@ -602,6 +614,11 @@ export interface IWaitForExitWithStringOptions extends IWaitForExitOptions {
602614
encoding: BufferEncoding;
603615
}
604616

617+
// @public (undocumented)
618+
export interface IWeighted {
619+
weight: number;
620+
}
621+
605622
// @public
606623
export class JsonFile {
607624
// @internal (undocumented)

common/reviews/api/rush-lib.api.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -619,6 +619,7 @@ export interface IOperationSettings {
619619
disableBuildCacheForOperation?: boolean;
620620
operationName: string;
621621
outputFolderNames?: string[];
622+
weight?: number;
622623
}
623624

624625
// @internal (undocumented)

libraries/node-core-library/src/Async.ts

Lines changed: 175 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,24 @@
55
* Options for controlling the parallelism of asynchronous operations.
66
*
77
* @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)}.
910
*
1011
* @public
1112
*/
1213
export interface IAsyncParallelismOptions {
1314
/**
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.
1618
*/
1719
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;
1826
}
1927

2028
/**
@@ -29,6 +37,42 @@ export interface IRunWithRetriesOptions<TResult> {
2937
retryDelayMs?: number;
3038
}
3139

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+
3276
/**
3377
* Utilities for parallel asynchronous operations, for use with the system `Promise` APIs.
3478
*
@@ -58,29 +102,18 @@ export class Async {
58102
public static async mapAsync<TEntry, TRetVal>(
59103
iterable: Iterable<TEntry> | AsyncIterable<TEntry>,
60104
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[]>;
75107

76108
/**
77109
* 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.
79111
*
80112
* @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.
84117
*
85118
* If `callback` throws a synchronous exception, or if it returns a promise that rejects,
86119
* then the loop stops immediately. Any remaining array items will be skipped, and
@@ -90,16 +123,42 @@ export class Async {
90123
* @param callback - a function that starts an asynchronous promise for an element
91124
* from the array
92125
* @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`
93128
*/
94-
public static async forEachAsync<TEntry>(
129+
public static async mapAsync<TEntry extends IWeighted, TRetVal>(
95130
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>,
97156
options?: IAsyncParallelismOptions | undefined
98157
): Promise<void> {
99158
await new Promise<void>((resolve: () => void, reject: (error: Error) => void) => {
100159
const concurrency: number =
101160
options?.concurrency && options.concurrency > 0 ? options.concurrency : Infinity;
102-
let operationsInProgress: number = 0;
161+
let concurrentUnitsInProgress: number = 0;
103162

104163
const iterator: Iterator<TEntry> | AsyncIterator<TEntry> = (
105164
(iterable as Iterable<TEntry>)[Symbol.iterator] ||
@@ -111,18 +170,29 @@ export class Async {
111170
let promiseHasResolvedOrRejected: boolean = false;
112171

113172
async function queueOperationsAsync(): Promise<void> {
114-
while (operationsInProgress < concurrency && !iteratorIsComplete && !promiseHasResolvedOrRejected) {
173+
while (
174+
concurrentUnitsInProgress < concurrency &&
175+
!iteratorIsComplete &&
176+
!promiseHasResolvedOrRejected
177+
) {
115178
// Increment the concurrency while waiting for the iterator.
116179
// This function is reentrant, so this ensures that at most `concurrency` executions are waiting
117-
operationsInProgress++;
180+
concurrentUnitsInProgress++;
118181
const currentIteratorResult: IteratorResult<TEntry> = await iterator.next();
119182
// eslint-disable-next-line require-atomic-updates
120183
iteratorIsComplete = !!currentIteratorResult.done;
121184

122185
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++))
124194
.then(async () => {
125-
operationsInProgress--;
195+
concurrentUnitsInProgress -= weight;
126196
await onOperationCompletionAsync();
127197
})
128198
.catch((error) => {
@@ -131,7 +201,7 @@ export class Async {
131201
});
132202
} else {
133203
// The iterator is complete and there wasn't a value, so untrack the waiting state.
134-
operationsInProgress--;
204+
concurrentUnitsInProgress--;
135205
}
136206
}
137207

@@ -142,7 +212,7 @@ export class Async {
142212

143213
async function onOperationCompletionAsync(): Promise<void> {
144214
if (!promiseHasResolvedOrRejected) {
145-
if (operationsInProgress === 0 && iteratorIsComplete) {
215+
if (concurrentUnitsInProgress === 0 && iteratorIsComplete) {
146216
promiseHasResolvedOrRejected = true;
147217
resolve();
148218
} else if (!iteratorIsComplete) {
@@ -158,6 +228,68 @@ export class Async {
158228
});
159229
}
160230

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+
161293
/**
162294
* Return a promise that resolves after the specified number of milliseconds.
163295
*/
@@ -190,6 +322,19 @@ export class Async {
190322
}
191323
}
192324

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+
193338
/**
194339
* Returns a Signal, a.k.a. a "deferred promise".
195340
*/

libraries/node-core-library/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
*/
99

1010
export { AlreadyReportedError } from './AlreadyReportedError';
11-
export { Async, AsyncQueue, IAsyncParallelismOptions, IRunWithRetriesOptions } from './Async';
11+
export { Async, AsyncQueue, IAsyncParallelismOptions, IRunWithRetriesOptions, IWeighted } from './Async';
1212
export { Brand } from './PrimitiveTypes';
1313
export { FileConstants, FolderConstants } from './Constants';
1414
export { Enum } from './Enum';

0 commit comments

Comments
 (0)