-
Notifications
You must be signed in to change notification settings - Fork 604
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[node-core-library][rush] Support weighted async concurrency #4092
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,10 @@ | ||
{ | ||
"changes": [ | ||
{ | ||
"packageName": "@microsoft/rush", | ||
"comment": "Allow operations to have variable weight for concurrency purposes.", | ||
"type": "none" | ||
} | ||
], | ||
"packageName": "@microsoft/rush" | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,10 @@ | ||
{ | ||
"changes": [ | ||
{ | ||
"packageName": "@rushstack/node-core-library", | ||
"comment": "Support variable concurrency based on task weights in `Async.forEachAsync`.", | ||
"type": "minor" | ||
} | ||
], | ||
"packageName": "@rushstack/node-core-library" | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -33,7 +33,9 @@ export class AnsiEscape { | |
|
||
// @beta | ||
export class Async { | ||
static forEachAsync<TEntry>(iterable: Iterable<TEntry> | AsyncIterable<TEntry>, callback: (entry: TEntry, arrayIndex: number) => Promise<void>, options?: IAsyncParallelismOptions | undefined): Promise<void>; | ||
static forEachAsync<TEntry>(iterable: Iterable<TEntry> | AsyncIterable<TEntry>, callback: (entry: TEntry, arrayIndex: number) => Promise<void>, options?: TEntry extends { | ||
weight?: number; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This type algebra seems overcomplicated. I think is trying to say that IF the options specifies I think it might be better to simply say that "If This would also eliminate the need for a separate There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The check on the optional property is to ensure that the |
||
} ? IAsyncParallelismOptionsWithWeight : IAsyncParallelismOptions | undefined): Promise<void>; | ||
static mapAsync<TEntry, TRetVal>(iterable: Iterable<TEntry> | AsyncIterable<TEntry>, callback: (entry: TEntry, arrayIndex: number) => Promise<TRetVal>, options?: IAsyncParallelismOptions | undefined): Promise<TRetVal[]>; | ||
static runWithRetriesAsync<TResult>({ action, maxRetries, retryDelayMs }: IRunWithRetriesOptions<TResult>): Promise<TResult>; | ||
static sleep(ms: number): Promise<void>; | ||
|
@@ -319,6 +321,11 @@ export interface IAsyncParallelismOptions { | |
concurrency?: number; | ||
} | ||
|
||
// @beta | ||
export interface IAsyncParallelismOptionsWithWeight extends IAsyncParallelismOptions { | ||
useWeight?: boolean; | ||
} | ||
|
||
// @beta (undocumented) | ||
export interface IColorableSequence { | ||
// (undocumented) | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -17,6 +17,21 @@ export interface IAsyncParallelismOptions { | |
concurrency?: number; | ||
} | ||
|
||
/** | ||
* Options for controlling the parallelism of asynchronous operations that have a property `weight?: number`. | ||
* | ||
* @remarks | ||
* Used with {@link Async.forEachAsync}. | ||
* | ||
* @beta | ||
*/ | ||
export interface IAsyncParallelismOptionsWithWeight extends IAsyncParallelismOptions { | ||
/** | ||
* If set, then will use `item.weight` instead of `1` as the parallelism consumed by a given iteration. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This needs more explanation. I had to read the code carefully to figure out what There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Try something like this: /**
* Optionally used with the {@link Async.mapAsync} and {@link Async.forEachAsync}
* to limit amount of concurrency.
* @remarks
* By default, `concurrency` simply specifies the maximum number of promises that can be active
* simultaneously. However if `useWeight` is true, then behavior
* is different. See the {@link IAsyncParallelismOptions.useWeight} documentation for details.
*/
concurrency?: number;
/**
* Set `useWeight` to true to configure the {@link IAsyncParallelismOptions.concurrency}
* setting to limit the total resource cost instead of limiting the total number of promises.
* @remarks
* By default, the `concurrency` specifies the maximum number of promises that can be
* active simultaneously. If promises have different resource costs, and those costs are known
* in advance, then `useWeight` changes `concurrency` so that it limits the sum of the weights
* of the entries passed to {@link Async.mapAsync} or {@link Async.forEachAsync}.
*
* The {@link IAsyncEntry.weight} property is optional and defaults to 1. (If all entries
* use the default of 1, then the behavior is identical to `useWeight=false`.)
* Weights can be fractional but not negative.
*/
useWeight?: boolean; /**
* If {@link Async.mapAsync} or {@link Async.forEachAsync} are used to parallelize tasks
* that have different resource costs, and this property can be used to specify the resource
* cost so that it can be limited by {@link IAsyncParallelismOptions.concurrency}.
* @remarks
* This property is ignored unless `useWeight` is true. See the {@link
* IAsyncParallelismOptions.useWeight} documentation for details.
*/
weight?: number There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is Also we are asking to assign There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The name I'm also open to having an altogether separate entry point for tasks whose CPU resource needs are not uniformly 1. |
||
*/ | ||
useWeight?: boolean; | ||
} | ||
|
||
/** | ||
* @remarks | ||
* Used with {@link Async.runWithRetriesAsync}. | ||
|
@@ -94,68 +109,54 @@ export class Async { | |
public static async forEachAsync<TEntry>( | ||
iterable: Iterable<TEntry> | AsyncIterable<TEntry>, | ||
callback: (entry: TEntry, arrayIndex: number) => Promise<void>, | ||
options?: IAsyncParallelismOptions | undefined | ||
options?: TEntry extends { weight?: number } | ||
? IAsyncParallelismOptionsWithWeight | ||
: IAsyncParallelismOptions | undefined | ||
): Promise<void> { | ||
await new Promise<void>((resolve: () => void, reject: (error: Error) => void) => { | ||
const concurrency: number = | ||
options?.concurrency && options.concurrency > 0 ? options.concurrency : Infinity; | ||
let operationsInProgress: number = 0; | ||
const concurrency: number = | ||
options?.concurrency && options.concurrency > 0 ? options.concurrency : Infinity; | ||
|
||
const iterator: Iterator<TEntry> | AsyncIterator<TEntry> = ( | ||
(iterable as Iterable<TEntry>)[Symbol.iterator] || | ||
(iterable as AsyncIterable<TEntry>)[Symbol.asyncIterator] | ||
).call(iterable); | ||
const useWeight: boolean | undefined = (options as IAsyncParallelismOptionsWithWeight)?.useWeight; | ||
|
||
let arrayIndex: number = 0; | ||
let iteratorIsComplete: boolean = false; | ||
let promiseHasResolvedOrRejected: boolean = false; | ||
const iterator: Iterator<TEntry> | AsyncIterator<TEntry> = ( | ||
(iterable as Iterable<TEntry>)[Symbol.iterator] || | ||
(iterable as AsyncIterable<TEntry>)[Symbol.asyncIterator] | ||
).call(iterable); | ||
|
||
async function queueOperationsAsync(): Promise<void> { | ||
while (operationsInProgress < concurrency && !iteratorIsComplete && !promiseHasResolvedOrRejected) { | ||
// Increment the concurrency while waiting for the iterator. | ||
// This function is reentrant, so this ensures that at most `concurrency` executions are waiting | ||
operationsInProgress++; | ||
const currentIteratorResult: IteratorResult<TEntry> = await iterator.next(); | ||
// eslint-disable-next-line require-atomic-updates | ||
iteratorIsComplete = !!currentIteratorResult.done; | ||
let arrayIndex: number = 0; | ||
let usedCapacity: number = 0; | ||
|
||
if (!iteratorIsComplete) { | ||
Promise.resolve(callback(currentIteratorResult.value, arrayIndex++)) | ||
.then(async () => { | ||
operationsInProgress--; | ||
await onOperationCompletionAsync(); | ||
}) | ||
.catch((error) => { | ||
promiseHasResolvedOrRejected = true; | ||
reject(error); | ||
}); | ||
} else { | ||
// The iterator is complete and there wasn't a value, so untrack the waiting state. | ||
operationsInProgress--; | ||
} | ||
} | ||
const pending: Set<Promise<void>> = new Set(); | ||
|
||
if (iteratorIsComplete) { | ||
await onOperationCompletionAsync(); | ||
} | ||
// eslint-disable-next-line no-constant-condition | ||
while (true) { | ||
const currentIteratorResult: IteratorResult<TEntry> = await iterator.next(); | ||
if (currentIteratorResult.done) { | ||
break; | ||
} | ||
|
||
async function onOperationCompletionAsync(): Promise<void> { | ||
if (!promiseHasResolvedOrRejected) { | ||
if (operationsInProgress === 0 && iteratorIsComplete) { | ||
promiseHasResolvedOrRejected = true; | ||
resolve(); | ||
} else if (!iteratorIsComplete) { | ||
await queueOperationsAsync(); | ||
} | ||
} | ||
const { value } = currentIteratorResult; | ||
const weight: number = useWeight ? (value as { weight?: number })?.weight ?? 1 : 1; | ||
if (weight < 0) { | ||
throw new Error(`Invalid weight ${weight}. Weights must be greater than or equal to 0.`); | ||
} | ||
|
||
queueOperationsAsync().catch((error) => { | ||
promiseHasResolvedOrRejected = true; | ||
reject(error); | ||
usedCapacity += weight; | ||
const promise: Promise<void> = Promise.resolve(callback(value, arrayIndex++)).then(() => { | ||
usedCapacity -= weight; | ||
pending.delete(promise); | ||
}); | ||
}); | ||
pending.add(promise); | ||
|
||
// eslint-disable-next-line no-unmodified-loop-condition | ||
while (usedCapacity >= concurrency && pending.size > 0) { | ||
await Promise.race(Array.from(pending)); | ||
} | ||
} | ||
|
||
if (pending.size > 0) { | ||
await Promise.all(Array.from(pending)); | ||
} | ||
} | ||
|
||
/** | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
{ weight?: number }
must be declared as an interface, so that we have some place to document theweight
property.