Skip to content

chore(ci): cache dependencies in the lake cache shadow pipeline - #43050

Draft
marcelolynch wants to merge 16 commits into
masterfrom
ci-dev/lake-cache-shadow-deps
Draft

chore(ci): cache dependencies in the lake cache shadow pipeline#43050
marcelolynch wants to merge 16 commits into
masterfrom
ci-dev/lake-cache-shadow-deps

Conversation

@marcelolynch

@marcelolynch marcelolynch commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

This PR makes the lake cache shadow pipeline cache dependencies. The pipeline previously cached the root package only, and used the legacy cache for the dependencies.

The sections below explain the mechanism. They also go in .github/workflows/lake_cache_shadow.md, which this PR adds. The workflow header used to carry the design; it now points at that document, and the comments in the workflow cover what each step does.

The pipeline caches every git dependency in mathlib's manifest, 8 of them today. For each dependency, <DEP> is its name, <R-DEP> is its revision from the manifest, and <S-DEP> is its scope, mathlib4-master-shadow/deps/<toolchain-slug>/<pin-hash>/<DEP>. The pin hash is a short fingerprint of the revisions that mathlib's manifest pins, and the toolchain slug is the toolchain with its punctuation replaced; both are explained below. A rendered scope reads mathlib4-master-shadow/deps/leanprover-lean4-v4.34.0-rc2/2d10dd67/batteries.

Cache service configuration

Every lake cache call passes --service=shadow. Each job writes that service definition itself, into the file LAKE_CONFIG names, because each job runs on its own runner:

[[cache.service]]
name = "shadow"
type = "s3"
artifactEndpoint = "https://pub-<hash>.r2.dev/<prefix>/artifacts"
revisionEndpoint = "https://pub-<hash>.r2.dev/<prefix>/revisions"

A job that fetches writes the public read endpoints, as above. The upload job writes the authenticated S3 endpoints, and LAKE_CACHE_KEY signs its requests. A services file is the supported way to configure a cache service, and Lake deprecates the endpoint environment variables.

Push, from a clean cache

Lake's -o records the mappings of the workspace root only, so step 3 loads each dependency as its own root. Step 1 runs once, and steps 2 to 5 run once per dependency.

  1. lake build Mathlib builds mathlib and every dependency from source, and writes the artifacts into the local Lake cache.

  2. Write <DEP>-overrides.json. A jq filter reads mathlib's lake-manifest.json, drops the entry for <DEP> itself, and rewrites each remaining package as a path entry into this checkout. It keeps the name, scope, configFile and inherited fields, sets type to path, and sets dir to .lake/packages/<name>. For aesop it renders:

     {"version": "1.2.0",
      "packages": [
        {"name": "batteries", "scope": "leanprover-community",
         "configFile": "lakefile.toml", "inherited": false, "type": "path",
         "dir": "<checkout>/.lake/packages/batteries"},
        ... one entry per other package ...
      ]}
    

    The file pins the dependencies of <DEP> to mathlib's. Without it Lake uses the manifest of <DEP>, and the mappings never match this workspace's input hashes.

  3. Export: lake -d .lake/packages/<DEP> build --packages=.lake/dep-plan/<DEP>-overrides.json -o .lake/dep-outputs/<DEP>.jsonl. This load replays instead of compiling, because step 1 filled the local cache.

  4. Stage: lake cache stage .lake/dep-outputs/<DEP>.jsonl lake-cache-staging/deps/<DEP>. Each dependency needs its own directory, because cache stage writes one outputs.jsonl per directory. The staging tree travels to the upload job as a GitHub artifact, because the build runs in a sandbox without the credentials.

  5. Upload: lake cache put-staged lake-cache-staging/deps/<DEP> --service=shadow --scope=<S-DEP> --rev=<R-DEP>. Lake PUTs the artifacts first and the revision file last.

All five steps, the two scope qualifiers described below, and the lakefile patch that enables Lake's artifact cache are required for a full hit on the root package and its dependencies. Without any one of them a fetch returns mappings that do not match, and the modules rebuild.

Pull

The consume job builds mathlib from the bucket alone. It sets LAKE_NO_CACHE, so the bucket accounts for every replay.

  1. lake cache get --service=shadow --scope=mathlib4-master-shadow --rev=<mathlib-sha>.
  2. lake cache get --service=shadow --package=<DEP> --scope=<S-DEP> --rev=<R-DEP>, once per dependency, with the revisions from mathlib's manifest.
  3. lake build Mathlib replays both.

The downstream job runs the same pull for a small project that requires mathlib. In that project mathlib is a dependency, so it comes from the root scope under its sha.

What a warm run adds

Four optimizations decide how much a run compiles and uploads. None of them changes the keys or the content that a run writes.

  • The probe. Before the build, curl -fsS -o /dev/null "$REVISION_ENDPOINT/<S-DEP>/<R-DEP>.jsonl" asks whether the bucket already holds this dependency. If it does, steps 2 to 5 skip it. A revision, a toolchain and a pin set determine the content, so a dependency uploads once. Later runs skip it, until a manifest bump or a toolchain bump changes the scope.
  • The root warm start. lake cache get --service=shadow --scope=mathlib4-master-shadow --rev=<previous-sha> seeds the local cache from the previous run on this toolchain, so step 1 compiles the churn since that run only. analysis/<toolchain-slug>/_latest.txt holds the previous sha.
  • The dependency warm start. lake cache get --service=shadow --package=<DEP> --scope=<S-DEP> --rev=<R-DEP> for each dependency the probe found, so step 1 replays it instead of compiling it.
  • The legacy cache, for a cold analysis chain only, where no previous run exists to warm start from.

The --rev arguments are an optimization too. Without them Lake searches back through the ancestors of the checkout's HEAD.

Dependency scopes

Lake requires --scope or --repo on every put and get against a custom endpoint; there is no unscoped form. A scope also bounds where a content hash is trusted, because Lake does not use cryptographically secure hashes and prefixes uploads to avoid clashes. Each package therefore gets its own namespace for its artifacts and its revision files, and the root scope stays for mathlib alone. --repo=<owner>/<package> would give a scope of that shape, and Lake would add the toolchain and the platform to it. It has no place for the pin hash, so the pipeline passes the whole string to --scope, which Lake uses verbatim.

Two qualifiers extend a dependency scope. The toolchain slug separates the toolchains that build one long-lived revision. The pin hash separates the manifest generations: the input hashes of a dependency cover the artifacts of its upstreams, so a bump of batteries alone changes the correct mappings for aesop but not the revision of aesop.

A dependency's entry is revisions/<S-DEP>/<R-DEP>.jsonl, so four things identify it: the dependency, its revision, the toolchain, and the pin set. A mathlib commit changes none of them, which is why the steady state uploads no dependency. A toolchain bump changes the slug, and a dependency bump changes both that dependency's revision and the pin hash. The pin hash covers the whole manifest, so one dependency bump currently re-keys all of them; a hash over each dependency's own upstream closure would re-key only the affected ones. Every job runs on Linux, so the scope carries no platform segment.

Storage layout

One bucket holds these keys:

revisions/mathlib4-master-shadow/<mathlib-sha>.jsonl
artifacts/mathlib4-master-shadow/<content-hash>.art
revisions/<S-DEP>/<R-DEP>.jsonl
artifacts/<S-DEP>/<content-hash>.art
analysis/leanprover-lean4-v4.34.0-rc2/_latest.txt
analysis/leanprover-lean4-v4.34.0-rc2/<mathlib-sha>.txt

lake cache get --scope=<SCOPE> --rev=<REV> reads revisions/<SCOPE>/<REV>.jsonl, which maps input hashes to artifacts, and downloads the artifacts/<SCOPE>/<content-hash>.art files it names. Lake does not know the analysis/ prefix; the workflow owns it. _latest.txt holds the sha the next run warm starts from, and <mathlib-sha>.txt holds its carryover baseline.

Other changes

Other changes:

  • The legacy cache becomes a bootstrap fallback for a cold analysis chain, so a pinned run hydrates from the shadow scope and compiles the churn since the previous run.
  • proofwidgets is cached like every other dependency. It commits its npm output and the traces that guard the npm steps.
  • The new downstream job sets MATHLIB_NO_CACHE_ON_UPDATE=1, because mathlib's post-update hook otherwise hides what the bucket serves.
  • The scope qualifiers are derived once, in the plan step, and published as job outputs. The downstream job derives its own, because a real downstream project has no access to mathlib's CI outputs.

Validation

Ten dispatch runs cover the clean push, the steady state, the override lane and the downstream project: run 4, run 5, run 7. They report zero root-package rebuilds, 8 of 8 dependencies fetched with zero rebuilds, and 9 of 9 packages fetched downstream with zero Mathlib rebuilds. A run fails if a root-package module rebuilds, so the daily run guards against a regression. A steady-state run takes about 11 minutes.

The pipeline needs lake cache get --package, which the repo pin v4.34.0-rc2 provides. An older toolchain override builds from source and warns.
🤖 Generated with Claude Code

marcelolynch and others added 5 commits August 21, 2026 20:26
The shadow pipeline previously cached only root-package outputs and used the legacy cache for dependencies. Export per-dependency mappings via dep-as-root builds with path overrides, upload each dep under a toolchain-qualified scope keyed by its manifest revision, and rehydrate deps in the consume job with `lake cache get --package` (available since the v4.34.0-rc2 toolchain pin). Deps already in the bucket are skipped end-to-end; override-toolchain runs warm-start deps from the shadow scope.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add a `downstream` job that synthesizes a minimal project depending on mathlib at the built rev, fetches mathlib and all transitive deps from the shadow bucket with `lake cache get --package`, and asserts zero Mathlib module rebuilds. The mappings are produced with mathlib as the workspace root while a downstream builds it as a dependency, so this validates that the input hashes agree across the two workspace shapes and the bucket can serve real downstream projects.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…cture

Three changes that make the shadow model the end state rather than a hybrid. First, dependency scopes gain a pins hash (over the manifest's sorted name+rev pairs) so a partial manifest bump cannot serve stale mappings for deps whose upstreams moved; this mirrors the legacy cache's root hash. Second, proofwidgets is no longer skip-listed: its npm-built JS and guarding traces are committed to its repo, so builds at pinned revs never invoke npm and its Lean modules cache like any other dep's. Third, the legacy cache is demoted to a bootstrap fallback: pinned runs now hydrate root and deps from the shadow scope itself and compile only the churn since the previous run, so the daily run measures exactly what a migrated CI would pay; legacy hydration fires only on a cold analysis chain. All lake cache invocations now go through a generated Lake services file (LAKE_CONFIG + --service=shadow) instead of the deprecated endpoint env vars, and the consume job sets LAKE_NO_CACHE so every replay is attributable to the shadow bucket.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…m job

Mathlib's post-update hook runs the legacy `cache get` when mathlib is updated as a dependency, so the downstream job's `lake update` hydrated the build directories from the legacy cache before the shadow-bucket fetches ran, masking what the bucket actually serves. Set MATHLIB_NO_CACHE_ON_UPDATE=1 in the job so zero-rebuild results are attributable to the shadow bucket alone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…w pipeline

Run 6 died with ENOSPC on a runner host with high baseline disk usage. Log the available disk and host at the start of every run so full hosts are visible, and defensively remove staging/tools-branch paths that sit outside the pr-branch checkout (a no-op on the ephemeral runners, but keeps a reused workspace from ever uploading stale staging).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 22, 2026

Copy link
Copy Markdown

PR summary 02c421ac6f

Import changes for modified files

No significant changes to the import graph

Import changes for all files
Files Import difference

Declarations diff (regex)

No declarations were harmed in the making of this PR! 🐙

You can run this locally as follows
## from your `mathlib4` directory:
git clone https://github.com/leanprover-community/mathlib-ci.git ../mathlib-ci

## summary with just the declaration names:
../mathlib-ci/scripts/pr_summary/declarations_diff.sh <optional_commit>

## more verbose report:
../mathlib-ci/scripts/pr_summary/declarations_diff.sh long <optional_commit>

The doc-module for scripts/pr_summary/declarations_diff.sh in the mathlib-ci repository contains some details about this script.

Declarations diff (Lean -- pending)

Computed after the build finishes.


No changes to strong technical debt.
No changes to weak technical debt.

Current commit 02c421ac6f
Reference commit 23a3216f0e

This script lives in the mathlib-ci repository. To run it locally, from your mathlib4 directory:

git clone https://github.com/leanprover-community/mathlib-ci.git ../mathlib-ci
../mathlib-ci/scripts/reporting/technical-debt-metrics.py pr_summary
  • The relative value is the weighted sum of the differences with weight given by the inverse of the current value of the statistic.
  • The absolute value is the relative value divided by the total sum of the inverses of the current values (i.e. the weighted average of the differences).

⚠️ Workflow documentation reminder

This PR modifies files under .github/workflows/.
Please update docs/workflows.md if the workflow inventory, triggers, or behavior changed.

Modified workflow files:

  • .github/workflows/lake_cache_shadow.md
  • .github/workflows/lake_cache_shadow.yml

@github-actions github-actions Bot added the CI Modifies the continuous integration setup or other automation label Aug 22, 2026
@marcelolynch
marcelolynch force-pushed the ci-dev/lake-cache-shadow-deps branch from f021e62 to ae2f3a7 Compare August 22, 2026 23:25
marcelolynch and others added 11 commits August 22, 2026 16:30
The pipeline's inline scripts were correct but hard to follow: the cache scope derivations appeared in four places with only a "must mirror" note, and several steps generate files whose shape was not visible from the code that writes them.

Add a bucket-layout map to the file header, showing the exact keys a run writes and which of them Lake reads versus which are this workflow's own bookkeeping. Document the rendered form of each generated file: the cache services block, a dependency's `--packages` overrides (the jq expression gives no hint of its output), the exported mappings, the staging tree the upload job navigates, and the downstream project. Explain the toolchain slug and pin hash where they are computed, and say at each duplicate site why the value is recomputed and that drift shows up as a silent miss rather than an error.

Also spell one `[ … ] && …` as an `if`. It is safe under `set -e` where it stands, but fails the step if it ever becomes the last command in the block.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The comments carried the history of the change: contrasts with approaches that were rejected, defences of decisions against earlier review, and speculation about later work. They also ran to long multi-clause sentences, and used several terms for one concept.

Rewrite them to describe the pipeline as it stands. Each comment now uses short sentences in the active voice and present tense, and one term per concept: dependency, revision, bucket, scope, pin hash. Remove the history, the rejected alternatives and the meta-commentary. Keep the rationale a reader needs, such as why a size cap holds its value and why two jobs derive the same scope. Keep the illustrative snippets of the bucket keys, the services block, the overrides file, the exported mappings and the staging tree.

No code changes: the diff touches comment lines only, the eight generated-file bodies are byte-identical, and the workflow passes actionlint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A reader could not tell which parts of the pipeline a full cache hit needs, and which parts only make a run faster. State the four requirements at the top of the file: the lakefile patch, the export build with its overrides, the two scope qualifiers, and the per-package cache calls. Mark the probe, the warm starts and the legacy fallback as optimizations, which change how much a run compiles and uploads but not the keys or the content it writes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The header used S and R both for the scope and revision of a dependency, and for any scope and revision in the general form of the command. Use <SCOPE> and <REV> for the general form.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The toolchain slug and the pin hash were derived in four places. A drift between any two produced no error, only a scope nothing was uploaded to. The plan step now publishes both as job outputs, and the upload and consume jobs read them.

The downstream job keeps its own derivation. A real downstream project has no access to mathlib's CI outputs and must reach the same scope from what it holds locally, which is what that job exists to prove.

Also document why a failed dependency upload does not fail the run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The workflow header carried the whole design: the cache service configuration, the push and pull sequences, the storage layout, the scope qualifiers, and the required repository configuration. That is reference material, and it belongs beside the workflow rather than inside it.

Move it to .github/workflows/lake_cache_shadow.md. The header now says what the pipeline is and points at the document, and the comments in the file cover what each step does.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The notes explained the two scope qualifiers, but not why a dependency needs a scope at all. Lake requires one on every put and get against a custom endpoint, and a scope bounds where a content hash is trusted. Also record why the pipeline builds the string itself: a --repo scope carries the toolchain and the platform, but has no place for the pin hash.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The scope placeholder used the term about 150 lines before the section that defines it. Gloss both qualifiers at first use.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The notes listed the scope qualifiers, and mentioned the consequence inside a bullet about the probe. Neither answered the question directly. Say what identifies an entry, and what a mathlib commit, a toolchain bump and a dependency bump each do to it. Record that the pin hash covers the whole manifest, so one dependency bump re-keys all of them, and that the scope carries no platform segment because every job runs on Linux.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The pin hash covered mathlib's whole manifest, so a bump of any dependency re-keyed every dependency scope. The run then rebuilt and re-uploaded all of them, about 500 MB, although only the bumped dependency and its dependents held different content. mathlib's dependency bumps are automated and land about every other day, so this repeated constantly.

Hash each dependency's own transitive upstream closure instead, as mathlib's manifest pins it. Six of the eight dependencies have no upstream and now carry a constant, so they keep their keys across an unrelated bump; a bump of batteries re-keys batteries, through its revision, and aesop, through its pin hash. Where the closure is unknown, the whole manifest is hashed for that dependency, which over-keys rather than risking a silent miss.

The hash travels with the plan files and the staging manifest as a third column, so only the plan step derives it. The downstream job still derives its own, which is what that job exists to show.

This changes the scope layout, so the next run re-exports and re-uploads every dependency once.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The plan step ran before `lake env`, so no dependency checkout was present and every closure lookup fell back to hashing the whole manifest. Run 32901107689 keyed all eight dependencies as eb6ffa59, and the downstream job, which derives the hashes from its own checkouts, then missed all eight.

Move the plan step after the dependency download, and warn when a closure is unknown, so the fallback is visible instead of silent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CI Modifies the continuous integration setup or other automation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant