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
[rush] When using cobuilds, no-op operations should not be treated as uncacheable #4660
base: main
Are you sure you want to change the base?
[rush] When using cobuilds, no-op operations should not be treated as uncacheable #4660
Conversation
libraries/rush-lib/src/logic/operations/CacheableOperationPlugin.ts
Outdated
Show resolved
Hide resolved
Example output with
|
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.
Mechanically this seems fine, though it will result in the Rush build cache engine doing considerably more work for noops than it used to, unless we still have logic to skip the actual cache reads/writes for noops.
libraries/rush-lib/src/logic/operations/CacheableOperationPlugin.ts
Outdated
Show resolved
Hide resolved
libraries/rush-lib/src/logic/operations/CacheableOperationPlugin.ts
Outdated
Show resolved
Hide resolved
libraries/rush-lib/src/logic/operations/CacheableOperationPlugin.ts
Outdated
Show resolved
Hide resolved
libraries/rush-lib/src/logic/operations/CacheableOperationPlugin.ts
Outdated
Show resolved
Hide resolved
@dmichon-msft I think no-ops should still be skipped bc the null operation runner has |
common/reviews/api/rush-lib.api.md
Outdated
@@ -1384,7 +1384,7 @@ export class RushLifecycleHooks { | |||
// @alpha | |||
export class RushProjectConfiguration { | |||
readonly disableBuildCacheForProject: boolean; | |||
getCacheDisabledReason(trackedFileNames: Iterable<string>, phaseName: string): string | undefined; | |||
getCacheDisabledReason(operation: Operation, trackedFileNames: Iterable<string>, phaseName: string): string | undefined; |
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.
This is technically a breaking change, even though the API is alpha
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.
If I flipped it to
getCacheDisabledReason(trackedFileNames: Iterable<string>, phaseName: string, operation?: Operation): string | undefined;
that would be less breaking right?
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.
@iclanton I'm not sure I agree with this recommendation. It seems like in the current the getCacheDisabledReason()
is not going to produce correct results unless the Operation
is included, so it is arguably a better experience for people's plugins to have a compile error about a missing function parameter, rather than silently appearing to compile while producing a wrong result. (In general, we should be cautious about introducing optional parameters in public APIs, because they frequently produce error-prone contracts.)
Is there actually a realistic concern about third-party Rush plugins using this @alpha
API? If so, as a compromise what we could do is make operation
into a required parameter, but put it last and retain the operation?.runner
test in case an old plugin calls the API incorrectly (because it was compiled using an older Rush .d.ts file).
(Although it's questionable how much risk there is for this contract, given that it is @alpha
.)
Co-authored-by: David Michon <[email protected]>
Co-authored-by: Ian Clanton-Thuon <[email protected]>
b9a70cf
to
3ecc6cc
Compare
@iclanton @dmichon-msft Are we ready to merge this? This PR has been open for nearly a month. |
// Skip no-op operations as they won't have any output/cacheable things. | ||
if (operation?.runner?.isNoOp) { | ||
return undefined; | ||
} |
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.
We should actually check this first; if the operation is a noop it doesn't matter if it is marked as uncacheable or not.
public getCacheDisabledReason( | ||
trackedFileNames: Iterable<string>, | ||
phaseName: string, | ||
operation?: Operation |
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.
Pass isNoOp
directly? Or just have the caller check it? I'm not comfortable with a configuration file object (which RushProjectConfiguration is) taking a dependency on the execution logic layer, even only for types.
That'll also make your unit tests much simpler.
const fileHashes: Map<string, string> | undefined = | ||
await projectChangeAnalyzer._tryGetProjectDependenciesAsync(associatedProject, terminal); | ||
const cacheDisabledReason: string | undefined = projectConfiguration | ||
? projectConfiguration.getCacheDisabledReason(fileHashes!.keys(), associatedPhase.name, operation) |
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.
? projectConfiguration.getCacheDisabledReason(fileHashes!.keys(), associatedPhase.name, operation) | |
? projectConfiguration.getCacheDisabledReason(fileHashes!.keys(), associatedPhase.name, operation?.runner?.isNoOp) |
That avoids a lot of coupling.
} | ||
|
||
function generateCobuildPlanSummary(operations: Operation[], terminal: ITerminal): ICobuildPlan['summary'] { | ||
const noOpOperations: Set<Operation> = new Set(operations.filter((e) => e.runner?.isNoOp)); |
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.
It's probably cheaper to just define:
function isNoOp(operation: Operation): boolean {
return operation?.runner?.isNoOp === true;
}
And call that instead of creating the set. I expect the lookup cost of the property access is negligible and I think it's easier to read calling the function.
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.
Heck, feel free to add a get isNoOp() { return !!this.runner?.isNoOp; }
on Operation
.
if (numberOfConsumers === 0) { | ||
leafQueue.push(operation); | ||
} |
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.
Just because your only consumer is a noop does not mean that you are a leaf of the build tree. You are only a leaf if all of your consumers' consumers are noops all the way down.
A -> noop -> B is completely valid. The graph construction deliberately leaves in such nodes, since more typically that looks like:
A -\ /- E
B -- > D < -- F
C -/ \- G
let currentLeafNodes: Set<Operation> = new Set<Operation>(); | ||
const remainingOperations: Set<Operation> = new Set<Operation>(operations); | ||
let depth: number = 0; | ||
let maxWidth: number = leafQueue.filter((e) => !e.runner?.isNoOp).length; |
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.
On line 86 you already filtered out all noops from leafQueue
, so this expression is exactly equal to leafQueue.length
.
@@ -126,7 +126,7 @@ export class CacheableOperationPlugin implements IPhasedCommandPlugin { | |||
const operationSettings: IOperationSettings | undefined = | |||
projectConfiguration?.operationSettingsByOperationName.get(phaseName); | |||
const cacheDisabledReason: string | undefined = projectConfiguration | |||
? projectConfiguration.getCacheDisabledReason(fileHashes.keys(), phaseName) | |||
? projectConfiguration.getCacheDisabledReason(fileHashes.keys(), phaseName, operation) |
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.
? projectConfiguration.getCacheDisabledReason(fileHashes.keys(), phaseName, operation) | |
? projectConfiguration.getCacheDisabledReason(fileHashes.keys(), phaseName, operation?.runner?.isNoOp) |
if (remainingOperations.has(leaf)) { | ||
remainingOperations.delete(leaf); |
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.
if (remainingOperations.has(leaf)) { | |
remainingOperations.delete(leaf); | |
if (remainingOperations.delete(leaf)) { |
delete
returns a boolean
indicating if it did anything.
const numberOfNodes: number[] = [maxWidth]; | ||
const depthToOperationsMap: Map<number, Set<Operation>> = new Map<number, Set<Operation>>(); | ||
depthToOperationsMap.set(depth, new Set(leafQueue)); | ||
do { |
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.
This is presumably a standard well-known algorithm; could you put a comment with the name of the algorithm? Or a brief description of what it is supposed to be doing?
@@ -189,6 +190,12 @@ export class PhasedScriptAction extends BaseScriptAction<IPhasedCommandConfig> { | |||
' including an ASCII chart of the start and stop times for each operation.' | |||
}); | |||
} | |||
this._cobuildPlanParameter = this.defineFlagParameter({ | |||
parameterLongName: '--cobuild-plan', |
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.
parameterLongName: '--cobuild-plan', | |
parameterLongName: '--log-cobuild-plan', |
this._cobuildPlanParameter = this.defineFlagParameter({ | ||
parameterLongName: '--cobuild-plan', | ||
description: | ||
'Before the build starts, log information about the cobuild state. This will include information about ' + |
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.
'Before the build starts, log information about the cobuild state. This will include information about ' + | |
'(EXPERIMENTAL) Before the build starts, log information about the cobuild state. This will include information about ' + |
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.
Kinda feels like this should just log the plan instead of logging it and then executing it.
@@ -415,6 +423,16 @@ export class PhasedScriptAction extends BaseScriptAction<IPhasedCommandConfig> { | |||
terminal.writeVerboseLine(`Incremental strategy: none (full rebuild)`); | |||
} | |||
|
|||
const showBuildPlan: boolean = this._cobuildPlanParameter ? this._cobuildPlanParameter.value : false; |
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.
const showBuildPlan: boolean = this._cobuildPlanParameter ? this._cobuildPlanParameter.value : false; | |
const showBuildPlan: boolean = this._cobuildPlanParameter?.value ?? false; |
destination: mockStreamWritable | ||
}); | ||
|
||
describe('BuildPlanPlugin', () => { |
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.
describe('BuildPlanPlugin', () => { | |
describe(BuildPlanPlugin.name, () => { |
import { ProjectChangeAnalyzer } from '../../ProjectChangeAnalyzer'; | ||
|
||
const mockWritable: MockWritable = new MockWritable(); | ||
const mockTerminal: Terminal = new Terminal(new CollatedTerminalProvider(new CollatedTerminal(mockWritable))); |
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.
Why not just use StringBufferTerminalProvider
? It's designed for unit testing.
jest.mock('@rushstack/terminal', () => { | ||
const originalModule = jest.requireActual('@rushstack/terminal'); | ||
return { | ||
...originalModule, | ||
ConsoleTerminalProvider: { | ||
...originalModule.ConsoleTerminalProvider, | ||
supportsColor: true | ||
} | ||
}; | ||
}); |
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.
What's up with this mock?
const mockStreamWritable: MockWritable = new MockWritable(); | ||
const streamCollator = new StreamCollator({ | ||
destination: mockStreamWritable | ||
}); |
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.
Seems like this should get reinitialized before each test.
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.
I know there's only one test in this file right now, but it'll be confusing if someone adds another one later and sees output from a previously-run test.
exports[`BuildPlanPlugin build plan debugging should generate a build plan 1`] = ` | ||
"Build Plan Depth (deepest dependency tree): 5 | ||
Build Plan Width (maximum parallelism): 40 | ||
Number of Nodes per Depth: 1, 4, 11, 40, 34 |
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.
What does this mean?
@@ -0,0 +1,736 @@ | |||
// Jest Snapshot v1, https://goo.gl/fbAQLP | |||
|
|||
exports[`BuildPlanPlugin build plan debugging should generate a build plan 1`] = ` |
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.
When reading this, I'm not really sure what I'm looking at. There should at least be some extensive documentation on how to read the output from this flag.
Summary
This PR does 2 things,
ignoreMissingScript
ormissingScriptBehavior: silent
are treated as disabling the build cache causing all operations that depend on that operation to cluster. This causes some pretty extreme slow down for projects that use central dependencies that don't have build/test steps.Details
No-op operations now no longer play into build cache cluster calculations. I'm also adding some visibility to the output for cobuilds so users can understand when this is happening. This solves the initial issue I was having, but additional operations may still have problems. While this is a bug fix, there may be projects that are incorrectly set up to build with cobuilds. This change may expose that and cause additional work by repo owners. However, I expect that they thought they were cobuilding all along. The bug fix should improve performance, the extra logging may decrease improvement and may be best to set behind verbose logging.
How it was tested
Tested a few different ways,
rm -rf common/temp/build-cache && RUSH_COBUILD_CONTEXT_ID=foo REDIS_PASS=redis123 RUSH_COBUILD_RUNNER_ID=runner1 node ../../lib/runRush.js cobuild -p 10
and checking the output plan.disableBuildCacheForProject: true
to rush-project.json caused the expected drop in clusters, adding it to thee
project in thesharded-repo
project dropped the number of clusters from 227 to 127 as expected.