Skip to content
This repository was archived by the owner on Aug 14, 2026. It is now read-only.
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
9 changes: 7 additions & 2 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -485,10 +485,14 @@ inputs:
required: false
default: ''
description: 'Comma-separated explicit local cache fallback keys to try after the exact key.'
cacheMode:
required: false
default: ''
description: 'Library cache mode: move-directory (default, O(1) rename, falls back to copy across filesystem volumes), copy-directory, or tar (opt-in — needed when distributing cache entries via the rclone/S3 built-in container hooks). Renamed from localCacheMode, which still works as a deprecated alias — see game-ci/roadmap#11. NOTE: canonical-overlay has been removed — setting it now throws a clear error instead of running.'
localCacheMode:
required: false
default: 'tar'
description: 'Local Library cache mode: tar, copy-directory, or move-directory. move-directory is intended for same-volume Windows self-hosted runners. NOTE: canonical-overlay has been removed (see game-ci/roadmap#11) — setting it now throws a clear error instead of running.'
default: ''
description: 'Deprecated alias for cacheMode — "local" no longer accurately describes this input now that distributed rclone/S3 caching is a supported mode too. Still works, but prefer cacheMode. Will be removed in a future major version.'
upmOfflineEnabled:
required: false
default: 'false'
Expand Down Expand Up @@ -894,6 +898,7 @@ runs:
INPUT_LOCALCACHELFS: ${{ inputs.localCacheLfs }}
INPUT_LOCALCACHEFALLBACK: ${{ inputs.localCacheFallback }}
INPUT_LOCALCACHEFALLBACKKEYS: ${{ inputs.localCacheFallbackKeys }}
INPUT_CACHEMODE: ${{ inputs.cacheMode }}
INPUT_LOCALCACHEMODE: ${{ inputs.localCacheMode }}
INPUT_UPMOFFLINEENABLED: ${{ inputs.upmOfflineEnabled }}
INPUT_BACKGROUNDCACHESAVE: ${{ inputs.backgroundCacheSave }}
Expand Down
33 changes: 29 additions & 4 deletions src/model/orchestrator/services/cache/child-workspace-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ export class ChildWorkspaceService {
OrchestratorLogger.log(
`[ChildWorkspace] Restoring workspace: ${cachedWorkspacePath} -> ${projectPath}`,
);
fs.renameSync(cachedWorkspacePath, projectPath);
ChildWorkspaceService.renameOrCopy(cachedWorkspacePath, projectPath);
OrchestratorLogger.log(`[ChildWorkspace] Workspace restored via atomic move`);

// Restore Library cache separately if configured
Expand Down Expand Up @@ -151,7 +151,7 @@ export class ChildWorkspaceService {
OrchestratorLogger.log(
`[ChildWorkspace] Saving workspace: ${projectPath} -> ${cachedWorkspacePath}`,
);
fs.renameSync(projectPath, cachedWorkspacePath);
ChildWorkspaceService.renameOrCopy(projectPath, cachedWorkspacePath);
OrchestratorLogger.log(`[ChildWorkspace] Workspace saved via atomic move`);
} catch (error: any) {
OrchestratorLogger.logWarning(`[ChildWorkspace] Workspace save failed: ${error.message}`);
Expand Down Expand Up @@ -194,7 +194,7 @@ export class ChildWorkspaceService {
OrchestratorLogger.log(
`[ChildWorkspace] Restoring ${folder} cache: ${backupPath} -> ${destination}`,
);
fs.renameSync(backupPath, destination);
ChildWorkspaceService.renameOrCopy(backupPath, destination);
OrchestratorLogger.log(`[ChildWorkspace] ${folder} cache restored`);
restored = true;
} catch (error: any) {
Expand Down Expand Up @@ -246,7 +246,7 @@ export class ChildWorkspaceService {
OrchestratorLogger.log(
`[ChildWorkspace] Caching ${folder}: ${folderPath} -> ${backupPath}`,
);
fs.renameSync(folderPath, backupPath);
ChildWorkspaceService.renameOrCopy(folderPath, backupPath);
OrchestratorLogger.log(`[ChildWorkspace] ${folder} cached separately`);
} catch (error: any) {
OrchestratorLogger.logWarning(
Expand Down Expand Up @@ -409,4 +409,29 @@ export class ChildWorkspaceService {

return `${value.toFixed(2)} ${units[index]}`;
}

/**
* Move a directory via atomic rename, falling back to a recursive copy (+ source
* cleanup) when the rename fails across filesystem boundaries (EXDEV) or due to
* permissions (EPERM) — e.g. parentCacheRoot configured on a different volume than
* the workspace. `parentCacheRoot` is documented as needing to be on the same volume
* for O(1) restore, but a misconfiguration shouldn't lose the cache entirely: this
* degrades to copy cost instead of falling through to "start fresh".
*/
private static renameOrCopy(source: string, destination: string): void {
try {
fs.renameSync(source, destination);
} catch (error: any) {
if (error?.code !== 'EXDEV' && error?.code !== 'EPERM') {
throw error;
}
OrchestratorLogger.logWarning(
`[ChildWorkspace] rename failed (${error.code}) from ${source} to ${destination}; ` +
`falling back to copy. This is slower — consider configuring parentCacheRoot on ` +
`the same volume as the workspace.`,
);
fs.cpSync(source, destination, { recursive: true });
fs.rmSync(source, { recursive: true, force: true });
}
}
}
98 changes: 94 additions & 4 deletions src/model/orchestrator/services/cache/local-cache-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,15 +142,19 @@ describe('LocalCacheService', () => {
expect(result).toBe(false);
});

it('should restore from the latest tar file on cache hit', async () => {
it('should restore from the latest tar file on cache hit (explicit tar mode)', async () => {
// move-directory is the default since #40; tar remains available as an explicit opt-in
// (e.g. for distributing cache entries via the rclone/S3 built-in container hooks).
(mockFs.existsSync as vi.Mock).mockReturnValue(true);
(mockFs.readdirSync as vi.Mock).mockReturnValue(['lib-1000.tar', 'lib-2000.tar']);
(mockFs.statSync as vi.Mock).mockImplementation((filePath: string) => ({
mtimeMs: String(filePath).includes('lib-2000') ? 2000 : 1000,
}));
(mockFs.mkdirSync as vi.Mock).mockReturnValue(undefined);

const result = await LocalCacheService.restoreLibraryCache('/project', '/cache', 'key1');
const result = await LocalCacheService.restoreEngineCache('/project', '/cache', 'key1', {
restoreMode: 'tar',
});

expect(result).toBe(true);
expect(OrchestratorSystem.Run).toHaveBeenCalledWith(
Expand Down Expand Up @@ -193,6 +197,47 @@ describe('LocalCacheService', () => {
});
expect(mockFs.renameSync).not.toHaveBeenCalled();
});

it('should default to move-directory (rename) when no restoreMode is specified', async () => {
const cachePath = path.join('/cache', 'key1', 'Library');
const destPath = path.join('/project', 'Library');

(mockFs.existsSync as vi.Mock).mockReturnValue(true);
(mockFs.readdirSync as vi.Mock).mockReturnValue(['ArtifactDB']);
(mockFs.statSync as vi.Mock).mockReturnValue({ size: 1 });
(mockFs.mkdirSync as vi.Mock).mockReturnValue(undefined);
(mockFs.renameSync as vi.Mock).mockReturnValue(undefined);

const result = await LocalCacheService.restoreEngineCache('/project', '/cache', 'key1');

expect(result).toBe(true);
expect(mockFs.renameSync).toHaveBeenCalledWith(cachePath, destPath);
expect(mockFs.cpSync).not.toHaveBeenCalled();
});

it('should fall back to copy when rename fails with EXDEV (cross-volume)', async () => {
const cachePath = path.join('/cache', 'key1', 'Library');
const destPath = path.join('/project', 'Library');

(mockFs.existsSync as vi.Mock).mockReturnValue(true);
(mockFs.readdirSync as vi.Mock).mockReturnValue(['ArtifactDB']);
(mockFs.statSync as vi.Mock).mockReturnValue({ size: 1 });
(mockFs.mkdirSync as vi.Mock).mockReturnValue(undefined);
(mockFs.renameSync as vi.Mock).mockImplementation(() => {
const error: any = new Error('cross-device link');
error.code = 'EXDEV';
throw error;
});
(mockFs.cpSync as vi.Mock).mockReturnValue(undefined);
(mockFs.rmSync as vi.Mock).mockReturnValue(undefined);

const result = await LocalCacheService.restoreEngineCache('/project', '/cache', 'key1');

expect(result).toBe(true);
expect(mockFs.renameSync).toHaveBeenCalledWith(cachePath, destPath);
expect(mockFs.cpSync).toHaveBeenCalledWith(cachePath, destPath, { recursive: true });
expect(mockFs.rmSync).toHaveBeenCalledWith(cachePath, { recursive: true, force: true });
});
});

describe('saveLibraryCache', () => {
Expand All @@ -209,7 +254,8 @@ describe('LocalCacheService', () => {
expect(mockFs.mkdirSync).not.toHaveBeenCalled();
});

it('should create cache directory and save tar', async () => {
it('should create cache directory and save tar (explicit tar mode)', async () => {
// move-directory is the default since #40; tar remains available as an explicit opt-in.
(mockFs.existsSync as vi.Mock).mockReturnValue(true);
(mockFs.readdirSync as vi.Mock).mockImplementation((dirPath: string) => {
if (String(dirPath).includes('Library') && !String(dirPath).includes('cache')) {
Expand All @@ -222,12 +268,56 @@ describe('LocalCacheService', () => {

OrchestratorSystem.Run.mockResolvedValue('');

await LocalCacheService.saveLibraryCache('/project', '/cache', 'key1');
await LocalCacheService.saveEngineCache('/project', '/cache', 'key1', { saveMode: 'tar' });
expect(mockFs.mkdirSync).toHaveBeenCalledWith(path.join('/cache', 'key1', 'Library'), {
recursive: true,
});
expect(OrchestratorSystem.Run).toHaveBeenCalledWith(expect.stringContaining('tar -cf'), true);
});

it('should default to move-directory (rename) when no saveMode is specified', async () => {
const folderPath = path.join('/project', 'Library');
const cachePath = path.join('/cache', 'key1', 'Library');

(mockFs.existsSync as vi.Mock).mockImplementation(
(filePath: string) => filePath === folderPath,
);
(mockFs.readdirSync as vi.Mock).mockReturnValue(['ArtifactDB']);
(mockFs.statSync as vi.Mock).mockReturnValue({ size: 1 });
(mockFs.mkdirSync as vi.Mock).mockReturnValue(undefined);
(mockFs.renameSync as vi.Mock).mockReturnValue(undefined);

await LocalCacheService.saveEngineCache('/project', '/cache', 'key1');

expect(mockFs.renameSync).toHaveBeenCalledWith(folderPath, cachePath);
expect(mockFs.cpSync).not.toHaveBeenCalled();
expect(OrchestratorSystem.Run).not.toHaveBeenCalled();
});

it('should fall back to copy when save rename fails with EXDEV (cross-volume)', async () => {
const folderPath = path.join('/project', 'Library');
const cachePath = path.join('/cache', 'key1', 'Library');

(mockFs.existsSync as vi.Mock).mockImplementation(
(filePath: string) => filePath === folderPath,
);
(mockFs.readdirSync as vi.Mock).mockReturnValue(['ArtifactDB']);
(mockFs.statSync as vi.Mock).mockReturnValue({ size: 1 });
(mockFs.mkdirSync as vi.Mock).mockReturnValue(undefined);
(mockFs.renameSync as vi.Mock).mockImplementation(() => {
const error: any = new Error('cross-device link');
error.code = 'EXDEV';
throw error;
});
(mockFs.cpSync as vi.Mock).mockReturnValue(undefined);
(mockFs.rmSync as vi.Mock).mockReturnValue(undefined);

await LocalCacheService.saveEngineCache('/project', '/cache', 'key1');

expect(mockFs.renameSync).toHaveBeenCalledWith(folderPath, cachePath);
expect(mockFs.cpSync).toHaveBeenCalledWith(folderPath, cachePath, { recursive: true });
expect(mockFs.rmSync).toHaveBeenCalledWith(folderPath, { recursive: true, force: true });
});
});

describe('restoreLfsCache', () => {
Expand Down
45 changes: 39 additions & 6 deletions src/model/orchestrator/services/cache/local-cache-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,8 +220,13 @@ export class LocalCacheService {
return false;
}

if (options.restoreMode && options.restoreMode !== 'tar') {
return LocalCacheService.restoreDirectoryCache(projectPath, cachePath, folder, options);
// move-directory is the default mode (O(1) rename); tar is opt-in for cases that
// need it (e.g. distributing cache entries via the rclone/S3 built-in container hooks).
if (!options.restoreMode || options.restoreMode !== 'tar') {
return LocalCacheService.restoreDirectoryCache(projectPath, cachePath, folder, {
...options,
restoreMode: options.restoreMode ?? 'move-directory',
});
}

const files = fs.readdirSync(cachePath).filter((f) => f.endsWith('.tar'));
Expand Down Expand Up @@ -328,11 +333,15 @@ export class LocalCacheService {
const cachePath = path.join(cacheRoot, cacheKey, folder);
fs.mkdirSync(cachePath, { recursive: true });

if (options.saveMode && options.saveMode !== 'tar') {
// move-directory is the default mode (O(1) rename); tar is opt-in for cases that
// need it (e.g. distributing cache entries via the rclone/S3 built-in container hooks).
if (options.saveMode !== 'tar') {
const directorySaveMode: 'move-directory' | 'copy-directory' =
options.saveMode === 'copy-directory' ? 'copy-directory' : 'move-directory';
LocalCacheService.saveDirectoryCache(
folderPath,
cachePath,
options.saveMode,
directorySaveMode,
options.backgroundSave,
);

Expand Down Expand Up @@ -392,7 +401,7 @@ export class LocalCacheService {
if (options.restoreMode === 'copy-directory') {
fs.cpSync(cachePath, dest, { recursive: true });
} else {
fs.renameSync(cachePath, dest);
LocalCacheService.renameOrCopy(cachePath, dest);
}

if (
Expand Down Expand Up @@ -838,14 +847,38 @@ export class LocalCacheService {
if (saveMode === 'copy-directory') {
fs.cpSync(folderPath, cachePath, { recursive: true });
} else {
fs.renameSync(folderPath, cachePath);
LocalCacheService.renameOrCopy(folderPath, cachePath);
}

OrchestratorLogger.log(
`[LocalCache] ${path.basename(folderPath)} directory cache saved successfully`,
);
}

/**
* Move a directory via atomic rename, falling back to a recursive copy (+ source
* cleanup) when the rename fails across filesystem boundaries (EXDEV) or due to
* permissions (EPERM) — e.g. cacheRoot configured on a different volume than the
* workspace. A cross-volume move-directory config would otherwise throw on every
* restore/save; this keeps the build working, at copy cost, instead.
*/
private static renameOrCopy(source: string, destination: string): void {
try {
fs.renameSync(source, destination);
} catch (error: any) {
if (error?.code !== 'EXDEV' && error?.code !== 'EPERM') {
throw error;
}
OrchestratorLogger.logWarning(
`[LocalCache] move-directory rename failed (${error.code}) from ${source} to ` +
`${destination}; falling back to copy. This is slower — consider configuring ` +
`localCacheRoot on the same volume as the workspace.`,
);
fs.cpSync(source, destination, { recursive: true });
fs.rmSync(source, { recursive: true, force: true });
}
}

/**
* Spawn a detached background process to copy the cache directory.
* Writes a lock file with PID before starting; removes it on completion.
Expand Down
24 changes: 19 additions & 5 deletions src/plugin-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,18 +271,32 @@ const config = {
get localCacheFallbackKeys() {
return getInput('localCacheFallbackKeys');
},
get localCacheMode() {
const mode = getInput('localCacheMode') || 'tar';
get cacheMode() {
const explicitCacheMode = getInput('cacheMode');
const legacyLocalCacheMode = getInput('localCacheMode');
if (!explicitCacheMode && legacyLocalCacheMode) {
core.warning(
"[plugin-lifecycle] Input 'localCacheMode' is deprecated — use 'cacheMode' instead. " +
"'local' no longer accurately describes this input now that distributed rclone/S3 " +
'caching is a supported mode too. localCacheMode will keep working for now, but ' +
'prefer cacheMode going forward — see game-ci/roadmap#11.',
);
}
const mode = explicitCacheMode || legacyLocalCacheMode || 'move-directory';
if (mode === 'canonical-overlay') {
throw new Error(
'[plugin-lifecycle] localCacheMode: canonical-overlay has been removed. ' +
'[plugin-lifecycle] cacheMode: canonical-overlay has been removed. ' +
"Use 'move-directory' (recommended for retained runners/build farms) or 'tar' " +
'(recommended for remote/ephemeral caches, or combine with rclone/S3 built-in ' +
'container hooks — see the caching docs for details).',
);
}
return mode;
},
/** @deprecated Use `cacheMode` instead — kept only so internal call sites reading the old property name don't need to change too. */
get localCacheMode() {
return config.cacheMode;
},
get maxCacheEntries() {
return Number(getInput('maxCacheEntries')) || 2;
},
Expand Down Expand Up @@ -611,7 +625,7 @@ export function createPlugin(): OrchestratorPlugin {
: [];
await LocalCacheService.restoreEngineCache(projectFullPath, cacheRoot, cacheKey, {
fallbackKeys,
restoreMode: config.localCacheMode as any,
restoreMode: config.cacheMode as any,
});
}
}
Expand Down Expand Up @@ -797,7 +811,7 @@ export function createPlugin(): OrchestratorPlugin {
if (config.localCacheLibrary) {
const projectFullPath = path.join(ws, coreParams.projectPath);
await LocalCacheService.saveEngineCache(projectFullPath, cacheRoot, cacheKey, {
saveMode: config.localCacheMode as any,
saveMode: config.cacheMode as any,
skipOnLfsPointerPoisoning: true,
backgroundSave: config.backgroundCacheSave,
maxCacheEntries: config.maxCacheEntries,
Expand Down