Skip to content

[rush] feat(buildcache): improve access to operation build cache #5058

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

Merged
merged 11 commits into from
Jan 8, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"changes": [
{
"comment": "Simplifies the process of going from operation to build cache ID.",
"type": "none",
"packageName": "@microsoft/rush"
}
],
"packageName": "@microsoft/rush",
"email": "aramissennyeydd@users.noreply.github.com"
}
45 changes: 37 additions & 8 deletions libraries/rush-lib/src/logic/buildCache/ProjectBuildCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,33 +13,37 @@ import type { ICloudBuildCacheProvider } from './ICloudBuildCacheProvider';
import type { FileSystemBuildCacheProvider } from './FileSystemBuildCacheProvider';
import { TarExecutable } from '../../utilities/TarExecutable';
import { EnvironmentVariableNames } from '../../api/EnvironmentConfiguration';
import type { OperationExecutionRecord } from '../operations/OperationExecutionRecord';

export interface IProjectBuildCacheOptions {
export interface IOperationBuildCacheOptions {
/**
* The repo-wide configuration for the build cache.
*/
buildCacheConfiguration: BuildCacheConfiguration;
/**
* The project to be cached.
* The terminal to use for logging.
*/
project: RushConfigurationProject;
terminal: ITerminal;
}

export type IProjectBuildCacheOptions = IOperationBuildCacheOptions & {
/**
* Value from rush-project.json
*/
projectOutputFolderNames: ReadonlyArray<string>;
/**
* The hash of all relevant inputs and configuration that uniquely identifies this execution.
* The project to be cached.
*/
operationStateHash: string;
project: RushConfigurationProject;
/**
* The terminal to use for logging.
* The hash of all relevant inputs and configuration that uniquely identifies this execution.
*/
terminal: ITerminal;
operationStateHash: string;
/**
* The name of the phase that is being cached.
*/
phaseName: string;
}
};

interface IPathsToCache {
filteredOutputFolderNames: string[];
Expand Down Expand Up @@ -94,6 +98,31 @@ export class ProjectBuildCache {
return new ProjectBuildCache(cacheId, options);
}

public static forOperation(
operation: OperationExecutionRecord,
options: IOperationBuildCacheOptions
): ProjectBuildCache {
if (!operation.associatedProject) {
throw new InternalError('Operation must have an associated project');
}
if (!operation.associatedPhase) {
throw new InternalError('Operation must have an associated phase');
}
const outputFolders: string[] = [...(operation.operation.settings?.outputFolderNames ?? [])];
if (operation.metadataFolderPath) {
outputFolders.push(operation.metadataFolderPath);
}
const buildCacheOptions: IProjectBuildCacheOptions = {
...options,
project: operation.associatedProject,
phaseName: operation.associatedPhase.name,
projectOutputFolderNames: outputFolders,
operationStateHash: operation.stateHash
};
const cacheId: string | undefined = ProjectBuildCache._getCacheId(buildCacheOptions);
return new ProjectBuildCache(cacheId, buildCacheOptions);
}

public async tryRestoreFromCacheAsync(terminal: ITerminal, specifiedCacheId?: string): Promise<boolean> {
const cacheId: string | undefined = specifiedCacheId || this._cacheId;
if (!cacheId) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ import type { IPhase } from '../../api/CommandLineConfiguration';
import type { BuildCacheConfiguration } from '../../api/BuildCacheConfiguration';
import type { IOperationExecutionResult } from './IOperationExecutionResult';
import type { OperationExecutionRecord } from './OperationExecutionRecord';
import type { IInputsSnapshot } from '../incremental/InputsSnapshot';

const PLUGIN_NAME: 'CacheablePhasedOperationPlugin' = 'CacheablePhasedOperationPlugin';
const PERIODIC_CALLBACK_INTERVAL_IN_SECONDS: number = 10;
Expand Down Expand Up @@ -88,8 +87,6 @@ export class CacheableOperationPlugin implements IPhasedCommandPlugin {
public apply(hooks: PhasedCommandHooks): void {
const { allowWarningsInSuccessfulBuild, buildCacheConfiguration, cobuildConfiguration } = this._options;

const { cacheHashSalt } = buildCacheConfiguration;

hooks.beforeExecuteOperations.tap(
PLUGIN_NAME,
(
Expand All @@ -104,76 +101,15 @@ export class CacheableOperationPlugin implements IPhasedCommandPlugin {
);
}

// This redefinition is necessary due to limitations in TypeScript's control flow analysis, due to the nested closure.
const definitelyDefinedInputsSnapshot: IInputsSnapshot = inputsSnapshot;

const disjointSet: DisjointSet<Operation> | undefined = cobuildConfiguration?.cobuildFeatureEnabled
? new DisjointSet()
: undefined;

const hashByOperation: Map<Operation, string> = new Map();
// Build cache hashes are computed up front to ensure stability and to catch configuration errors early.
function getOrCreateOperationHash(operation: Operation): string {
const cachedHash: string | undefined = hashByOperation.get(operation);
if (cachedHash !== undefined) {
return cachedHash;
}

// Examples of data in the config hash:
// - CLI parameters (ShellOperationRunner)
const configHash: string | undefined = operation.runner?.getConfigHash();

const { associatedProject, associatedPhase } = operation;
// Examples of data in the local state hash:
// - Environment variables specified in `dependsOnEnvVars`
// - Git hashes of tracked files in the associated project
// - Git hash of the shrinkwrap file for the project
// - Git hashes of any files specified in `dependsOnAdditionalFiles` (must not be associated with a project)
const localStateHash: string | undefined =
associatedProject &&
definitelyDefinedInputsSnapshot.getOperationOwnStateHash(
associatedProject,
associatedPhase?.name
);

// The final state hashes of operation dependencies are factored into the hash to ensure that any
// state changes in dependencies will invalidate the cache.
const dependencyHashes: string[] = Array.from(operation.dependencies, getDependencyHash).sort();

const hasher: crypto.Hash = crypto.createHash('sha1');
// This property is used to force cache bust when version changes, e.g. when fixing bugs in the content
// of the build cache.
hasher.update(`${RushConstants.buildCacheVersion}`);

if (cacheHashSalt !== undefined) {
// This allows repository owners to force a cache bust by changing the salt.
// A common use case is to invalidate the cache when adding/removing/updating rush plugins that alter the build output.
hasher.update(cacheHashSalt);
}

for (const dependencyHash of dependencyHashes) {
hasher.update(dependencyHash);
}

if (localStateHash) {
hasher.update(`${RushConstants.hashDelimiter}${localStateHash}`);
}

if (configHash) {
hasher.update(`${RushConstants.hashDelimiter}${configHash}`);
}

const hashString: string = hasher.digest('hex');

hashByOperation.set(operation, hashString);
return hashString;
}

function getDependencyHash(operation: Operation): string {
return `${RushConstants.hashDelimiter}${operation.name}=${getOrCreateOperationHash(operation)}`;
}

for (const [operation, record] of recordByOperation) {
const stateHash: string = (record as OperationExecutionRecord).calculateStateHash({
inputsSnapshot,
buildCacheConfiguration
});
const { associatedProject, associatedPhase, runner, settings: operationSettings } = operation;
if (!associatedProject || !associatedPhase || !runner) {
return;
Expand All @@ -188,7 +124,6 @@ export class CacheableOperationPlugin implements IPhasedCommandPlugin {
// depending on the selected phase.
const fileHashes: ReadonlyMap<string, string> | undefined =
inputsSnapshot.getTrackedFileHashesForOperation(associatedProject, phaseName);
const stateHash: string = getOrCreateOperationHash(operation);

const cacheDisabledReason: string | undefined = projectConfiguration
? projectConfiguration.getCacheDisabledReason(fileHashes.keys(), phaseName, operation.isNoOp)
Expand Down Expand Up @@ -325,10 +260,8 @@ export class CacheableOperationPlugin implements IPhasedCommandPlugin {
let projectBuildCache: ProjectBuildCache | undefined = this._tryGetProjectBuildCache({
buildCacheContext,
buildCacheConfiguration,
rushProject: project,
phase,
terminal: buildCacheTerminal,
operation: operation
record
});

// Try to acquire the cobuild lock
Expand Down Expand Up @@ -639,39 +572,30 @@ export class CacheableOperationPlugin implements IPhasedCommandPlugin {
private _tryGetProjectBuildCache({
buildCacheConfiguration,
buildCacheContext,
rushProject,
phase,
terminal,
operation
record
}: {
buildCacheContext: IOperationBuildCacheContext;
buildCacheConfiguration: BuildCacheConfiguration | undefined;
rushProject: RushConfigurationProject;
phase: IPhase;
terminal: ITerminal;
operation: Operation;
record: OperationExecutionRecord;
}): ProjectBuildCache | undefined {
if (!buildCacheContext.operationBuildCache) {
const { cacheDisabledReason } = buildCacheContext;
if (cacheDisabledReason && !operation.settings?.allowCobuildWithoutCache) {
if (cacheDisabledReason && !record.operation.settings?.allowCobuildWithoutCache) {
terminal.writeVerboseLine(cacheDisabledReason);
return;
}

const { outputFolderNames, stateHash: operationStateHash } = buildCacheContext;
if (!outputFolderNames || !buildCacheConfiguration) {
if (!buildCacheConfiguration) {
// Unreachable, since this will have set `cacheDisabledReason`.
return;
}

// eslint-disable-next-line require-atomic-updates -- This is guaranteed to not be concurrent
buildCacheContext.operationBuildCache = ProjectBuildCache.getProjectBuildCache({
project: rushProject,
projectOutputFolderNames: outputFolderNames,
buildCacheContext.operationBuildCache = ProjectBuildCache.forOperation(record, {
buildCacheConfiguration,
terminal,
operationStateHash,
phaseName: phase.name
terminal
});
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
// See LICENSE in the project root for license information.
import * as crypto from 'crypto';

import {
type ITerminal,
Expand Down Expand Up @@ -29,6 +30,9 @@ import {
initializeProjectLogFilesAsync
} from './ProjectLogWritable';
import type { IOperationExecutionResult } from './IOperationExecutionResult';
import type { IInputsSnapshot } from '../incremental/InputsSnapshot';
import { RushConstants } from '../RushConstants';
import type { BuildCacheConfiguration } from '../../api/BuildCacheConfiguration';

export interface IOperationExecutionRecordContext {
streamCollator: StreamCollator;
Expand Down Expand Up @@ -114,6 +118,7 @@ export class OperationExecutionRecord implements IOperationRunnerContext, IOpera

private _collatedWriter: CollatedWriter | undefined = undefined;
private _status: OperationStatus;
private _stateHash: string | undefined;

public constructor(operation: Operation, context: IOperationExecutionRecordContext) {
const { runner, associatedPhase, associatedProject } = operation;
Expand Down Expand Up @@ -206,6 +211,15 @@ export class OperationExecutionRecord implements IOperationRunnerContext, IOpera
return !this.operation.enabled || this.runner.silent;
}

public get stateHash(): string {
if (!this._stateHash) {
throw new Error(
'Operation state hash is not calculated yet, you must call `calculateStateHash` first.'
);
}
return this._stateHash;
}

/**
* {@inheritdoc IOperationRunnerContext.runWithTerminalAsync}
*/
Expand Down Expand Up @@ -335,4 +349,61 @@ export class OperationExecutionRecord implements IOperationRunnerContext, IOpera
}
}
}

public calculateStateHash(options: {
inputsSnapshot: IInputsSnapshot;
buildCacheConfiguration: BuildCacheConfiguration;
}): string {
if (!this._stateHash) {
const {
inputsSnapshot,
buildCacheConfiguration: { cacheHashSalt }
} = options;

// Examples of data in the config hash:
// - CLI parameters (ShellOperationRunner)
const configHash: string = this.runner.getConfigHash();

const { associatedProject, associatedPhase } = this;
// Examples of data in the local state hash:
// - Environment variables specified in `dependsOnEnvVars`
// - Git hashes of tracked files in the associated project
// - Git hash of the shrinkwrap file for the project
// - Git hashes of any files specified in `dependsOnAdditionalFiles` (must not be associated with a project)
const localStateHash: string | undefined =
associatedProject &&
inputsSnapshot.getOperationOwnStateHash(associatedProject, associatedPhase?.name);

// The final state hashes of operation dependencies are factored into the hash to ensure that any
// state changes in dependencies will invalidate the cache.
const dependencyHashes: string[] = Array.from(this.dependencies, (record) => {
return `${RushConstants.hashDelimiter}${record.name}=${record.calculateStateHash(options)}`;
}).sort();

const hasher: crypto.Hash = crypto.createHash('sha1');
// This property is used to force cache bust when version changes, e.g. when fixing bugs in the content
// of the build cache.
hasher.update(`${RushConstants.buildCacheVersion}`);

if (cacheHashSalt !== undefined) {
// This allows repository owners to force a cache bust by changing the salt.
// A common use case is to invalidate the cache when adding/removing/updating rush plugins that alter the build output.
hasher.update(cacheHashSalt);
}

for (const dependencyHash of dependencyHashes) {
hasher.update(dependencyHash);
}

if (localStateHash) {
hasher.update(`${RushConstants.hashDelimiter}${localStateHash}`);
}

hasher.update(`${RushConstants.hashDelimiter}${configHash}`);

const hash: string = hasher.digest('hex');
this._stateHash = hash;
}
return this._stateHash;
}
}
Loading