Skip to content

A remote cache hit is never stored back, so a server that shares entries across endpoints can't keep each endpoint's own entries current #924

Open

Description

@jasonkuhrt

Written by an AI agent (Claude) on behalf of @jasonkuhrt.

Remote caching is still unreleased (#820), and its API is drafted in #713, so this is a request for the protocol while it can still change.

What happens

In read-write mode, Vite Task uploads an entry only after it executes a task. ExecutionCache::update records the entry locally, then spawns the upload (crates/vt/src/session/cache/mod.rs:521-568 at a3c087a7, the revision Vite+ 1.4.0 pins, called from session/execute/cache_update.rs:190).

A remote hit takes the other path. try_hit_remote fetches and validates it (mod.rs:453-490), and restore extracts its archive and records it locally (mod.rs:583-619). As try_hit's doc puts it, the hit "is recorded locally by [Self::restore], and is never uploaded" (mod.rs:350-353). main at c6c91f9 is the same.

The protocol has each endpoint name its own storage, and an exact match is "the entry stored under the requested key" (crates/vt_remote_cache/src/lib.rs:113). Some servers also share entries between related endpoints. GitHub Actions' cache does this: a run can restore a cache that the default branch created when its own branch has none (GitHub: dependency caching).

A server like that has a gap. When it answers one endpoint's fetch with another endpoint's entry, the requesting endpoint's own entry for that key stays as it was. And because that machine's local cache now hits, no later run there fetches the task or uploads it either.

The case that motivates it

We run our own server for the protocol, with one endpoint per branch. Writes are scoped per branch for the reason GitHub Actions scopes its cache by branch: a pull request's CI can't replace the entries that the default branch, and every branch reading it, use. Several candidates per key (#778) would let branches share one namespace, but not keep a pull request from writing where the default branch reads. A fetch on a branch's endpoint serves the most recently stored entry under the key among that branch's entries and the default branch's, so pull requests replay what the default branch's CI stored.

After a merge, the default branch's endpoint could read the merged branch's entries too, and replay what the merged branch's CI stored. We tried it. One run replayed 146 tasks from the merged branch, and the default branch's endpoint kept its older entry for every one of them. Every branch reads the default branch's entries, so all of them missed those tasks until the default branch next executed them.

So the default branch no longer reads the merged branch. Its CI re-executes work that the merged branch's CI already did, and branches miss those tasks until that run finishes.

The ask

An optional store field on a fetch's exact match, which asks a read-write client to store the entry it was just served:

{ kind: "exact", value: bytes, blob_id: string | null, store: true }

What it means in each case:

  • Absent or false: nothing changes. Today's servers never send it, and Fetched doesn't deny unknown fields (lib.rs:109-131), so today's clients ignore it.
  • A read client ignores it.
  • A hit that validates, restores and is recorded locally is stored the way update stores an executed task's entry: the same key and secondary_key, and the value as it was recorded locally, with its blob. That value differs from the served bytes only in output_archive, the local archive's file name, which every client replaces with its own on download (mod.rs:489). The blob is the archive just downloaded, and the store uses the configured auth.
  • A hit that fails validation, or whose archive fails to download, is a miss and isn't stored. The task executes, and the usual upload after executing stores it.
  • A hit whose outputs fail to restore fails the task, as it does today, and isn't stored.
  • A hit that restores but fails to record locally (mod.rs:607-616) isn't stored either. The next run fetches it again, and the server can ask again.
  • Timing: the store runs in the background like an upload after executing, so the task's dependents don't wait. The run waits for it before exiting, as it waits for uploads (crates/vt/src/session/execute/scheduler.rs:302).
  • A store that fails never fails the task. Its error is reported on the hit's row of the summary.
  • A fallback match has no store field.

The server decides when to ask, and the client needs no notion of where an entry came from.

Where it goes:

  • The field on Fetched::Exact (lib.rs:114-122).
  • The flag carried through remote::resolve and Restore (remote.rs:95-137), try_hit_remote and CacheHit.
  • In ExecutionCache::restore, once a remote hit is recorded locally, the same prepare_upload and uploads.spawn that update uses (mod.rs:554-566).
  • A field for a hit's store error in the summary. Today set_upload_error applies only to a task that ran (crates/vt/src/session/reporter/summary.rs:402-411).

Alternatives considered

Not part of this ask: a store by reference, which would name a blob the server already holds instead of sending the archive again. It would make the store cheaper, but it needs a protocol addition of its own, and the flag works without it.

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

    Fields

    Priority

    None yet

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions