mirror of
https://github.com/awalsh128/cache-apt-pkgs-action.git
synced 2026-11-13 21:36:56 +00:00
perf: speed up cache restores
- Upload the diagnostic artifact only when debug is on or the action fails. The upload took ~1.4s of a ~3.7s restore in CI. - Extract all package archives in one sudo shell when install scripts are off, instead of a sudo and tar process per package. Per-package extraction stays when scripts run, to keep install ordering (#196). - Skip the apt-fast availability probe on a cache hit, where nothing is installed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
e4f2f46b93
commit
2e80d3bfa8
12
action.yml
12
action.yml
|
|
@ -29,7 +29,7 @@ inputs:
|
|||
required: false
|
||||
default: "error"
|
||||
debug:
|
||||
description: "Enable debugging when there are issues with action. Minor performance penalty."
|
||||
description: "Enable debugging when there are issues with action. Also uploads the action logs and manifests as a workflow artifact, which is otherwise done only when the action fails. Minor performance penalty."
|
||||
required: false
|
||||
default: "false"
|
||||
|
||||
|
|
@ -37,9 +37,15 @@ outputs:
|
|||
cache-hit:
|
||||
description: "A boolean value to indicate a cache was found for the packages requested."
|
||||
package-version-list:
|
||||
description: "The main requested packages and versions that are installed. Represented as a sorted comma delimited list with equals delimit on the package version (i.e. <package1>=<version1>,<package2>=<version2>,...). Packages installed for a non-native architecture include it (i.e. <package>:<arch>=<version>)."
|
||||
description: >
|
||||
The main requested packages and versions that are installed.
|
||||
Represented as a sorted comma delimited list with equals delimit on the package version (i.e. <package1>=<version1>,<package2>=<version2>,...).
|
||||
Packages installed for a non-native architecture include it (i.e. <package>:<arch>=<version>).
|
||||
all-package-version-list:
|
||||
description: "All the pulled in packages and versions, including dependencies, that are installed. Represented as a sorted comma delimited list with equals delimit on the package version (i.e. <package1>=<version1>,<package2>=<version2>,...). Packages installed for a non-native architecture include it (i.e. <package>:<arch>=<version>)."
|
||||
description: >
|
||||
All the pulled in packages and versions, including dependencies, that are installed.
|
||||
Represented as a sorted comma delimited list with equals delimit on the package version (i.e. <package1>=<version1>,<package2>=<version2>,...).
|
||||
Packages installed for a non-native architecture include it (i.e. <package>:<arch>=<version>).
|
||||
|
||||
runs:
|
||||
using: "node24"
|
||||
|
|
|
|||
|
|
@ -95,8 +95,9 @@ export class ActionRunner {
|
|||
const cacheHit = manifest !== undefined;
|
||||
|
||||
// Needed on a cache hit too, to resolve the native arch for outputs.
|
||||
// apt-fast only speeds up installs, so skip probing for it on a hit.
|
||||
const installManager = await createPackageManager(
|
||||
true,
|
||||
!cacheHit,
|
||||
this.logger,
|
||||
this.logger,
|
||||
);
|
||||
|
|
|
|||
42
src/cache.ts
42
src/cache.ts
|
|
@ -31,6 +31,15 @@ export function resolveCacheDir(
|
|||
*/
|
||||
const NO_TIMEOUT_MS = -1;
|
||||
|
||||
/**
|
||||
* Shell script that extracts each archive given as an argument onto `/`.
|
||||
*
|
||||
* Paths are passed as positional arguments, not interpolated, so they are
|
||||
* never parsed by the shell.
|
||||
*/
|
||||
const EXTRACT_ARCHIVES_SCRIPT =
|
||||
'for archive do tar --extract --file="$archive" --directory=/ --keep-directory-symlink || exit 1; done';
|
||||
|
||||
/** Maintainer scripts run on restore when install scripts are enabled. */
|
||||
type InstallScriptType = "preinst" | "postinst";
|
||||
|
||||
|
|
@ -258,12 +267,15 @@ export class Cache {
|
|||
}
|
||||
}
|
||||
|
||||
for (const { packageName, archivePath } of archivePaths) {
|
||||
await this.extractArchive(archivePath);
|
||||
|
||||
if (executeInstallScripts) {
|
||||
if (executeInstallScripts) {
|
||||
// Each package's scripts must run before the next package is extracted.
|
||||
for (const { packageName, archivePath } of archivePaths) {
|
||||
await this.extractArchives([archivePath]);
|
||||
await this.runInstallScripts(packageName);
|
||||
}
|
||||
} else {
|
||||
// One process for all archives avoids a sudo and tar spawn per package.
|
||||
await this.extractArchives(archivePaths.map((item) => item.archivePath));
|
||||
}
|
||||
|
||||
// Return every installed package so outputs can report dependencies too.
|
||||
|
|
@ -271,23 +283,23 @@ export class Cache {
|
|||
}
|
||||
|
||||
/**
|
||||
* Extracts a package archive onto the root filesystem.
|
||||
* Extracts package archives, in order, onto the root filesystem.
|
||||
*
|
||||
* Runs system tar under sudo because package files live in root-owned
|
||||
* directories. Fails on any entry error instead of skipping it.
|
||||
* directories. All archives share one sudo shell so the process cost is paid
|
||||
* once. Stops at the first archive with any entry error.
|
||||
*
|
||||
* @param archivePath Absolute path to the package archive.
|
||||
* @param archivePaths Absolute paths to the package archives.
|
||||
*/
|
||||
private async extractArchive(archivePath: string): Promise<void> {
|
||||
private async extractArchives(
|
||||
archivePaths: readonly string[],
|
||||
): Promise<void> {
|
||||
if (archivePaths.length === 0) {
|
||||
return;
|
||||
}
|
||||
await this.commandRunner.run(
|
||||
"sudo",
|
||||
[
|
||||
"tar",
|
||||
"--extract",
|
||||
`--file=${archivePath}`,
|
||||
"--directory=/",
|
||||
"--keep-directory-symlink",
|
||||
],
|
||||
["sh", "-c", EXTRACT_ARCHIVES_SCRIPT, "sh", ...archivePaths],
|
||||
{ timeoutMs: NO_TIMEOUT_MS },
|
||||
);
|
||||
}
|
||||
|
|
|
|||
18
src/index.ts
18
src/index.ts
|
|
@ -75,7 +75,9 @@ function getInputs(): ActionInputs {
|
|||
async function main(): Promise<void> {
|
||||
const cacheDir = CACHE_DEFAULT_DIRNAME;
|
||||
const inputs = getInputs();
|
||||
const instruments = new Instruments(cacheDir, inputs.debug || core.isDebug());
|
||||
const debug = inputs.debug || core.isDebug();
|
||||
const instruments = new Instruments(cacheDir, debug);
|
||||
let failed = false;
|
||||
try {
|
||||
ensureBundledScriptsExecutable();
|
||||
const commandRunner = new DefaultCommandRunner(
|
||||
|
|
@ -96,12 +98,16 @@ async function main(): Promise<void> {
|
|||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
core.setFailed(message);
|
||||
failed = true;
|
||||
} finally {
|
||||
// Logs are diagnostics, so a failed upload shouldn't fail the action.
|
||||
await instruments.artifacts.upload().catch((error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
core.warning(`Failed to upload diagnostic artifacts: ${message}`);
|
||||
});
|
||||
// The upload takes over a second, so only pay for it when the logs are
|
||||
// wanted. Logs are diagnostics, so a failed upload shouldn't fail the action.
|
||||
if (debug || failed) {
|
||||
await instruments.artifacts.upload().catch((error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
core.warning(`Failed to upload diagnostic artifacts: ${message}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -63,6 +63,28 @@ describe("ActionRunner", () => {
|
|||
expect(cache.archiveAndSave).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runAction cache hit creates package manager without apt-fast", async () => {
|
||||
const { runner, cache } = createRunner();
|
||||
const packageNames = ActionPackageNames.fromInput("curl");
|
||||
const cacheKey = new CacheKey("v1", "0", process.arch, packageNames);
|
||||
cache.loadAndRestore.mockResolvedValue(
|
||||
new Manifest(new Date(), [], cacheKey),
|
||||
);
|
||||
vi.mocked(createPackageManager).mockResolvedValue({
|
||||
getNativeArchitecture: vi.fn().mockResolvedValue(NATIVE_ARCH),
|
||||
} as never);
|
||||
|
||||
await runner.runAction({
|
||||
packages: "curl",
|
||||
version: "v1",
|
||||
executeInstallScripts: false,
|
||||
emptyPackagesBehavior: "error",
|
||||
debug: false,
|
||||
});
|
||||
|
||||
expect(vi.mocked(createPackageManager).mock.calls[0]?.[0]).toBe(false);
|
||||
});
|
||||
|
||||
it("runAction cache miss archives installed packages with their files", async () => {
|
||||
// Unlikely to have dpkg install scripts on the test host.
|
||||
const installedPackage = createPackageName("test-pkg-not-real", "1.0.0");
|
||||
|
|
|
|||
|
|
@ -87,6 +87,8 @@ async function writeCache(archivedPackages = INSTALL_ORDER): Promise<void> {
|
|||
interface TarCalls {
|
||||
created: [archive: string, files: string[]][];
|
||||
extracted: string[];
|
||||
/** Number of commands run that extracted archives. */
|
||||
extractCommands?: number;
|
||||
}
|
||||
|
||||
const argValue = (args: string[], flag: string) =>
|
||||
|
|
@ -100,8 +102,10 @@ function recordTar(calls: TarCalls) {
|
|||
if (args.includes("--create")) {
|
||||
calls.created.push([archive, String(options.input ?? "").split("\0")]);
|
||||
}
|
||||
if (args.includes("--extract")) {
|
||||
calls.extracted.push(archive);
|
||||
// Extraction runs as `sh -c <script> sh <archive>...`.
|
||||
if (args[0] === "sh" && args[1] === "-c") {
|
||||
calls.extracted.push(...args.slice(4));
|
||||
calls.extractCommands = (calls.extractCommands ?? 0) + 1;
|
||||
}
|
||||
return { cmdLine: "", stdout: "", stderr: "", exitCode: 0 };
|
||||
},
|
||||
|
|
@ -136,6 +140,27 @@ describe("Cache", () => {
|
|||
expect(calls.extracted).toEqual(INSTALL_ORDER.map(archivePath));
|
||||
});
|
||||
|
||||
it("loadAndRestore without install scripts extracts all archives in one command", async () => {
|
||||
await writeCache();
|
||||
|
||||
const calls: TarCalls = { created: [], extracted: [] };
|
||||
|
||||
await createCache(calls).loadAndRestore(KEY, false);
|
||||
|
||||
expect(calls.extractCommands).toBe(1);
|
||||
});
|
||||
|
||||
it("loadAndRestore with install scripts extracts archives one at a time in install order", async () => {
|
||||
await writeCache();
|
||||
|
||||
const calls: TarCalls = { created: [], extracted: [] };
|
||||
|
||||
await createCache(calls).loadAndRestore(KEY, true);
|
||||
|
||||
expect(calls.extracted).toEqual(INSTALL_ORDER.map(archivePath));
|
||||
expect(calls.extractCommands).toBe(INSTALL_ORDER.length);
|
||||
});
|
||||
|
||||
it("loadAndRestore archive missing returns undefined without extracting", async () => {
|
||||
await writeCache(INSTALL_ORDER.slice(1));
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue