Skip to content

Default cache key globs the entire workspace and stalls setup on large monorepos #134

Description

@ryanleecode

Problem Statement

On a large monorepo, denoland/setup-deno@v2 with cache: true logs Installation complete. and then goes completely silent for minutes until the job is killed. Nothing indicates what it is doing. The cause is the default cache key: when no cache-hash is supplied the action globs **/deno.lock across the whole workspace with symlinks followed, so on a repo that has already run pnpm install it walks hundreds of thousands of paths to find one lockfile. Passing an explicit cache-hash makes the same step finish in under two seconds, so the bypass exists — the default just should not be pathological.

This matters more than usual right now because #116 proposes enabling cache by default.

Goal

With cache: true and no cache-hash, cache-key resolution completes in under 10 seconds on a workspace whose symlink-followed reachable file count exceeds 500,000, while still deriving the key from every deno.lock in the workspace.

Evidence: the glob

export async function restoreCache(cacheHash: string) {
  try {
    const denoDir = await resolveDenoDir();
    core.saveState(state.DENO_DIR, denoDir);

    if (cacheHash.length === 0) {
      cacheHash = await resolveDefaultCacheKey();
    }
function resolveDefaultCacheKey(): Promise<string> {
  return hashFiles(
    "**/deno.lock",
    process.env.GITHUB_WORKSPACE,
  );
}

Source: src/cache.ts:34-41 and src/cache.ts:69-74 at commit 22d081ff2d3a40755e97629de92e3bcbfa7cf2ed (v2.0.5). Call site: src/main.ts:65-67.

hashFiles from @actions/glob defaults to followSymbolicLinks: true, and a ** pattern must exhaust the tree — finding the single match early does not stop the walk. Every log line inside restoreCache is emitted after the key is computed, which is why the step produces no output at all while it stalls.

Evidence: reproduction

Reproduced locally with act against a real monorepo checkout, container bind-mounted to the workspace:

[deno-cache-probe/default-glob] ⭐ Run Main denoland/setup-deno@v2
[deno-cache-probe/default-glob]   | Going to install stable version 2.9.5.
[deno-cache-probe/default-glob]   | Using cached Deno installation from /opt/hostedtoolcache/deno/2.9.5/x64.
[deno-cache-probe/default-glob]   | Installation complete.
<no further output; killed at 2400s>

The same workspace, same container, same action, with cache-hash: ${{ hashFiles('deno.lock') }} added:

[deno-cache-probe/explicit-hash]   | Installation complete.
[deno-cache-probe/explicit-hash]   | No cache found for restore key: "deno-cache-Linux-X64".
[deno-cache-probe/explicit-hash]   ✅  Success - Main denoland/setup-deno@v2 [1.846721265s]

Measurements

Workspace: one root deno.lock (61 lines, two dependencies), node_modules populated by pnpm, plus vendored subtrees.

Configuration setup-deno step
cache: true, no cache-hash killed at 2400 s, still walking
cache: true + cache-hash 1.85 s
no cache input 0.49 s

Directory walk of the same workspace, measured independently:

Walk Result
symlinks not followed 290,426 files / 47,814 dirs in 19 s
symlinks followed 562,409 files / 58,705 dirs, still running at 300 s

Top contributors by entry count: node_modules 154,204 and vendored subtrees 36,300.

The original report that led here was a CI job where the step was cancelled at 5m36s with Error: The operation was canceled. immediately after Installation complete. — the same signature, just a shorter leash.

Orientation

Affected: src/cache.ts (resolveDefaultCacheKey, restoreCache) and src/main.ts:65-67. The cache-hash input already short-circuits the expensive path, so the bypass semantics are established; the question is what the default should do instead. Relevant upstream defaults live in @actions/glob (packages/glob/src/glob.ts, packages/glob/src/internal-glob-options-helper.ts).

Non-Counting Outcomes

  • Documenting cache-hash in the README as a workaround while the default is unchanged. The default is the bug.
  • Setting followSymbolicLinks: false and nothing else. That walk still took 19 s and visited 290,426 files on this workspace; it narrows the blast radius without fixing the complexity.
  • Restricting the glob to the workspace root only. That breaks multi-package repos that legitimately have nested deno.lock files, which the current behavior supports.
  • Wrapping the glob in a timeout or abort so a stall becomes a silent cache miss. The job stops hanging and the cache silently stops working.
  • Benchmarking on a clean checkout. The pathological case requires a populated node_modules; a fresh clone will not reproduce it.

Acceptance Criteria

  • (gatekeeper, runnable) A test or benchmark over a fixture workspace containing a pnpm-style node_modules symlink tree and one root deno.lock resolves the default cache key in under 10 s, and fails when the change is reverted.
  • cache: true with no cache-hash on a workspace with more than 500,000 symlink-followed reachable files resolves a key without exceeding a typical 10-minute job budget.
  • A workspace with deno.lock files in nested subdirectories still produces a key derived from all of them, not just the root one.
  • At least one log line is emitted before key computation begins, so a slow resolution is diagnosable from the job log rather than appearing as a hang.

Environment

  • denoland/setup-deno@v2, resolving to v2.0.5 (22d081ff2d3a40755e97629de92e3bcbfa7cf2ed)
  • Deno 2.9.5, x86_64-unknown-linux-gnu
  • Originally observed on a self-hosted Linux runner; reproduced locally with act 0.2.89 on catthehacker/ubuntu:act-latest

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions