Skip to content

One cache for everything the tool fetches, keyed four ways - #37

Merged
tamnd merged 1 commit into
mainfrom
cache
Sep 5, 2026
Merged

tamnd merged 1 commit into
mainfrom
cache

Conversation

@tamnd

@tamnd tamnd commented Sep 5, 2026

Copy link
Copy Markdown
Owner

The M1 checklist line is "the artifact cache keyed on repository, tag, platform and configuration". This is that.

What was there before

The citation checker had a cache. It held source files pulled out of dotnet/runtime and dotnet/roslyn, and it had all of the following problems at once. Its own directory, separate from anything else. Its own environment variable, XRAY_CITE_CACHE, which was written down in no document and so was findable only by reading the source. No way to look inside it. No way to empty it. Nothing recording what any of the files in it were or where they came from. And it lived for the length of one CI job, so every pull request fetched every cited file over again.

It also believed whatever was sitting on disk. That is the part that matters, and it is the part this PR is mostly about.

The key

A name, and the four ways two things with the same name can differ.

Part What it is
Repository Where it came from, such as dotnet/runtime
Tag The commit or tag inside that repository. A pin, never a branch
Platform The runtime identifier, or any for something that does not vary
Configuration Release, Checked, Debug, or any
Name What the thing is called. Slashes allowed, so a fetched source file keeps its shape

The only thing in the cache today varies along two of those four. A source file is the same text whatever machine asked for it and whatever configuration that machine builds in, so it says any twice rather than leaving the axes out.

The other two are there for what is coming, and putting them in now is a judgement call worth stating rather than burying. A checked and a release libclrjit.so, from the same commit, on the same platform, have the same file name, roughly the same size, and are different programs. A cache that mixed those up would hand a lesson the wrong JIT, and the lesson would run, print output, and pin numbers that are correct for a runtime nobody was using. Nothing would go red. Adding an axis to a key after the fact means invalidating every cache anybody has, so the time to be right about the layout is while it holds four text files.

A key becomes a path, one directory per part, file last. Anything that is not a letter, a digit, a dot, an underscore or a hyphen becomes a hyphen. A part that is empty, or ., or .., or an absolute path, is refused rather than escaped.

Why every entry has a hash

The point of this is not speed.

Everything else in this repository is checked by regenerating it and comparing against what is committed. A cache exists precisely so that the thing does not get regenerated, which makes it the one set of files here that is read back and believed. So each entry has a small JSON file beside it recording the address it came from, its size, when it arrived and the sha256 of the bytes. The hash is checked on every read. An entry that no longer matches is deleted and fetched again, with a line saying so, rather than used.

This is not mainly about an attacker, although it covers one. It is about the ordinary way a cache goes wrong, which is that somebody edits a file in it to try something out, forgets, and then every run on that machine reads the edit for the next six months and nothing anywhere notices.

The commands

There were none before. There are five now.

$ xray cache path
/Users/apple/Library/Application Support/xray/cache

$ xray cache key --repository dotnet/runtime --tag v10.0.0 --platform linux-x64 --configuration checked --name libclrjit.so
xray1-dotnet-runtime-v10.0.0-linux-x64-checked-libclrjit.so

$ xray cache list
dotnet/roslyn@871ef63... any any src/Compilers/Core/Portable/PEWriter/MetadataWriter.cs
  181488 byte(s), fetched 2026-09-05T18:38:34Z from https://raw.githubusercontent.com/dotnet/roslyn/871ef63.../src/Compilers/Core/Portable/PEWriter/MetadataWriter.cs
dotnet/runtime@60629d1... any any src/coreclr/vm/methodtable.h
  155077 byte(s), fetched 2026-09-05T18:38:34Z from https://raw.githubusercontent.com/dotnet/runtime/60629d1.../src/coreclr/vm/methodtable.h
xray cache: 2 entr(ies) under /Users/apple/Library/Application Support/xray/cache

xray cache clear empties it. xray cache key exists so that a workflow does not type a key by hand and then disagree with the tool about what a key is. The layout version is on the front of it, so changing the layout abandons an old cache instead of restoring it into a tool that would read it wrong.

Proving it works

xray cache --selftest, sixteen assertions, no network, so it runs anywhere.

A cache that lost everything and refetched every time would pass a test that only checks the answers come out right, and would show up as nothing worse than a slow build. A cache that handed back the wrong entry would show up as a lesson with confident, wrong numbers. Neither of those goes red on its own, so the cases go at them directly.

ok    hands back what was put in it
ok    says nothing for something it does not have
ok    keeps two things apart when only the repository differs
ok    keeps two things apart when only the tag differs
ok    keeps two things apart when only the platform differs
ok    keeps two things apart when only the configuration differs
ok    refuses an entry that has been altered since it was stored
ok    says why it refused the altered entry
ok    throws away an entry it has refused
ok    records where each thing came from
ok    records the size and the hash of what arrived
ok    refuses a key whose name is '../../escaped.txt'
ok    refuses a key whose name is '..'
ok    refuses a key whose name is ''
ok    empties itself and says how much it removed
ok    is empty after being emptied
xray cache --selftest: 0 failure(s)

The first two are the control. A cache that stored nothing would pass every case below them.

One decision that changed a test

The citation self test used to point at a cache directory of its own, so that running it would not touch a real one. It now uses the shared cache.

The reason is that until the pin lands there are no citations in this repository, so the four files that self test resolves are the only things anything here ever fetches. Leaving it pointed somewhere private would mean the cache is exercised by nothing at all until November. Its cases that expect a refusal are unaffected, because nothing stores a 404, so the network is still reached whatever is on disk.

That makes it possible to check the cache is doing its job rather than assume it. Running the citation self test twice and listing the cache in between shows the timestamps unchanged, so the second run read from disk rather than fetching again.

CI

A cache job for the self test, which is a new required context and needs adding to branch protection after the first run.

The cite job now restores and saves the cache between runs. The path and the key both come out of the tool rather than being typed into the workflow, and the key carries a hash of pin.json, because what that job is allowed to fetch is exactly what the pin names. It ends with xray cache list, so the log says what was fetched and from where. A cache is a set of files a program wrote while nobody was looking, and the run that fills it is the run that ought to say what went in.

Checked before pushing

xray lint: 20 file(s), 0 problem(s)
xray check --offline: 46 file(s), 0 problem(s)
xray cite: 0 citation(s), 0 problem(s)
xray cite --selftest: 0 failure(s)
xray cache --selftest: 0 failure(s)
xray numbers lessons: 2 lesson(s), 0 problem(s)
xray numbers --selftest: 0 failure(s)
xray assert --selftest: 0 failure(s)
xray check --selftest: 0 failure(s)
dotnet format --verify-no-changes: clean

Not in this PR

Nothing fetches a binary yet. xray get E1, which would use this to pull a checked JIT, is the next thing that wants it and is deliberately separate. E1 is declared and detected today, but the tool still cannot go and get it for you.

The citation checker had a cache of its own. Its own directory, its own
environment variable that appeared in no document, no way to look inside it,
no way to empty it, nothing recording what any of it was, and it lived for the
length of one CI job so every pull request fetched every cited file again.

This replaces it with one cache, keyed by repository, tag, platform and
configuration, with a record beside each entry saying where it came from and
the sha256 of what arrived. The hash is checked on the way back out and an
entry that no longer matches is deleted and refetched rather than used.
@tamnd tamnd added this to the M1 The toolchain milestone Sep 5, 2026
@tamnd tamnd added kind/tooling xray, the generators, the widgets and the checkers priority/p0 Blocks the current milestone area/build The build matrix, containers, CI and the drift bot labels Sep 5, 2026
@tamnd
tamnd merged commit 71629e0 into main Sep 5, 2026
18 checks passed
@tamnd
tamnd deleted the cache branch September 5, 2026 19:09
@tamnd tamnd mentioned this pull request Sep 5, 2026
7 tasks done
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/build The build matrix, containers, CI and the drift bot kind/tooling xray, the generators, the widgets and the checkers priority/p0 Blocks the current milestone

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant