@rushstack/operation-graph
Version:
Library for managing and executing operations in a directed acyclic graph.
218 lines • 9.97 kB
JavaScript
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
// See LICENSE in the project root for license information.
import { InternalError } from '@rushstack/node-core-library';
import { Stopwatch } from './Stopwatch';
import { OperationStatus } from './OperationStatus';
/**
* The `Operation` class is a node in the dependency graph of work that needs to be scheduled by the
* `OperationExecutionManager`. Each `Operation` has a `runner` member of type `IOperationRunner`, whose
* implementation manages the actual process of running a single operation.
*
* The graph of `Operation` instances will be cloned into a separate execution graph after processing.
*
* @beta
*/
export class Operation {
constructor(options) {
/**
* A set of all dependencies which must be executed before this operation is complete.
*/
this.dependencies = new Set();
/**
* A set of all operations that wait for this operation.
*/
this.consumers = new Set();
/**
* When the scheduler is ready to process this `Operation`, the `runner` implements the actual work of
* running the operation.
*/
this.runner = undefined;
/**
* This number represents how far away this Operation is from the furthest "root" operation (i.e.
* an operation with no consumers). This helps us to calculate the critical path (i.e. the
* longest chain of projects which must be executed in order, thereby limiting execution speed
* of the entire operation tree.
*
* This number is calculated via a memoized depth-first search, and when choosing the next
* operation to execute, the operation with the highest criticalPathLength is chosen.
*
* Example:
* (0) A
* \\
* (1) B C (0) (applications)
* \\ /|\\
* \\ / | \\
* (2) D | X (1) (utilities)
* | / \\
* |/ \\
* (2) Y Z (2) (other utilities)
*
* All roots (A & C) have a criticalPathLength of 0.
* B has a score of 1, since A depends on it.
* D has a score of 2, since we look at the longest chain (e.g D-\>B-\>A is longer than D-\>C)
* X has a score of 1, since the only package which depends on it is A
* Z has a score of 2, since only X depends on it, and X has a score of 1
* Y has a score of 2, since the chain Y-\>X-\>C is longer than Y-\>C
*
* The algorithm is implemented in AsyncOperationQueue.ts as calculateCriticalPathLength()
*/
this.criticalPathLength = undefined;
/**
* The state of this operation the previous time a manager was invoked.
*/
this.lastState = undefined;
/**
* The current state of this operation
*/
this.state = undefined;
/**
* A cached execution promise for the current OperationExecutionManager invocation of this operation.
*/
this._promise = undefined;
/**
* If true, then a run of this operation is currently wanted.
* This is used to track state from the `requestRun` callback passed to the runner.
*/
this._runPending = true;
this.group = options.group;
this.runner = options.runner;
this.weight = options.weight ?? 1;
this.name = options.name;
this.metadata = options.metadata || {};
if (this.group) {
this.group.addOperation(this);
}
}
addDependency(dependency) {
this.dependencies.add(dependency);
dependency.consumers.add(this);
}
deleteDependency(dependency) {
this.dependencies.delete(dependency);
dependency.consumers.delete(this);
}
reset() {
// Reset operation state
this.lastState = this.state;
this.state = {
status: this.dependencies.size > 0 ? OperationStatus.Waiting : OperationStatus.Ready,
hasBeenRun: this.lastState?.hasBeenRun ?? false,
error: undefined,
stopwatch: new Stopwatch()
};
this._promise = undefined;
this._runPending = true;
}
/**
* @internal
*/
async _executeAsync(context) {
const { state } = this;
if (!state) {
throw new Error(`Operation state has not been initialized.`);
}
if (!this._promise) {
this._promise = this._executeInnerAsync(context, state);
}
return this._promise;
}
async _executeInnerAsync(context, rawState) {
const state = rawState;
const { runner } = this;
const dependencyResults = await Promise.allSettled(Array.from(this.dependencies, (dependency) => dependency._executeAsync(context)));
const { abortSignal, requestRun, queueWork } = context;
if (abortSignal.aborted) {
state.status = OperationStatus.Aborted;
return state.status;
}
for (const result of dependencyResults) {
if (result.status === 'rejected' ||
result.value === OperationStatus.Blocked ||
result.value === OperationStatus.Failure) {
state.status = OperationStatus.Blocked;
return state.status;
}
}
state.status = OperationStatus.Ready;
const innerContext = {
abortSignal,
isFirstRun: !state.hasBeenRun,
requestRun: requestRun
? (detail) => {
switch (this.state?.status) {
case OperationStatus.Waiting:
case OperationStatus.Ready:
case OperationStatus.Executing:
// If current status has not yet resolved to a fixed value,
// re-executing this operation does not require a full rerun
// of the operation graph. Simply mark that a run is requested.
// This variable is on the Operation instead of the
// containing closure to deal with scenarios in which
// the runner hangs on to an old copy of the callback.
this._runPending = true;
return;
case OperationStatus.Blocked:
case OperationStatus.Aborted:
case OperationStatus.Failure:
case OperationStatus.NoOp:
case OperationStatus.Success:
// The requestRun callback is assumed to remain constant
// throughout the lifetime of the process, so it is safe
// to capture here.
return requestRun(this.name, detail);
default:
// This line is here to enforce exhaustiveness
const currentStatus = this.state?.status;
throw new InternalError(`Unexpected status: ${currentStatus}`);
}
}
: undefined
};
// eslint-disable-next-line require-atomic-updates
state.status = await queueWork(async () => {
// Redundant variable to satisfy require-atomic-updates
const innerState = state;
if (abortSignal.aborted) {
innerState.status = OperationStatus.Aborted;
return innerState.status;
}
context.beforeExecute(this, innerState);
innerState.stopwatch.start();
innerState.status = OperationStatus.Executing;
// Mark that the operation has been started at least once.
innerState.hasBeenRun = true;
while (this._runPending) {
this._runPending = false;
try {
// We don't support aborting in the middle of a runner's execution.
innerState.status = runner ? await runner.executeAsync(innerContext) : OperationStatus.NoOp;
}
catch (error) {
innerState.status = OperationStatus.Failure;
innerState.error = error;
}
// Since runner.executeAsync is async, a change could have occurred that requires re-execution
// This operation is still active, so can re-execute immediately, rather than forcing a whole
// new execution pass.
// As currently written, this does mean that if a job is scheduled with higher priority while
// this operation is still executing, it will still wait for this retry. This may not be desired
// and if it becomes a problem, the retry loop will need to be moved outside of the `queueWork` call.
// This introduces complexity regarding tracking of timing and start/end logging, however.
if (this._runPending) {
if (abortSignal.aborted) {
innerState.status = OperationStatus.Aborted;
break;
}
else {
context.terminal.writeLine(`Immediate rerun requested. Executing.`);
}
}
}
state.stopwatch.stop();
context.afterExecute(this, state);
return state.status;
}, /* priority */ this.criticalPathLength ?? 0);
return state.status;
}
}
//# sourceMappingURL=Operation.js.map