Skip to content

Commit f515df3

Browse files
authored
Merge pull request #682 from simonchrz/fix/metadata-cache-consistency
Fix metadata cache responses that don't belong together
2 parents c419f82 + b719035 commit f515df3

21 files changed

Lines changed: 1288 additions & 124 deletions

‎docs/configuration.md‎

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1040,6 +1040,18 @@ A repository's files are cached under its name and a hash of its upstream URL,
10401040
so pointing a repository at a different URL stops serving what the old one
10411041
sent.
10421042

1043+
In every Maven repository, `maven-metadata.xml` follows `metadata_ttl`, and
1044+
its `.md5`, `.sha1`, `.sha256` and `.sha512` files are computed from the
1045+
cached metadata rather than fetched, so they always match it. Other files are
1046+
cached for good, except SNAPSHOT files named with `SNAPSHOT` rather than a
1047+
build timestamp (`lib-1.0-SNAPSHOT.jar`, as JitPack and non-unique snapshot
1048+
repositories publish them), which each build replaces: those are fetched
1049+
again for every `GET`, through the denylist, cooldown, scanning and the
1050+
Plugin Portal fallback like any other file. The new copy replaces the cached
1051+
one once stored; while upstream can't be reached the cached copy is served,
1052+
and `HEAD` is answered from cache. Timestamped SNAPSHOT files
1053+
(`lib-1.0-20240101.120000-1.jar`) never change and are cached.
1054+
10431055
This field has no environment variable, as with the other named upstream maps.
10441056
`PROXY_UPSTREAM_MAVEN` still sets `upstream.maven`.
10451057

@@ -1240,7 +1252,7 @@ Currently supported for npm, PyPI, pub.dev, Composer, Cargo, NuGet, Conda, RubyG
12401252

12411253
Go has no publish time. The module proxy's `.info` gives the commit time of the revision a version points at, so a version tagged on an old commit counts as old. The proxy reads each listed version's `.info` once, stores the time, and leaves versions in cooldown out of `@v/list` and `@latest`. When upstream's `@latest` is in cooldown, it serves the newest release out of cooldown instead. Requests for a version in cooldown, including its `.info`, `.mod` and `.zip`, and a branch or commit query that resolves to one, get 403 rather than 404, because the go command moves on to the next `GOPROXY` entry, such as `direct`, after a 404. If a version's `.info` can't be fetched, the list or download fails with 502 instead of letting the version through.
12421254

1243-
Maven metadata has no publish time per version. The proxy uses the `Last-Modified` header of each version's `.pom`, which on Maven Central is the time the version was deployed. It reads the header once per version and stores the time, for each repository under `upstream.maven_repositories` separately: from the response when the `.pom` or Gradle `.module` itself is downloaded, which clients do before a version's other files, and otherwise with a `HEAD` request. A `.pom` or `.module` refused this way is still cached, so it counts toward `max_size` before the version leaves cooldown. To keep requests to the upstream down, it only checks versions that can still be in cooldown: none when the metadata's `<lastUpdated>` is older than the cooldown, and otherwise the versions from the end of the list back to the first one that is old enough. Versions in cooldown are left out of `maven-metadata.xml`, `<latest>` and `<release>` are set to the last versions left, and the metadata's `.md5`, `.sha1`, `.sha256` and `.sha512` files are computed from what is served. Requests for any file of a version in cooldown get 403, as for a version on the denylist. If a `.pom` can't be checked, the metadata or download fails with 502 instead of letting the version through, or with 503 and `Retry-After` when the upstream rate-limits the proxy; a version without a `.pom` is let through.
1255+
Maven metadata has no publish time per version. The proxy uses the `Last-Modified` header of each version's `.pom`, which on Maven Central is the time the version was deployed. It reads the header once per version and stores the time, for each repository under `upstream.maven_repositories` separately: from the response when the `.pom` or Gradle `.module` itself is downloaded, which clients do before a version's other files, and otherwise with a `HEAD` request. A `.pom` or `.module` refused this way is still cached, so it counts toward `max_size` before the version leaves cooldown. To keep requests to the upstream down, it only checks versions that can still be in cooldown: none when the metadata's `<lastUpdated>` is older than the cooldown, and otherwise the versions from the end of the list back to the first one that is old enough. Versions in cooldown are left out of `maven-metadata.xml`, `<latest>` and `<release>` are set to the last versions left, and the metadata's `.md5`, `.sha1`, `.sha256` and `.sha512` files are computed from what is served, as they are without cooldown. Requests for any file of a version in cooldown get 403, as for a version on the denylist. If a `.pom` can't be checked, the metadata or download fails with 502 instead of letting the version through, or with 503 and `Retry-After` when the upstream rate-limits the proxy; a version without a `.pom` is let through.
12441256

12451257
Maven caveats:
12461258

@@ -1507,7 +1519,7 @@ cache_metadata: true
15071519

15081520
Or via environment variable: `PROXY_CACHE_METADATA=true`.
15091521

1510-
Some metadata never changes once published, and is cached like an artifact, with or without `cache_metadata`, and served without revalidation whatever `metadata_ttl` says: a Go module's `.mod` and `.info` for a canonical version (a release, pre-release, pseudo-version or `+incompatible` version; the checksum database fixes their content), and RPM repodata files named after their own checksum (`repodata/<sha256>-primary.xml.gz`). A stored copy is checked against the digest recorded for it and fetched again if it doesn't match. Go branch and commit queries, `@v/list` and `@latest`, and RPM `repomd.xml` and plainly named repodata files follow `metadata_ttl` as before. A checksum-named repodata file over `metadata_max_size` is streamed from upstream uncached.
1522+
Some metadata never changes once published, and is cached like an artifact, with or without `cache_metadata`, and served without revalidation whatever `metadata_ttl` says: a Go module's `.mod` and `.info` for a canonical version (a release, pre-release, pseudo-version or `+incompatible` version; the checksum database fixes their content), and RPM repodata files named after their own checksum (`repodata/<sha256>-primary.xml.gz`). A stored copy is checked against the digest recorded for it and fetched again if it doesn't match. Go branch and commit queries, `@v/list` and `@latest`, and RPM `repomd.xml` follow `metadata_ttl` as before; plainly named repodata files follow the cached `repomd.xml` (see [Metadata TTL](#metadata-ttl)). A checksum-named repodata file over `metadata_max_size` is streamed from upstream uncached.
15111523

15121524
Composer archive downloads look up their upstream URL in the same cached metadata, so an archive is resolved without asking Packagist again and stays installable while it is down. Only a version or file the cached metadata does not list yet is looked up upstream. With or without `cache_metadata`, a cached archive of a tagged release from the default repository is served without any metadata lookup; with cooldown enabled it is checked against the publish time recorded when the archive was first resolved. Branch (dev) versions, routed packages and archives picked by a query string such as `?sha=` are always resolved from the metadata.
15131525

@@ -1527,7 +1539,9 @@ Or via environment variable: `PROXY_METADATA_TTL=10m`.
15271539

15281540
Set to `"0"` to always revalidate with upstream (ETag-based conditional requests still avoid re-downloading unchanged content).
15291541

1530-
When upstream is unreachable and the cached entry is past its TTL, the proxy serves the stale cached copy with a `Warning: 110 - "Response is Stale"` header so clients can tell the data may be outdated.
1542+
When upstream is unreachable and the cached entry is past its TTL (with a `metadata_ttl` of `"0"`, on every such request), the proxy serves the stale cached copy with a `Warning: 110 - "Response is Stale"` header so clients can tell the data may be outdated. The `ETag` and `Last-Modified` sent with a document are always those of the bytes served, so a document fetched from upstream but not stored, for example because storage failed, carries the upstream's validators rather than the older cached copy's.
1543+
1544+
APT and RPM clients fetch a release file first (`InRelease` or `Release`, `repomd.xml`) and reject an index file whose checksum doesn't match it. So an index file the cached release file lists (`Packages`, `Sources`, a plainly named `primary.xml.gz`, …) does not follow its own `metadata_ttl`: its cached copy is served while it matches the cached release file's sha256, however old, and otherwise it is fetched from upstream. If upstream's copy doesn't match either, the repository has changed since the release file was cached; that copy is served and the release file is expired, so the next `apt update` or `dnf makecache` gets a matching pair. Where a suite has both `InRelease` and `Release` cached, the one fetched last decides. The release file itself follows `metadata_ttl`. Index files it doesn't list, such as `by-hash` paths, follow `metadata_ttl` too.
15311545

15321546
### Change feeds
15331547

‎internal/handler/arch.go‎

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -199,12 +199,12 @@ func (h *ArchHandler) handleDatabase(w http.ResponseWriter, r *http.Request, rep
199199
d := h.database(repository, upstreamURL, path)
200200
if _, hit := p.cachedMetadataState(r.Context(), archEcosystem, d.dbKey, nil); hit != nil {
201201
metrics.RecordMetadataCacheHit(archEcosystem, len(hit.body))
202-
h.writeMetadata(w, r, d.dbKey, *hit)
202+
h.writeMetadata(w, r, *hit)
203203
return
204204
}
205205
p.recordMetadataCacheMiss(archEcosystem)
206206
res := h.sharedRefresh(r.Context(), d, true)
207-
h.writeMetadata(w, r, d.dbKey, res)
207+
h.writeMetadata(w, r, res)
208208
}
209209

210210
// handleDatabaseSignature serves the signature stored for the cached
@@ -218,7 +218,7 @@ func (h *ArchHandler) handleDatabaseSignature(w http.ResponseWriter, r *http.Req
218218
d := h.database(repository, upstreamURL, strings.TrimSuffix(path, archSignatureSuffix))
219219
if res, ok := h.cachedSignature(r.Context(), d); ok {
220220
metrics.RecordMetadataCacheHit(archEcosystem, len(res.body))
221-
h.writeMetadata(w, r, d.sigKey, res)
221+
h.writeMetadata(w, r, res)
222222
return
223223
}
224224
p.recordMetadataCacheMiss(archEcosystem)
@@ -242,7 +242,7 @@ func (h *ArchHandler) handleDatabaseSignature(w http.ResponseWriter, r *http.Req
242242
}
243243
return metadataResult{err: errArchSignatureUnpaired}
244244
})
245-
h.writeMetadata(w, r, d.sigKey, res)
245+
h.writeMetadata(w, r, res)
246246
}
247247

248248
var errArchSignatureUnpaired = errors.New("database signature could not be paired with the database")
@@ -324,7 +324,7 @@ func (h *ArchHandler) fetchDatabase(ctx context.Context, d archDatabase, entry *
324324
return metadataResult{err: fmt.Errorf("upstream failed and no cached metadata: %w", err)}, nil
325325
}
326326
p.Logger.Warn("upstream metadata fetch failed, checking cache", "ecosystem", archEcosystem, "key", d.dbKey, "error", err)
327-
return h.readDatabase(ctx, p.currentMetadataEntry(archEcosystem, d.dbKey, entry), err)
327+
return h.readDatabase(ctx, p.currentMetadataEntry(archEcosystem, d.dbKey, entry), err, true)
328328
}
329329

330330
// storeDatabase caches meta under its digest and returns the database the
@@ -345,15 +345,15 @@ func (h *ArchHandler) storeDatabase(ctx context.Context, d archDatabase, meta *u
345345
row, _ := p.DB.GetMetadataCache(archEcosystem, d.dbKey)
346346
if row == nil {
347347
// Nothing cached at all: serve it like an uncached proxy would.
348-
return metadataResult{body: meta.body, contentType: meta.contentType, contentEncoding: meta.contentEncoding}, nil
348+
return fetchedResult(meta, ""), nil
349349
}
350350
if row.StoragePath != path {
351-
return h.readDatabase(ctx, row, errArchDatabaseNotCached)
351+
return h.readDatabase(ctx, row, errArchDatabaseNotCached, false)
352352
}
353353
if previous != nil && previous.StoragePath != path {
354354
_ = p.Storage.Delete(ctx, previous.StoragePath)
355355
}
356-
return metadataResult{body: meta.body, contentType: meta.contentType, contentEncoding: meta.contentEncoding}, row
356+
return fetchedResult(meta, row.ContentDigest.String), row
357357
}
358358

359359
// blobExists reports whether path is stored. Size eviction can remove a blob
@@ -377,13 +377,18 @@ func (h *ArchHandler) touchDatabase(row *database.MetadataCacheEntry, meta *upst
377377
var errArchDatabaseNotCached = errors.New("database could not be cached")
378378

379379
// readDatabase returns the cached database row's bytes, or cause if they are
380-
// unreadable.
381-
func (h *ArchHandler) readDatabase(ctx context.Context, row *database.MetadataCacheEntry, cause error) (metadataResult, *database.MetadataCacheEntry) {
380+
// unreadable. stale marks them as served because the upstream failed with
381+
// cause.
382+
func (h *ArchHandler) readDatabase(ctx context.Context, row *database.MetadataCacheEntry, cause error, stale bool) (metadataResult, *database.MetadataCacheEntry) {
382383
body, contentType, err := h.proxy.readCachedMetadata(ctx, row, nil)
383384
if err != nil {
384385
return metadataResult{err: fmt.Errorf("cached database unusable (%v): %w", err, cause)}, nil
385386
}
386-
return metadataResult{body: body, contentType: contentType, contentEncoding: row.ContentEncoding.String}, row
387+
res := metadataResult{body: body, contentType: contentType, contentEncoding: row.ContentEncoding.String}
388+
if stale {
389+
res.stale, res.upstreamErr = true, cause
390+
}
391+
return res.describedBy(row), row
387392
}
388393

389394
// lock serializes cache writes for one database and its signature, so a row
@@ -433,7 +438,7 @@ func (h *ArchHandler) storedSignature(ctx context.Context, d archDatabase, dbDig
433438
if err != nil {
434439
return metadataResult{}, false
435440
}
436-
return metadataResult{body: body, contentType: contentType, contentEncoding: entry.ContentEncoding.String}, true
441+
return metadataResult{body: body, contentType: contentType, contentEncoding: entry.ContentEncoding.String}.describedBy(entry), true
437442
}
438443
return metadataResult{}, false
439444
}
@@ -478,7 +483,7 @@ func archDigest(body []byte) string {
478483
return "sha256:" + hex.EncodeToString(digest[:])
479484
}
480485

481-
func (h *ArchHandler) writeMetadata(w http.ResponseWriter, r *http.Request, cacheKey string, res metadataResult) {
486+
func (h *ArchHandler) writeMetadata(w http.ResponseWriter, r *http.Request, res metadataResult) {
482487
if res.err != nil {
483488
if errors.Is(res.err, ErrUpstreamNotFound) {
484489
http.Error(w, "not found", http.StatusNotFound)
@@ -491,7 +496,7 @@ func (h *ArchHandler) writeMetadata(w http.ResponseWriter, r *http.Request, cach
491496
http.Error(w, "failed to fetch from upstream", http.StatusBadGateway)
492497
return
493498
}
494-
h.proxy.writeMetadataCachedResponseWithEncoding(w, r, archEcosystem, cacheKey, res.body, res.contentType, res.contentEncoding)
499+
writeMetadataResult(w, r, res)
495500
}
496501

497502
// metadataCacheKey hashes repository, upstream URL and path, as APKHandler does.

‎internal/handler/arch_test.go‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package handler
22

33
import (
4+
"database/sql"
45
"fmt"
56
"net/http"
67
"net/http/httptest"
@@ -648,3 +649,55 @@ func serveArchRequest(h *ArchHandler, method, target string, header http.Header)
648649
h.Routes().ServeHTTP(w, req)
649650
return w
650651
}
652+
653+
// TestArchHandler_DatabaseValidatorsDescribeBody checks a fetched database
654+
// goes out with its own Last-Modified, not that of the cache row, which
655+
// another instance may have rewritten while the signature was fetched.
656+
func TestArchHandler_DatabaseValidatorsDescribeBody(t *testing.T) {
657+
const dbPath, sigPath = "/core/os/x86_64/core.db", "/core/os/x86_64/core.db.sig"
658+
fetched := time.Date(2026, 10, 1, 12, 0, 0, 0, time.UTC)
659+
rewritten := fetched.Add(time.Hour)
660+
upstream := newArchUpstream(t, map[string][]byte{dbPath: []byte("core-db-v1"), sigPath: []byte("sig")})
661+
upstream.modified[dbPath] = fetched
662+
proxy := newArchTestProxy(t, upstream)
663+
h := NewArchHandler(proxy, "http://proxy.example", map[string]string{"archlinux": upstream.URL})
664+
key := h.metadataCacheKey("archlinux", upstream.URL, "core/os/x86_64/core.db")
665+
upstream.served = func(path string) {
666+
if path != sigPath {
667+
return
668+
}
669+
entry, err := proxy.DB.GetMetadataCache(archEcosystem, key)
670+
if err != nil || entry == nil {
671+
t.Errorf("database row while fetching the signature: %v", err)
672+
return
673+
}
674+
entry.LastModified = sql.NullTime{Time: rewritten, Valid: true}
675+
if err := proxy.DB.UpsertMetadataCache(entry); err != nil {
676+
t.Error(err)
677+
}
678+
}
679+
680+
w := serveArchRequest(h, http.MethodGet, "/archlinux"+dbPath, nil)
681+
assertArchBody(t, w, "database", []byte("core-db-v1"))
682+
if got := w.Header().Get("Last-Modified"); got != fetched.Format(http.TimeFormat) {
683+
t.Errorf("Last-Modified = %q, want the fetched database's %q", got, fetched.Format(http.TimeFormat))
684+
}
685+
}
686+
687+
// TestArchHandler_StaleDatabaseMarkedWithoutTTL checks the stale fallback
688+
// carries the Warning header with metadata_ttl 0.
689+
func TestArchHandler_StaleDatabaseMarkedWithoutTTL(t *testing.T) {
690+
db := []byte("core-db-bytes")
691+
upstream := newArchUpstream(t, map[string][]byte{"/core/os/x86_64/core.db": db})
692+
proxy := newArchTestProxy(t, upstream)
693+
proxy.MetadataTTL = 0
694+
h := NewArchHandler(proxy, "http://proxy.example", map[string]string{"archlinux": upstream.URL})
695+
696+
assertArchBody(t, serveArchRequest(h, http.MethodGet, "/archlinux/core/os/x86_64/core.db", nil), "seed", db)
697+
upstream.available.Store(false)
698+
w := serveArchRequest(h, http.MethodGet, "/archlinux/core/os/x86_64/core.db", nil)
699+
assertArchBody(t, w, "stale", db)
700+
if got := w.Header().Get("Warning"); got != staleWarning {
701+
t.Errorf("Warning = %q, want %q", got, staleWarning)
702+
}
703+
}

‎internal/handler/conda_shards.go‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -58,11 +58,12 @@ func (h *CondaHandler) handleShardIndex(w http.ResponseWriter, r *http.Request)
5858
upstreamURL := h.upstreamURL + r.URL.Path
5959
cacheKey := h.metadataCacheKey(r.URL.Path)
6060

61-
body, contentType, contentEncoding, err := h.proxy.fetchOrCacheMetadata(r.Context(), "conda", cacheKey, upstreamURL, "identity", nil, "*/*")
62-
if err != nil {
63-
h.proxy.writeMetadataFetchError(w, err)
61+
res := h.proxy.lookupMetadata(r.Context(), "conda", cacheKey, upstreamURL, "*/*", "identity", nil)
62+
if res.err != nil {
63+
h.proxy.writeMetadataFetchError(w, res.err)
6464
return
6565
}
66+
body := res.body
6667

6768
if !h.cooldownEnabled() {
6869
index, err := decodeCondaShardIndex(body)
@@ -74,7 +75,7 @@ func (h *CondaHandler) handleShardIndex(w http.ResponseWriter, r *http.Request)
7475
http.NotFound(w, r)
7576
return
7677
}
77-
h.proxy.writeMetadataCachedResponseWithEncoding(w, r, "conda", cacheKey, body, contentType, contentEncoding)
78+
writeMetadataResult(w, r, res)
7879
return
7980
}
8081

@@ -121,7 +122,7 @@ func (h *CondaHandler) handleCooldownShard(w http.ResponseWriter, r *http.Reques
121122
}
122123
upstreamPath := "/" + channel + "/" + arch + "/" + dir + hex.EncodeToString(masked) + condaShardSuffix
123124

124-
body, _, _, err := h.proxy.fetchOrCacheMetadata(r.Context(), "conda", h.metadataCacheKey(upstreamPath), h.upstreamURL+upstreamPath, "identity", nil, "*/*")
125+
body, _, err := h.proxy.fetchOrCacheMetadata(r.Context(), "conda", h.metadataCacheKey(upstreamPath), h.upstreamURL+upstreamPath, "identity", nil, "*/*")
125126
if err != nil {
126127
h.proxy.writeMetadataFetchError(w, err)
127128
return

0 commit comments

Comments
 (0)