2026-01-20 21:52:44 +00:00
|
|
|
// Package handler provides HTTP protocol handlers for package manager proxying.
|
|
|
|
|
package handler
|
|
|
|
|
|
|
|
|
|
import (
|
2026-03-19 21:06:02 +00:00
|
|
|
"bytes"
|
2026-01-20 21:52:44 +00:00
|
|
|
"context"
|
|
|
|
|
"database/sql"
|
2026-04-08 16:02:30 +01:00
|
|
|
"errors"
|
2026-01-20 21:52:44 +00:00
|
|
|
"fmt"
|
|
|
|
|
"io"
|
|
|
|
|
"log/slog"
|
|
|
|
|
"net/http"
|
2026-04-27 12:14:37 +01:00
|
|
|
"net/url"
|
Fix metadata caching, 404 propagation, mirror progress, and registry stubs
- ProxyCached now stores upstream Last-Modified in the cache and uses it
(along with ETag) for conditional request handling, returning 304 when
client validators match. Adds Content-Length to cached responses.
- Handlers calling FetchOrCacheMetadata (pypi, composer, pub, nuget) now
check for ErrUpstreamNotFound and return 404 instead of 502, matching
the existing npm and cargo behavior.
- Mirror jobs report live progress via a periodic callback while running,
so API polls return real counts instead of zeroed progress.
- Registry mirroring removed from CLI flags, API acceptance, README, and
docs since every enumerator was a stub returning "not yet implemented".
- Added tests for the conditional metadata path (ETag/If-None-Match,
Last-Modified/If-Modified-Since, 304 responses, header omission).
2026-04-01 20:14:11 +01:00
|
|
|
"strconv"
|
2026-03-12 12:05:52 +00:00
|
|
|
"strings"
|
2026-08-13 08:06:41 +01:00
|
|
|
"sync"
|
2026-01-20 21:52:44 +00:00
|
|
|
"time"
|
|
|
|
|
|
2026-09-04 10:58:22 +01:00
|
|
|
"github.com/git-pkgs/artifacts"
|
2026-05-13 06:45:33 +01:00
|
|
|
"github.com/git-pkgs/cooldown"
|
2026-01-20 21:52:44 +00:00
|
|
|
"github.com/git-pkgs/proxy/internal/database"
|
2026-09-20 14:42:30 +05:30
|
|
|
"github.com/git-pkgs/proxy/internal/denylist"
|
2026-10-15 13:35:12 +01:00
|
|
|
"github.com/git-pkgs/proxy/internal/httpclient"
|
2026-02-03 22:40:23 +00:00
|
|
|
"github.com/git-pkgs/proxy/internal/metrics"
|
2026-09-02 13:06:34 +01:00
|
|
|
"github.com/git-pkgs/proxy/internal/packageurl"
|
2026-09-03 05:06:44 -04:00
|
|
|
"github.com/git-pkgs/proxy/internal/scanner"
|
2026-01-20 21:52:44 +00:00
|
|
|
"github.com/git-pkgs/proxy/internal/storage"
|
2026-03-04 09:20:16 +00:00
|
|
|
"github.com/git-pkgs/purl"
|
2026-02-20 08:47:14 +00:00
|
|
|
"github.com/git-pkgs/registries/fetch"
|
2026-09-04 10:58:22 +01:00
|
|
|
"github.com/opencontainers/go-digest"
|
2026-01-20 21:52:44 +00:00
|
|
|
)
|
|
|
|
|
|
2026-03-12 12:05:52 +00:00
|
|
|
// containsPathTraversal returns true if the path contains ".." segments
|
2026-05-03 09:07:16 +01:00
|
|
|
// that could be used to escape the intended directory. It checks the path
|
|
|
|
|
// as given and after URL-decoding, and treats backslashes as separators.
|
2026-03-12 12:05:52 +00:00
|
|
|
func containsPathTraversal(path string) bool {
|
2026-05-03 09:07:16 +01:00
|
|
|
if hasDotDotSegment(path) {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
if decoded, err := url.PathUnescape(path); err == nil && decoded != path {
|
|
|
|
|
return hasDotDotSegment(decoded)
|
|
|
|
|
}
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func hasDotDotSegment(path string) bool {
|
|
|
|
|
path = strings.ReplaceAll(path, "\\", "/")
|
|
|
|
|
for segment := range strings.SplitSeq(path, "/") {
|
2026-03-12 12:05:52 +00:00
|
|
|
if segment == ".." {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-29 11:19:54 +01:00
|
|
|
func configuredUpstreamURL(value, defaultValue string) string {
|
|
|
|
|
if value == "" {
|
|
|
|
|
value = defaultValue
|
|
|
|
|
}
|
|
|
|
|
return strings.TrimRight(value, "/")
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-18 10:59:29 +00:00
|
|
|
const defaultHTTPTimeout = 30 * time.Second
|
|
|
|
|
|
2026-08-13 08:06:41 +01:00
|
|
|
const artifactCopyBufferSize = 32 << 10
|
|
|
|
|
|
|
|
|
|
var artifactCopyBufferPool = sync.Pool{ //nolint:gochecknoglobals // shared across artifact responses
|
|
|
|
|
New: func() any {
|
|
|
|
|
buffer := make([]byte, artifactCopyBufferSize)
|
|
|
|
|
return &buffer
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-17 18:11:47 -07:00
|
|
|
// canonicalPackagePURL returns a versionless PURL in canonical form so cooldown
|
|
|
|
|
// lookups match keys produced by config.CooldownConfig.NormalizedPackages.
|
|
|
|
|
func canonicalPackagePURL(ecosystem, name string) string {
|
2026-09-02 13:06:34 +01:00
|
|
|
return packageurl.MakeString(ecosystem, name, "")
|
2026-07-17 18:11:47 -07:00
|
|
|
}
|
|
|
|
|
|
2026-09-02 07:17:55 -04:00
|
|
|
// canonicalVersionPURL returns a versioned PURL in canonical form, matching
|
|
|
|
|
// the keys the artifact cache writes to the versions table.
|
|
|
|
|
func canonicalVersionPURL(ecosystem, name, version string) string {
|
2026-09-02 13:06:34 +01:00
|
|
|
return packageurl.MakeString(ecosystem, name, version)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var errUnsupportedPackageIdentity = errors.New("package identity cannot be represented as a PURL")
|
|
|
|
|
|
|
|
|
|
func packagePURLStrings(ecosystem, name, version string) (string, string, error) {
|
|
|
|
|
packagePURL := packageurl.MakeString(ecosystem, name, "")
|
|
|
|
|
versionPURL := packageurl.MakeString(ecosystem, name, version)
|
|
|
|
|
if packagePURL == "" || versionPURL == "" {
|
|
|
|
|
return "", "", fmt.Errorf("%w: %s %q", errUnsupportedPackageIdentity, ecosystem, name)
|
|
|
|
|
}
|
|
|
|
|
return packagePURL, versionPURL, nil
|
2026-09-02 07:17:55 -04:00
|
|
|
}
|
|
|
|
|
|
2026-03-19 21:06:02 +00:00
|
|
|
const contentTypeJSON = "application/json"
|
|
|
|
|
|
2026-09-02 13:22:02 +01:00
|
|
|
const (
|
2026-10-01 10:09:33 +02:00
|
|
|
headerAccept = "Accept"
|
fix(handler): preserve upstream content-encoding for cached metadata (#304)
* fix(handler): preserve upstream content-encoding for cached metadata
Signed and hash-pinned index files (debian Release/Packages.gz, rpm
repomd.xml, helm index.yaml, conda repodata.json, apk APKINDEX.tar.gz)
were cached after Go's transport transparently decompressed any
Content-Encoding: gzip response, so the proxy served bytes that differ
from what the upstream signed and broke client verification.
Request the identity encoding on the shared metadata fetch so Go no
longer auto-decompresses, and persist the upstream Content-Encoding in
a new metadata_cache column so both the cached and offline responses
replay the exact bytes and header. The uncached streaming path now
forwards Content-Encoding too.
Fixes #300
* fix(handler): scope verbatim metadata fetch to the ProxyCached path
The first cut forced Accept-Encoding: identity in the shared
fetchUpstreamMetadata, which an adversarial review showed both missed
the bug and regressed unrelated ecosystems:
- proxyMetadataStream (the default path, since cache_metadata is off)
never forced identity, so a client sending no Accept-Encoding still
triggered Go's transparent gzip decompression and served altered bytes
for apk/debian/rpm/conda indexes.
- Direct FetchOrCacheMetadata callers that parse or rewrite the body
(npm, pypi, cargo, composer, pub, nuget, swift, maven, helm) were
forced to identity too, losing wire compression and 502-ing against
upstreams that ignore identity and gzip anyway (helm rewriteIndex).
- Rows cached before the fix kept serving decompressed bytes via ETag
304 revalidation.
Scope the verbatim behavior to the ProxyCached code path, which serves
upstream bytes through unchanged (apk, debian, rpm, go, hex, conda,
cran, gem, conan, julia). That path now requests identity on both the
cached fetch and the uncached stream branch and replays Content-Encoding;
direct callers keep transparent compression, matching main. Migration
008 clears etag/fetched_at so legacy rows refetch once with identity.
Tests now exercise the stream path with no client Accept-Encoding and
pin that direct callers are not forced to identity.
2026-09-03 10:58:58 +02:00
|
|
|
headerAcceptEncoding = "Accept-Encoding"
|
|
|
|
|
headerContentType = "Content-Type"
|
|
|
|
|
headerContentLength = "Content-Length"
|
|
|
|
|
headerContentEncoding = "Content-Encoding"
|
2026-09-03 16:59:12 +01:00
|
|
|
headerETag = "ETag"
|
|
|
|
|
headerLastModified = "Last-Modified"
|
2026-09-02 13:22:02 +01:00
|
|
|
)
|
2026-05-05 10:25:17 +01:00
|
|
|
|
2026-09-03 16:59:12 +01:00
|
|
|
// ifNoneMatchHits reports whether the given If-None-Match header value
|
|
|
|
|
// matches the current entity tag using weak comparison, so "*" matches any
|
|
|
|
|
// tag, W/ prefixes are ignored on both sides, and a comma-separated list is
|
|
|
|
|
// scanned. An empty header or an empty stored tag never match.
|
|
|
|
|
func ifNoneMatchHits(header, etag string) bool {
|
|
|
|
|
if etag == "" || header == "" {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
if header == "*" {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
etag = strings.TrimPrefix(etag, "W/")
|
|
|
|
|
for value := range strings.SplitSeq(header, ",") {
|
|
|
|
|
if strings.TrimPrefix(strings.TrimSpace(value), "W/") == etag {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-02 07:59:00 +01:00
|
|
|
// defaultMetadataMaxSize is used when Proxy.MetadataMaxSize is unset.
|
|
|
|
|
const defaultMetadataMaxSize = 100 << 20
|
2026-04-08 16:02:30 +01:00
|
|
|
|
2026-06-02 07:59:00 +01:00
|
|
|
// ErrMetadataTooLarge is returned when upstream metadata exceeds the configured limit.
|
2026-04-08 16:02:30 +01:00
|
|
|
var ErrMetadataTooLarge = errors.New("metadata response exceeds size limit")
|
2026-03-12 12:04:12 +00:00
|
|
|
|
|
|
|
|
// ReadMetadata reads an upstream response body with a size limit to prevent OOM
|
2026-04-08 16:02:30 +01:00
|
|
|
// from unexpectedly large responses. Returns ErrMetadataTooLarge if the response
|
|
|
|
|
// is truncated by the limit.
|
2026-06-02 07:59:00 +01:00
|
|
|
func (p *Proxy) ReadMetadata(r io.Reader) ([]byte, error) {
|
|
|
|
|
limit := p.MetadataMaxSize
|
|
|
|
|
if limit <= 0 {
|
|
|
|
|
limit = defaultMetadataMaxSize
|
|
|
|
|
}
|
|
|
|
|
data, err := io.ReadAll(io.LimitReader(r, limit+1))
|
2026-04-08 16:02:30 +01:00
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
2026-06-02 07:59:00 +01:00
|
|
|
if int64(len(data)) > limit {
|
2026-04-08 16:02:30 +01:00
|
|
|
return nil, ErrMetadataTooLarge
|
|
|
|
|
}
|
|
|
|
|
return data, nil
|
2026-03-12 12:04:12 +00:00
|
|
|
}
|
|
|
|
|
|
2026-01-20 21:52:44 +00:00
|
|
|
// Proxy provides shared functionality for protocol handlers.
|
|
|
|
|
type Proxy struct {
|
2026-05-04 12:15:16 +02:00
|
|
|
DB *database.DB
|
|
|
|
|
Storage storage.Storage
|
|
|
|
|
Fetcher fetch.FetcherInterface
|
|
|
|
|
Resolver *fetch.Resolver
|
|
|
|
|
Logger *slog.Logger
|
2026-10-04 20:48:41 +01:00
|
|
|
Cooldown CooldownPolicy
|
2026-09-20 14:42:30 +05:30
|
|
|
Denylist *denylist.Policy
|
2026-05-04 12:15:16 +02:00
|
|
|
CacheMetadata bool
|
|
|
|
|
MetadataTTL time.Duration
|
2026-06-02 07:59:00 +01:00
|
|
|
MetadataMaxSize int64
|
2026-05-04 12:15:16 +02:00
|
|
|
GradleReadOnly bool
|
2026-10-13 13:19:58 +02:00
|
|
|
HostedEnabled bool
|
2026-05-04 12:15:16 +02:00
|
|
|
GradleMaxUploadSize int64
|
2026-09-02 07:13:12 -04:00
|
|
|
// NPMFullMetadata requests full npm packuments from upstream even when
|
|
|
|
|
// cooldown is disabled, so served metadata carries publish times.
|
|
|
|
|
NPMFullMetadata bool
|
|
|
|
|
DirectServe bool
|
|
|
|
|
DirectServeTTL time.Duration
|
2026-04-27 12:14:37 +01:00
|
|
|
// DirectServeBaseURL, if set, replaces the scheme and host of presigned
|
|
|
|
|
// URLs so clients receive a public address even when the proxy reaches
|
|
|
|
|
// storage at an internal one.
|
|
|
|
|
DirectServeBaseURL string
|
|
|
|
|
HTTPClient *http.Client
|
2026-09-03 05:06:44 -04:00
|
|
|
|
2026-10-15 13:35:12 +01:00
|
|
|
// RateLimits is the gate under the upstream clients. Artifact fetches
|
|
|
|
|
// check it before calling Fetcher, so held hosts never reach its breaker.
|
|
|
|
|
RateLimits *httpclient.RateLimitGate
|
|
|
|
|
|
2026-10-13 13:42:34 +02:00
|
|
|
// HostedNPMScopes and HostedComposerVendors name namespaces served from
|
|
|
|
|
// pushed packages alone, without asking the upstream.
|
|
|
|
|
HostedNPMScopes []string
|
|
|
|
|
HostedComposerVendors []string
|
|
|
|
|
|
Add storage.cache_artifacts to serve artifacts without storing them (#387)
* Add storage.passthrough to serve artifacts without storing them
With storage.passthrough enabled the proxy streams every artifact from
upstream to the client and never writes it to storage or the cache
database. Metadata filtering, cooldown and the denylist work as before,
so the proxy can sit behind another cache (e.g. an Artifactory remote)
purely as a policy layer without holding a second copy of every package.
Artifacts whose digest is known up front (OCI, Swift, Helm) are verified
while streaming. Their responses are sent chunked and the connection is
aborted on a mismatch, so a client never receives a tampered artifact as
a complete response.
Passthrough is rejected together with scanning, direct_serve and
mirror_api, and the mirror command refuses to run with it, since all of
them depend on stored artifacts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Address review: abort truncated streams, guard every cache read
- serveArtifact aborts the response on any body read error, and when fewer
bytes than the declared size were written, not only on a digest mismatch.
A failed or short upstream body in passthrough mode no longer reaches the
client as a complete 200.
- Streamed upstream read failures are logged and counted as
stream_failed upstream errors before the response is aborted.
- checkCache reports a miss when artifact caching is off, so every cache
read path, including Swift archive HEAD requests, ignores entries stored
before the mode was enabled. Denylisted versions are still rejected.
- Rename storage.passthrough to storage.cache_artifacts (default true,
PROXY_STORAGE_CACHE_ARTIFACTS), which names what actually changes.
Tests cover an OCI blob shorter than its Content-Length and an npm tarball
whose chunked response ends without the final chunk, both served through
the HTTP handlers, plus a Swift HEAD request against a cached archive.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Rename Proxy.Passthrough to StreamArtifacts
Match the storage.cache_artifacts option: the field says what changes
(artifacts are streamed instead of stored) and its zero value keeps the
usual caching behaviour for every Proxy built without the server config,
such as the mirror command and tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 15:28:20 +07:00
|
|
|
// StreamArtifacts streams artifacts from upstream without storing them.
|
|
|
|
|
// Each request fetches its own copy: there is no cache to check and
|
|
|
|
|
// nothing for concurrent misses to share.
|
|
|
|
|
StreamArtifacts bool
|
|
|
|
|
|
2026-09-03 05:06:44 -04:00
|
|
|
// Scanners runs pre-cache artifact scanning (e.g. trivy, ClamAV, Wiz).
|
|
|
|
|
// Nil or disabled means artifacts are cached without scanning.
|
|
|
|
|
Scanners *scanner.Group
|
|
|
|
|
|
|
|
|
|
// ScanSigningKey authenticates pull requests to the internal
|
|
|
|
|
// /_internal/scan-fetch route used by scanners to retrieve staged
|
|
|
|
|
// artifacts, for every storage backend.
|
|
|
|
|
ScanSigningKey []byte
|
|
|
|
|
|
|
|
|
|
// ScanFetchBaseURL is the address scanners use to reach this proxy to
|
|
|
|
|
// pull staged artifacts.
|
|
|
|
|
ScanFetchBaseURL string
|
Fix duplicate fetches and 502s on concurrent cache misses (#329)
* Return the stored artifact from storeArtifact, not a reader
storeArtifact returned a CacheResult holding an open file handle. A
handle has one read position, so it can only ever serve a single caller,
which is what blocks sharing one fetch between concurrent requests.
Return the artifact and its storage path instead, and let each caller
open its own reader through openStoredArtifact. Threading that type
through fetchAndCache, fetchAndCacheFromURL and their error paths is
mechanical; behaviour is unchanged.
* Coalesce concurrent cache misses
A cache miss went from checkCache straight to an upstream fetch with
nothing tracking in-flight work, so N concurrent requests for one
uncached artifact produced N upstream fetches and N stores to the same
key. That is the CI shape: parallel jobs installing overlapping
dependencies against a cold cache. The duplicate stores also fail
requests, racing fileblob's per-key ".attrs" sidecar into a partial read
served as a 502. Over 12 runs of 8 simultaneous requests for one
uncached tarball, against bb2205a: before, 8 fetches per run and 12 of
96 responses were 502; after, 1 fetch per run and none failed.
Route both miss paths through a shared in-flight map keyed on the
artifact, including the download URL and upstream-declared hash so
callers expecting different bytes never share a fetch.
singleflight does not fit: Do gives waiters no way to leave, while
DoChan lets the caller running the fetch abandon it, breaking
storeArtifact's scan-on-disconnect contract. Deciding roles under a
mutex gives both behaviours. The fetch runs on the first caller's
context and is seen through; waiters leave when their own clients do.
This removes the sidecar trigger on this path. The race is in fileblob
and three writers bypass this path entirely, so it is fixed separately.
Fewer failures now reach the circuit breaker, so it trips later.
Sixteen concurrent callers against real file:// storage fail 10 of 10
runs on main and pass 10 of 10 here. Other tests pin key discrimination,
failure propagation, resolver-path coalescing, per-caller readers,
waiter cancellation, key release and panic safety. allocs/op is
unchanged. mockStorage gains a mutex so concurrent tests can use it.
* Normalize digest case in the coalescing key
artifactHashMatches compares digests with strings.EqualFold, but the
coalescing key used the hash verbatim. The same digest in two casings
produced two keys, so two callers for one artifact each ran their own
upstream fetch and store, which is what the coalescing is meant to
prevent.
* Make the panic coalescing test deterministic
The test timed the second caller's arrival with a sleep, so which caller
became the leader was left to the scheduler. When it lost that race the
second caller ran the fetch itself, and its panic was not recovered, so
the test binary died instead of the test failing.
Whether a caller has reached the wait is not observable from outside:
it runs a cache lookup against the database first, so releasing the
leader on a timer races that query. Drive coalesceFetch directly and
hold the shared entry instead, which removes the timing entirely. The
panicking fetcher is no longer needed.
* Recheck the cache before running a shared fetch
A caller checks the cache before it reaches coalesceFetch, so a fetch
that commits in that gap is invisible to it. Arriving after the sharing
entry is gone, it became a new leader and fetched, stored and scanned an
artifact the cache already held.
The leader now rechecks the committed record first. It serves that
record only if its bytes still open, because a record can outlive them,
and refetching is the recovery the cache lookup already makes for that
case. Waiters are unaffected: the record fills the same shared value a
fetch would, and each caller opens its own reader from it.
The recheck is the leader's alone. A waiter has a fetch in flight to
wait on, and rechecking would race it for no gain.
* Lock the mock fetcher's bookkeeping
Coalescing tests call the handler from many goroutines. The key keeps
the fetch itself serialized, but the mock should not lean on that: it
now locks the fields it records, so any concurrency the handler applies
is safe under the race detector.
* Wait for the leader's fetch instead of sleeping
The canceled-waiter test slept 200ms and assumed the leader had taken
the key by then. On a slow scheduler the canceled call could become the
leader and the test would no longer cover waiter cancellation. The
fetcher now signals when its first fetch begins, which happens only once
the key is held.
2026-09-15 11:59:03 -07:00
|
|
|
|
|
|
|
|
// inFlight coalesces concurrent cache misses for one artifact, so a single
|
|
|
|
|
// upstream fetch serves every waiting caller. Keyed by artifactCoalesceKey.
|
|
|
|
|
fetchMu sync.Mutex
|
|
|
|
|
inFlight map[string]*inflightFetch
|
Cache rewritten npm and Composer metadata (#402)
The npm and Composer handlers rewrite every metadata document they serve
so that download URLs point at the proxy: decode the whole document into
generic maps, change the URLs, encode it again, and for Composer expand
the minified format first. That ran on every request, cached metadata
included. With metadata caching on and upstream out of the picture, a
cached request still cost 7 ms for @babel/core, 17 ms for
symfony/console and 155 ms and 134 MB of allocations for typescript, and
an 8 vCPU VM serving cached npm packuments to 20 clients ran the proxy
at 450% CPU for about 1,100 requests a second.
Rewritten documents are now kept in memory, keyed by the ecosystem,
proxy URL, package and a SHA-256 of the raw document, so new bytes from
upstream are rewritten again and nothing is served stale. Requests that
arrive while a document is being rewritten wait for that rewrite rather
than running their own; a waiter leaves when its client does, and the
rewrite still completes and is cached. The denylist is fixed at startup,
so it needs no place in the key. Cooldown filtering depends on the
current time, so with cooldown on the cache is bypassed.
metadata_rewrite_cache_size bounds the cache (default "256MB", least
recently used out first, "0" to rewrite on every request). NewProxy
callers keep the old behaviour unless they set it.
Whole cached requests, measured locally:
@babel/core 7.1 ms -> 0.38 ms, 54,489 -> 119 allocations
typescript 155 ms -> 10.5 ms, 1.37M -> 133 allocations
symfony/console 17.4 ms -> 0.48 ms, 165,909 -> 123 allocations
What remains is reading the raw document from storage and hashing it.
2026-10-03 04:09:15 -07:00
|
|
|
|
2026-10-11 23:51:43 +02:00
|
|
|
// detachSlots caps the artifact fetches that keep running after their
|
|
|
|
|
// client went away (see DetachUpstreamFetch). Guarded by fetchMu while it
|
|
|
|
|
// is created.
|
|
|
|
|
detachSlots chan struct{}
|
|
|
|
|
|
Coalesce concurrent metadata fetches (#400)
* Coalesce concurrent metadata fetches
Concurrent cache misses for one artifact already share a single upstream
fetch (#329), but metadata misses did not: every request for a package's
metadata went to the registry, even when the same document was already
being fetched. In CI that is the common case. Composer resolves versions
on every job, since MediaWiki core and its extensions commit no
composer.lock, and each package is one /p2/ document, so a burst of jobs
fetched each document once per job.
Metadata misses now follow the same pattern as artifacts: the first caller
fetches, the rest wait for its result, and the caller that takes the key
rechecks the cache first so a fetch that just finished is not repeated.
The key includes the Accept and Accept-Encoding headers, which change the
bytes upstream returns (npm's abbreviated and full documents), and whether
the caller validates the response.
One difference from artifacts: the shared fetch is detached from the first
caller's cancellation. Artifact fetches keep it for scanning and mirroring,
neither of which applies to metadata, and otherwise one job disconnecting
would fail every job waiting on the same document. runScan detaches the
same way, and the HTTP client's timeout still bounds the fetch.
This covers every ecosystem that goes through fetchOrCacheMetadata
(Composer, npm, PyPI, Maven, Cargo, Swift, Pub, Helm, NuGet), with
metadata caching on or off. Streamed metadata with caching off is not
coalesced.
On an 8 vCPU test instance with caching off, 10 concurrent clients
requesting the npm metadata of 1,263 packages went from 12,630 upstream
requests to 1,898. With cache_metadata on, 20 clients requesting 1,538
packages made exactly 1,538.
* Run each caller's validate on a shared metadata fetch
A caller that joined another's metadata fetch got the shared bytes
without running its own validate. NuGet's cooldown path uses validate to
decode the document for the request, so a joined download found no
publication date and was allowed, bypassing the cooldown.
coalesceMetadata now reports whether the caller joined, and
coalescedMetadataMiss runs that caller's validate on the shared bytes.
Validation before caching is unchanged: the first caller still runs it
inside the fetch.
TestNuGetCooldownConcurrentDownloads reproduces the bypass through
NuGetHandler.Routes() with two overlapping downloads of a version inside
its cooldown.
2026-10-04 09:50:45 -07:00
|
|
|
// inFlightMeta does the same for metadata misses. Keyed by
|
|
|
|
|
// metadataCoalesceKey.
|
|
|
|
|
metaMu sync.Mutex
|
|
|
|
|
inFlightMeta map[string]*inflightMetadata
|
|
|
|
|
|
Cache rewritten npm and Composer metadata (#402)
The npm and Composer handlers rewrite every metadata document they serve
so that download URLs point at the proxy: decode the whole document into
generic maps, change the URLs, encode it again, and for Composer expand
the minified format first. That ran on every request, cached metadata
included. With metadata caching on and upstream out of the picture, a
cached request still cost 7 ms for @babel/core, 17 ms for
symfony/console and 155 ms and 134 MB of allocations for typescript, and
an 8 vCPU VM serving cached npm packuments to 20 clients ran the proxy
at 450% CPU for about 1,100 requests a second.
Rewritten documents are now kept in memory, keyed by the ecosystem,
proxy URL, package and a SHA-256 of the raw document, so new bytes from
upstream are rewritten again and nothing is served stale. Requests that
arrive while a document is being rewritten wait for that rewrite rather
than running their own; a waiter leaves when its client does, and the
rewrite still completes and is cached. The denylist is fixed at startup,
so it needs no place in the key. Cooldown filtering depends on the
current time, so with cooldown on the cache is bypassed.
metadata_rewrite_cache_size bounds the cache (default "256MB", least
recently used out first, "0" to rewrite on every request). NewProxy
callers keep the old behaviour unless they set it.
Whole cached requests, measured locally:
@babel/core 7.1 ms -> 0.38 ms, 54,489 -> 119 allocations
typescript 155 ms -> 10.5 ms, 1.37M -> 133 allocations
symfony/console 17.4 ms -> 0.48 ms, 165,909 -> 123 allocations
What remains is reading the raw document from storage and hashing it.
2026-10-03 04:09:15 -07:00
|
|
|
// rewrites caches metadata documents after their handler rewrites them.
|
|
|
|
|
// Nil leaves every request to rewrite its own copy.
|
|
|
|
|
rewrites *rewriteCache
|
Share the dates of rewritten metadata across replicas
Dates were kept in each process's rewrite cache, so replicas sharing a
database and storage dated independently: one dated a document, upstream
changed in that second, and another gave the new document the same date,
so a client holding it got 304 and kept the old document.
- Date from metadata_dates instead of process memory: a package's date is
the second it started serving its current representation, recorded once
for every replica
- Switch a package to another representation only in a second later than
its recorded date, and serve it undated until then
- Date nothing in the second the process started, so dates clients hold
from before this change, or from the process it replaced, stay older
- Serve no date ahead of the local clock, recorded by a replica whose clock
is ahead
- Date rewrites without the rewrite cache too; the cache keeps only ETags
- Add two-replica, pre-fix date and clock skew regression tests
2026-10-12 12:52:02 +02:00
|
|
|
|
|
|
|
|
// now is the clock rewritten metadata is dated by, and started the time
|
|
|
|
|
// the process started; see metadataDate.
|
|
|
|
|
now func() time.Time
|
|
|
|
|
started time.Time
|
2026-01-20 21:52:44 +00:00
|
|
|
}
|
|
|
|
|
|
2026-10-04 20:48:41 +01:00
|
|
|
// CooldownPolicy supplies version-age filtering and package-specific durations.
|
|
|
|
|
type CooldownPolicy interface {
|
|
|
|
|
IsAllowed(ecosystem, packagePURL string, publishedAt time.Time) bool
|
|
|
|
|
Evaluate(ecosystem, packagePURL string, publishedAt, evaluatedAt time.Time) cooldown.Decision
|
|
|
|
|
For(ecosystem, packagePURL string) time.Duration
|
|
|
|
|
Enabled() bool
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-20 21:52:44 +00:00
|
|
|
// NewProxy creates a new Proxy with the given dependencies.
|
2026-02-20 08:47:14 +00:00
|
|
|
func NewProxy(db *database.DB, store storage.Storage, fetcher fetch.FetcherInterface, resolver *fetch.Resolver, logger *slog.Logger) *Proxy {
|
2026-01-20 21:52:44 +00:00
|
|
|
if logger == nil {
|
|
|
|
|
logger = slog.Default()
|
|
|
|
|
}
|
|
|
|
|
return &Proxy{
|
|
|
|
|
DB: db,
|
|
|
|
|
Storage: store,
|
|
|
|
|
Fetcher: fetcher,
|
|
|
|
|
Resolver: resolver,
|
|
|
|
|
Logger: logger,
|
2026-03-13 07:44:11 +00:00
|
|
|
HTTPClient: &http.Client{
|
2026-03-18 10:59:29 +00:00
|
|
|
Timeout: defaultHTTPTimeout,
|
2026-03-13 07:44:11 +00:00
|
|
|
},
|
Share the dates of rewritten metadata across replicas
Dates were kept in each process's rewrite cache, so replicas sharing a
database and storage dated independently: one dated a document, upstream
changed in that second, and another gave the new document the same date,
so a client holding it got 304 and kept the old document.
- Date from metadata_dates instead of process memory: a package's date is
the second it started serving its current representation, recorded once
for every replica
- Switch a package to another representation only in a second later than
its recorded date, and serve it undated until then
- Date nothing in the second the process started, so dates clients hold
from before this change, or from the process it replaced, stay older
- Serve no date ahead of the local clock, recorded by a replica whose clock
is ahead
- Date rewrites without the rewrite cache too; the cache keeps only ETags
- Add two-replica, pre-fix date and clock skew regression tests
2026-10-12 12:52:02 +02:00
|
|
|
now: time.Now,
|
|
|
|
|
started: time.Now(),
|
2026-01-20 21:52:44 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// CacheResult contains information about a cached or fetched artifact.
|
|
|
|
|
type CacheResult struct {
|
|
|
|
|
Reader io.ReadCloser
|
2026-04-27 12:04:38 +01:00
|
|
|
RedirectURL string
|
2026-09-04 10:58:22 +01:00
|
|
|
Artifact artifacts.Artifact
|
2026-01-20 21:52:44 +00:00
|
|
|
Cached bool
|
2026-09-02 13:06:34 +01:00
|
|
|
storagePath string
|
2026-01-20 21:52:44 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// GetOrFetchArtifact retrieves an artifact from cache or fetches from upstream.
|
|
|
|
|
func (p *Proxy) GetOrFetchArtifact(ctx context.Context, ecosystem, name, version, filename string) (*CacheResult, error) {
|
2026-10-12 19:56:38 +02:00
|
|
|
// NuGet ids are case-insensitive and the NuGet handler stores them
|
|
|
|
|
// lowercased, so a mirrored Newtonsoft.Json must land on the same record
|
|
|
|
|
// clients are served.
|
|
|
|
|
if ecosystem == "nuget" {
|
|
|
|
|
name = strings.ToLower(name)
|
|
|
|
|
}
|
2026-09-02 13:06:34 +01:00
|
|
|
pkgPURL, versionPURL, err := packagePURLStrings(ecosystem, name, version)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
2026-09-23 17:51:49 +05:30
|
|
|
// Deny before resolution as well as before reading the cache. Mirror callers
|
|
|
|
|
// do not supply a filename, and resolving it can contact an upstream registry.
|
|
|
|
|
if p.Denylist.Denied(versionPURL) {
|
|
|
|
|
return nil, fmt.Errorf("%w: %s", ErrVersionDenied, versionPURL)
|
|
|
|
|
}
|
|
|
|
|
var info *fetch.ArtifactInfo
|
|
|
|
|
if filename == "" {
|
|
|
|
|
info, err = p.resolveArtifact(ctx, ecosystem, name, version)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
filename = info.Filename
|
|
|
|
|
if filename == "" {
|
|
|
|
|
return nil, errors.New("resolved artifact has no filename")
|
|
|
|
|
}
|
|
|
|
|
}
|
Add storage.cache_artifacts to serve artifacts without storing them (#387)
* Add storage.passthrough to serve artifacts without storing them
With storage.passthrough enabled the proxy streams every artifact from
upstream to the client and never writes it to storage or the cache
database. Metadata filtering, cooldown and the denylist work as before,
so the proxy can sit behind another cache (e.g. an Artifactory remote)
purely as a policy layer without holding a second copy of every package.
Artifacts whose digest is known up front (OCI, Swift, Helm) are verified
while streaming. Their responses are sent chunked and the connection is
aborted on a mismatch, so a client never receives a tampered artifact as
a complete response.
Passthrough is rejected together with scanning, direct_serve and
mirror_api, and the mirror command refuses to run with it, since all of
them depend on stored artifacts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Address review: abort truncated streams, guard every cache read
- serveArtifact aborts the response on any body read error, and when fewer
bytes than the declared size were written, not only on a digest mismatch.
A failed or short upstream body in passthrough mode no longer reaches the
client as a complete 200.
- Streamed upstream read failures are logged and counted as
stream_failed upstream errors before the response is aborted.
- checkCache reports a miss when artifact caching is off, so every cache
read path, including Swift archive HEAD requests, ignores entries stored
before the mode was enabled. Denylisted versions are still rejected.
- Rename storage.passthrough to storage.cache_artifacts (default true,
PROXY_STORAGE_CACHE_ARTIFACTS), which names what actually changes.
Tests cover an OCI blob shorter than its Content-Length and an npm tarball
whose chunked response ends without the final chunk, both served through
the HTTP handlers, plus a Swift HEAD request against a cached archive.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Rename Proxy.Passthrough to StreamArtifacts
Match the storage.cache_artifacts option: the field says what changes
(artifacts are streamed instead of stored) and its zero value keeps the
usual caching behaviour for every Proxy built without the server config,
such as the mirror command and tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 15:28:20 +07:00
|
|
|
if p.StreamArtifacts {
|
|
|
|
|
if info == nil {
|
|
|
|
|
if info, err = p.resolveArtifact(ctx, ecosystem, name, version); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-10-14 15:36:26 +02:00
|
|
|
return p.streamFromUpstream(ctx, ecosystem, name, version, filename, versionPURL, info.URL, "", "",
|
Add storage.cache_artifacts to serve artifacts without storing them (#387)
* Add storage.passthrough to serve artifacts without storing them
With storage.passthrough enabled the proxy streams every artifact from
upstream to the client and never writes it to storage or the cache
database. Metadata filtering, cooldown and the denylist work as before,
so the proxy can sit behind another cache (e.g. an Artifactory remote)
purely as a policy layer without holding a second copy of every package.
Artifacts whose digest is known up front (OCI, Swift, Helm) are verified
while streaming. Their responses are sent chunked and the connection is
aborted on a mismatch, so a client never receives a tampered artifact as
a complete response.
Passthrough is rejected together with scanning, direct_serve and
mirror_api, and the mirror command refuses to run with it, since all of
them depend on stored artifacts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Address review: abort truncated streams, guard every cache read
- serveArtifact aborts the response on any body read error, and when fewer
bytes than the declared size were written, not only on a digest mismatch.
A failed or short upstream body in passthrough mode no longer reaches the
client as a complete 200.
- Streamed upstream read failures are logged and counted as
stream_failed upstream errors before the response is aborted.
- checkCache reports a miss when artifact caching is off, so every cache
read path, including Swift archive HEAD requests, ignores entries stored
before the mode was enabled. Denylisted versions are still rejected.
- Rename storage.passthrough to storage.cache_artifacts (default true,
PROXY_STORAGE_CACHE_ARTIFACTS), which names what actually changes.
Tests cover an OCI blob shorter than its Content-Length and an npm tarball
whose chunked response ends without the final chunk, both served through
the HTTP handlers, plus a Swift HEAD request against a cached archive.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Rename Proxy.Passthrough to StreamArtifacts
Match the storage.cache_artifacts option: the field says what changes
(artifacts are streamed instead of stored) and its zero value keeps the
usual caching behaviour for every Proxy built without the server config,
such as the mirror command and tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 15:28:20 +07:00
|
|
|
func(fetchCtx context.Context) (*fetch.Artifact, error) {
|
|
|
|
|
return p.Fetcher.Fetch(fetchCtx, info.URL)
|
|
|
|
|
})
|
|
|
|
|
}
|
2026-09-02 13:06:34 +01:00
|
|
|
if cached, err := p.checkCache(ctx, pkgPURL, versionPURL, filename); err != nil {
|
2026-01-21 22:47:23 +00:00
|
|
|
return nil, err
|
|
|
|
|
} else if cached != nil {
|
|
|
|
|
return cached, nil
|
|
|
|
|
}
|
2026-08-16 18:01:59 +01:00
|
|
|
metrics.RecordCacheMiss(ecosystem)
|
2026-01-21 22:47:23 +00:00
|
|
|
|
2026-10-14 15:36:26 +02:00
|
|
|
key := artifactCoalesceKey(versionPURL, filename, "", "", "")
|
Fix duplicate fetches and 502s on concurrent cache misses (#329)
* Return the stored artifact from storeArtifact, not a reader
storeArtifact returned a CacheResult holding an open file handle. A
handle has one read position, so it can only ever serve a single caller,
which is what blocks sharing one fetch between concurrent requests.
Return the artifact and its storage path instead, and let each caller
open its own reader through openStoredArtifact. Threading that type
through fetchAndCache, fetchAndCacheFromURL and their error paths is
mechanical; behaviour is unchanged.
* Coalesce concurrent cache misses
A cache miss went from checkCache straight to an upstream fetch with
nothing tracking in-flight work, so N concurrent requests for one
uncached artifact produced N upstream fetches and N stores to the same
key. That is the CI shape: parallel jobs installing overlapping
dependencies against a cold cache. The duplicate stores also fail
requests, racing fileblob's per-key ".attrs" sidecar into a partial read
served as a 502. Over 12 runs of 8 simultaneous requests for one
uncached tarball, against bb2205a: before, 8 fetches per run and 12 of
96 responses were 502; after, 1 fetch per run and none failed.
Route both miss paths through a shared in-flight map keyed on the
artifact, including the download URL and upstream-declared hash so
callers expecting different bytes never share a fetch.
singleflight does not fit: Do gives waiters no way to leave, while
DoChan lets the caller running the fetch abandon it, breaking
storeArtifact's scan-on-disconnect contract. Deciding roles under a
mutex gives both behaviours. The fetch runs on the first caller's
context and is seen through; waiters leave when their own clients do.
This removes the sidecar trigger on this path. The race is in fileblob
and three writers bypass this path entirely, so it is fixed separately.
Fewer failures now reach the circuit breaker, so it trips later.
Sixteen concurrent callers against real file:// storage fail 10 of 10
runs on main and pass 10 of 10 here. Other tests pin key discrimination,
failure propagation, resolver-path coalescing, per-caller readers,
waiter cancellation, key release and panic safety. allocs/op is
unchanged. mockStorage gains a mutex so concurrent tests can use it.
* Normalize digest case in the coalescing key
artifactHashMatches compares digests with strings.EqualFold, but the
coalescing key used the hash verbatim. The same digest in two casings
produced two keys, so two callers for one artifact each ran their own
upstream fetch and store, which is what the coalescing is meant to
prevent.
* Make the panic coalescing test deterministic
The test timed the second caller's arrival with a sleep, so which caller
became the leader was left to the scheduler. When it lost that race the
second caller ran the fetch itself, and its panic was not recovered, so
the test binary died instead of the test failing.
Whether a caller has reached the wait is not observable from outside:
it runs a cache lookup against the database first, so releasing the
leader on a timer races that query. Drive coalesceFetch directly and
hold the shared entry instead, which removes the timing entirely. The
panicking fetcher is no longer needed.
* Recheck the cache before running a shared fetch
A caller checks the cache before it reaches coalesceFetch, so a fetch
that commits in that gap is invisible to it. Arriving after the sharing
entry is gone, it became a new leader and fetched, stored and scanned an
artifact the cache already held.
The leader now rechecks the committed record first. It serves that
record only if its bytes still open, because a record can outlive them,
and refetching is the recovery the cache lookup already makes for that
case. Waiters are unaffected: the record fills the same shared value a
fetch would, and each caller opens its own reader from it.
The recheck is the leader's alone. A waiter has a fetch in flight to
wait on, and rechecking would race it for no gain.
* Lock the mock fetcher's bookkeeping
Coalescing tests call the handler from many goroutines. The key keeps
the fetch itself serialized, but the mock should not lean on that: it
now locks the fields it records, so any concurrency the handler applies
is safe under the race detector.
* Wait for the leader's fetch instead of sleeping
The canceled-waiter test slept 200ms and assumed the leader had taken
the key by then. On a slow scheduler the canceled call could become the
leader and the test would no longer cover waiter cancellation. The
fetcher now signals when its first fetch begins, which happens only once
the key is held.
2026-09-15 11:59:03 -07:00
|
|
|
recheck := func() (artifacts.Artifact, string, bool) {
|
|
|
|
|
return p.cachedArtifactRecord(pkgPURL, versionPURL, filename, "")
|
|
|
|
|
}
|
|
|
|
|
return p.coalesceFetch(ctx, key, recheck, func(fetchCtx context.Context) (artifacts.Artifact, string, error) {
|
2026-09-23 17:51:49 +05:30
|
|
|
return p.fetchAndCache(fetchCtx, ecosystem, name, version, filename, pkgPURL, versionPURL, info)
|
Fix duplicate fetches and 502s on concurrent cache misses (#329)
* Return the stored artifact from storeArtifact, not a reader
storeArtifact returned a CacheResult holding an open file handle. A
handle has one read position, so it can only ever serve a single caller,
which is what blocks sharing one fetch between concurrent requests.
Return the artifact and its storage path instead, and let each caller
open its own reader through openStoredArtifact. Threading that type
through fetchAndCache, fetchAndCacheFromURL and their error paths is
mechanical; behaviour is unchanged.
* Coalesce concurrent cache misses
A cache miss went from checkCache straight to an upstream fetch with
nothing tracking in-flight work, so N concurrent requests for one
uncached artifact produced N upstream fetches and N stores to the same
key. That is the CI shape: parallel jobs installing overlapping
dependencies against a cold cache. The duplicate stores also fail
requests, racing fileblob's per-key ".attrs" sidecar into a partial read
served as a 502. Over 12 runs of 8 simultaneous requests for one
uncached tarball, against bb2205a: before, 8 fetches per run and 12 of
96 responses were 502; after, 1 fetch per run and none failed.
Route both miss paths through a shared in-flight map keyed on the
artifact, including the download URL and upstream-declared hash so
callers expecting different bytes never share a fetch.
singleflight does not fit: Do gives waiters no way to leave, while
DoChan lets the caller running the fetch abandon it, breaking
storeArtifact's scan-on-disconnect contract. Deciding roles under a
mutex gives both behaviours. The fetch runs on the first caller's
context and is seen through; waiters leave when their own clients do.
This removes the sidecar trigger on this path. The race is in fileblob
and three writers bypass this path entirely, so it is fixed separately.
Fewer failures now reach the circuit breaker, so it trips later.
Sixteen concurrent callers against real file:// storage fail 10 of 10
runs on main and pass 10 of 10 here. Other tests pin key discrimination,
failure propagation, resolver-path coalescing, per-caller readers,
waiter cancellation, key release and panic safety. allocs/op is
unchanged. mockStorage gains a mutex so concurrent tests can use it.
* Normalize digest case in the coalescing key
artifactHashMatches compares digests with strings.EqualFold, but the
coalescing key used the hash verbatim. The same digest in two casings
produced two keys, so two callers for one artifact each ran their own
upstream fetch and store, which is what the coalescing is meant to
prevent.
* Make the panic coalescing test deterministic
The test timed the second caller's arrival with a sleep, so which caller
became the leader was left to the scheduler. When it lost that race the
second caller ran the fetch itself, and its panic was not recovered, so
the test binary died instead of the test failing.
Whether a caller has reached the wait is not observable from outside:
it runs a cache lookup against the database first, so releasing the
leader on a timer races that query. Drive coalesceFetch directly and
hold the shared entry instead, which removes the timing entirely. The
panicking fetcher is no longer needed.
* Recheck the cache before running a shared fetch
A caller checks the cache before it reaches coalesceFetch, so a fetch
that commits in that gap is invisible to it. Arriving after the sharing
entry is gone, it became a new leader and fetched, stored and scanned an
artifact the cache already held.
The leader now rechecks the committed record first. It serves that
record only if its bytes still open, because a record can outlive them,
and refetching is the recovery the cache lookup already makes for that
case. Waiters are unaffected: the record fills the same shared value a
fetch would, and each caller opens its own reader from it.
The recheck is the leader's alone. A waiter has a fetch in flight to
wait on, and rechecking would race it for no gain.
* Lock the mock fetcher's bookkeeping
Coalescing tests call the handler from many goroutines. The key keeps
the fetch itself serialized, but the mock should not lean on that: it
now locks the fields it records, so any concurrency the handler applies
is safe under the race detector.
* Wait for the leader's fetch instead of sleeping
The canceled-waiter test slept 200ms and assumed the leader had taken
the key by then. On a slow scheduler the canceled call could become the
leader and the test would no longer cover waiter cancellation. The
fetcher now signals when its first fetch begins, which happens only once
the key is held.
2026-09-15 11:59:03 -07:00
|
|
|
})
|
2026-01-21 22:47:23 +00:00
|
|
|
}
|
|
|
|
|
|
2026-08-13 07:35:07 +01:00
|
|
|
// GetCachedArtifact retrieves an artifact from cache without contacting an upstream.
|
|
|
|
|
// It returns nil when no usable cache entry exists.
|
|
|
|
|
func (p *Proxy) GetCachedArtifact(ctx context.Context, ecosystem, name, version, filename string) (*CacheResult, error) {
|
2026-09-02 13:06:34 +01:00
|
|
|
pkgPURL, versionPURL, err := packagePURLStrings(ecosystem, name, version)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
2026-08-13 07:35:07 +01:00
|
|
|
return p.checkCache(ctx, pkgPURL, versionPURL, filename)
|
|
|
|
|
}
|
|
|
|
|
|
Give each fetch its own storage path
Every fetch of an artifact wrote one path, so fetches with different URLs
or digests, which do not share a coalescing key, could overwrite each
other's bytes, serve the wrong ones, or delete them on a digest mismatch
while the other was about to open them.
storeArtifact now writes {ecosystem}/{name}/{version}/{fetch id}/{filename},
with a random id per fetch. Most ecosystems learn the digest only after
the fetch, and storage has no rename, so the id is the one rule that fits
all of them. Existing records keep their paths and stay readable. On a
file:// bucket, Delete now removes an emptied fetch directory, which
fileblob leaves behind, and no other directory, since another fetch may
be creating its own inside it.
A path a record stops pointing at is queued in pending_deletes rather
than deleted, since a request that read the record may still open it. A
loop deletes queued paths after max(1h, direct_serve_ttl), whether or not
max_size is set. UpsertArtifact and clears apply only if the record still
holds the path the caller read, so racing commits each queue the path
they replaced and a clear never orphans a newer commit. Eviction still
deletes inline, and counts space as freed only when the record still
pointed at what it deleted.
2026-09-23 14:40:13 -07:00
|
|
|
// ClearCachedArtifact clears an artifact cache record after an external
|
|
|
|
|
// integrity check fails, and queues its stored bytes for deletion.
|
2026-09-29 10:24:22 -07:00
|
|
|
func (p *Proxy) ClearCachedArtifact(ecosystem, name, version, filename string) error {
|
2026-08-16 22:42:55 +05:30
|
|
|
if p.DB == nil || p.Storage == nil {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
2026-09-02 13:06:34 +01:00
|
|
|
pkgPURL, versionPURL, err := packagePURLStrings(ecosystem, name, version)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
2026-08-16 22:42:55 +05:30
|
|
|
cached, err := p.DB.GetCachedArtifact(pkgPURL, versionPURL, filename)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("looking up cached artifact: %w", err)
|
|
|
|
|
}
|
|
|
|
|
if cached == nil {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
Give each fetch its own storage path
Every fetch of an artifact wrote one path, so fetches with different URLs
or digests, which do not share a coalescing key, could overwrite each
other's bytes, serve the wrong ones, or delete them on a digest mismatch
while the other was about to open them.
storeArtifact now writes {ecosystem}/{name}/{version}/{fetch id}/{filename},
with a random id per fetch. Most ecosystems learn the digest only after
the fetch, and storage has no rename, so the id is the one rule that fits
all of them. Existing records keep their paths and stay readable. On a
file:// bucket, Delete now removes an emptied fetch directory, which
fileblob leaves behind, and no other directory, since another fetch may
be creating its own inside it.
A path a record stops pointing at is queued in pending_deletes rather
than deleted, since a request that read the record may still open it. A
loop deletes queued paths after max(1h, direct_serve_ttl), whether or not
max_size is set. UpsertArtifact and clears apply only if the record still
holds the path the caller read, so racing commits each queue the path
they replaced and a clear never orphans a newer commit. Eviction still
deletes inline, and counts space as freed only when the record still
pointed at what it deleted.
2026-09-23 14:40:13 -07:00
|
|
|
return p.DB.DiscardArtifact(versionPURL, filename, cached.StoragePath)
|
2026-08-16 22:42:55 +05:30
|
|
|
}
|
|
|
|
|
|
2026-01-21 22:47:23 +00:00
|
|
|
// checkCache looks up an artifact in the cache. Returns nil if not cached.
|
Add storage.cache_artifacts to serve artifacts without storing them (#387)
* Add storage.passthrough to serve artifacts without storing them
With storage.passthrough enabled the proxy streams every artifact from
upstream to the client and never writes it to storage or the cache
database. Metadata filtering, cooldown and the denylist work as before,
so the proxy can sit behind another cache (e.g. an Artifactory remote)
purely as a policy layer without holding a second copy of every package.
Artifacts whose digest is known up front (OCI, Swift, Helm) are verified
while streaming. Their responses are sent chunked and the connection is
aborted on a mismatch, so a client never receives a tampered artifact as
a complete response.
Passthrough is rejected together with scanning, direct_serve and
mirror_api, and the mirror command refuses to run with it, since all of
them depend on stored artifacts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Address review: abort truncated streams, guard every cache read
- serveArtifact aborts the response on any body read error, and when fewer
bytes than the declared size were written, not only on a digest mismatch.
A failed or short upstream body in passthrough mode no longer reaches the
client as a complete 200.
- Streamed upstream read failures are logged and counted as
stream_failed upstream errors before the response is aborted.
- checkCache reports a miss when artifact caching is off, so every cache
read path, including Swift archive HEAD requests, ignores entries stored
before the mode was enabled. Denylisted versions are still rejected.
- Rename storage.passthrough to storage.cache_artifacts (default true,
PROXY_STORAGE_CACHE_ARTIFACTS), which names what actually changes.
Tests cover an OCI blob shorter than its Content-Length and an npm tarball
whose chunked response ends without the final chunk, both served through
the HTTP handlers, plus a Swift HEAD request against a cached archive.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Rename Proxy.Passthrough to StreamArtifacts
Match the storage.cache_artifacts option: the field says what changes
(artifacts are streamed instead of stored) and its zero value keeps the
usual caching behaviour for every Proxy built without the server config,
such as the mirror command and tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 15:28:20 +07:00
|
|
|
// With StreamArtifacts set it always reports a miss, so entries stored before the
|
|
|
|
|
// mode was enabled are never served.
|
2026-10-13 15:16:55 +02:00
|
|
|
//
|
|
|
|
|
// A miss on a pushed artifact is an error rather than a miss: the caller would
|
|
|
|
|
// otherwise fetch the upstream copy and replace the pushed one with it.
|
2026-01-21 22:47:23 +00:00
|
|
|
func (p *Proxy) checkCache(ctx context.Context, pkgPURL, versionPURL, filename string) (*CacheResult, error) {
|
2026-10-13 15:16:55 +02:00
|
|
|
result, err := p.lookupCache(ctx, pkgPURL, versionPURL, filename)
|
|
|
|
|
if err != nil || result != nil || p.StreamArtifacts {
|
|
|
|
|
return result, err
|
|
|
|
|
}
|
|
|
|
|
return nil, p.localArtifactMiss(versionPURL, filename)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// localArtifactMiss returns ErrHostedArtifactUnavailable when the artifact
|
|
|
|
|
// was pushed and is stored but couldn't be served, ErrHostedArtifactMissing
|
|
|
|
|
// when it was pushed and its stored object is gone, and nil otherwise.
|
|
|
|
|
func (p *Proxy) localArtifactMiss(versionPURL, filename string) error {
|
|
|
|
|
record, err := p.DB.GetArtifact(versionPURL, filename)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("checking artifact source: %w", err)
|
|
|
|
|
}
|
|
|
|
|
if record == nil || record.Source != database.SourceLocal {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
if record.StoragePath.Valid {
|
|
|
|
|
return fmt.Errorf("%w: %s %s", ErrHostedArtifactUnavailable, versionPURL, filename)
|
|
|
|
|
}
|
|
|
|
|
return fmt.Errorf("%w: %s %s", ErrHostedArtifactMissing, versionPURL, filename)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// lookupCache looks up an artifact in the cache. Returns nil if not cached.
|
|
|
|
|
func (p *Proxy) lookupCache(ctx context.Context, pkgPURL, versionPURL, filename string) (*CacheResult, error) {
|
2026-09-20 14:42:30 +05:30
|
|
|
if p.Denylist.Denied(versionPURL) {
|
|
|
|
|
return nil, fmt.Errorf("%w: %s", ErrVersionDenied, versionPURL)
|
|
|
|
|
}
|
Add storage.cache_artifacts to serve artifacts without storing them (#387)
* Add storage.passthrough to serve artifacts without storing them
With storage.passthrough enabled the proxy streams every artifact from
upstream to the client and never writes it to storage or the cache
database. Metadata filtering, cooldown and the denylist work as before,
so the proxy can sit behind another cache (e.g. an Artifactory remote)
purely as a policy layer without holding a second copy of every package.
Artifacts whose digest is known up front (OCI, Swift, Helm) are verified
while streaming. Their responses are sent chunked and the connection is
aborted on a mismatch, so a client never receives a tampered artifact as
a complete response.
Passthrough is rejected together with scanning, direct_serve and
mirror_api, and the mirror command refuses to run with it, since all of
them depend on stored artifacts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Address review: abort truncated streams, guard every cache read
- serveArtifact aborts the response on any body read error, and when fewer
bytes than the declared size were written, not only on a digest mismatch.
A failed or short upstream body in passthrough mode no longer reaches the
client as a complete 200.
- Streamed upstream read failures are logged and counted as
stream_failed upstream errors before the response is aborted.
- checkCache reports a miss when artifact caching is off, so every cache
read path, including Swift archive HEAD requests, ignores entries stored
before the mode was enabled. Denylisted versions are still rejected.
- Rename storage.passthrough to storage.cache_artifacts (default true,
PROXY_STORAGE_CACHE_ARTIFACTS), which names what actually changes.
Tests cover an OCI blob shorter than its Content-Length and an npm tarball
whose chunked response ends without the final chunk, both served through
the HTTP handlers, plus a Swift HEAD request against a cached archive.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Rename Proxy.Passthrough to StreamArtifacts
Match the storage.cache_artifacts option: the field says what changes
(artifacts are streamed instead of stored) and its zero value keeps the
usual caching behaviour for every Proxy built without the server config,
such as the mirror command and tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 15:28:20 +07:00
|
|
|
if p.StreamArtifacts {
|
|
|
|
|
return nil, nil
|
|
|
|
|
}
|
2026-10-14 13:21:57 +02:00
|
|
|
return p.readStored(ctx, pkgPURL, versionPURL, filename)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// readStored opens the stored object of an artifact, whether or not
|
|
|
|
|
// StreamArtifacts is set. Returns nil if no usable object is stored.
|
|
|
|
|
func (p *Proxy) readStored(ctx context.Context, pkgPURL, versionPURL, filename string) (*CacheResult, error) {
|
2026-08-13 08:06:41 +01:00
|
|
|
artifact, err := p.DB.GetCachedArtifact(pkgPURL, versionPURL, filename)
|
2026-01-21 22:47:23 +00:00
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("checking artifact cache: %w", err)
|
|
|
|
|
}
|
2026-08-13 08:06:41 +01:00
|
|
|
if artifact == nil {
|
2026-01-21 22:47:23 +00:00
|
|
|
return nil, nil
|
|
|
|
|
}
|
2026-09-04 10:58:22 +01:00
|
|
|
checks, err := newIntegrityChecks(artifact.Artifact.Digest.Encoded(), artifact.Integrity.String)
|
2026-08-17 09:20:11 +01:00
|
|
|
if err != nil {
|
|
|
|
|
p.rejectUnusableCacheRecord(artifact, versionPURL, filename, err)
|
|
|
|
|
return nil, nil
|
|
|
|
|
}
|
2026-01-21 22:47:23 +00:00
|
|
|
|
2026-04-27 12:04:38 +01:00
|
|
|
result := &CacheResult{
|
2026-09-04 10:58:22 +01:00
|
|
|
Artifact: artifact.Artifact,
|
2026-04-27 12:04:38 +01:00
|
|
|
Cached: true,
|
2026-09-02 13:06:34 +01:00
|
|
|
storagePath: artifact.StoragePath,
|
2026-04-27 12:04:38 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if p.DirectServe {
|
2026-08-13 08:06:41 +01:00
|
|
|
signed, err := p.Storage.SignedURL(ctx, artifact.StoragePath, p.DirectServeTTL)
|
2026-04-27 12:04:38 +01:00
|
|
|
if err == nil {
|
2026-04-27 12:14:37 +01:00
|
|
|
result.RedirectURL = rewriteSignedURLHost(signed, p.DirectServeBaseURL)
|
2026-08-13 08:06:41 +01:00
|
|
|
p.recordCacheHit(artifact.Ecosystem, versionPURL, filename)
|
2026-04-27 12:04:38 +01:00
|
|
|
return result, nil
|
|
|
|
|
}
|
|
|
|
|
if !errors.Is(err, storage.ErrSignedURLUnsupported) {
|
|
|
|
|
p.Logger.Warn("failed to sign storage URL, falling back to streaming",
|
2026-08-13 08:06:41 +01:00
|
|
|
"path", artifact.StoragePath, "error", err)
|
2026-04-27 12:04:38 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-03 22:40:23 +00:00
|
|
|
start := time.Now()
|
2026-08-13 08:06:41 +01:00
|
|
|
reader, err := p.Storage.Open(ctx, artifact.StoragePath)
|
2026-02-03 22:40:23 +00:00
|
|
|
metrics.RecordStorageOperation("read", time.Since(start))
|
2026-01-21 22:47:23 +00:00
|
|
|
if err != nil {
|
Record the detail behind each failure counter
- Failure recorders take a FailureDetail: PURL, filename, URL, storage path, error
- Keep the last 20 failures per counter series in memory, reset on restart
- Strip userinfo, query and fragment from URLs, including those inside error text
- Pass the detail from upstream, storage, integrity, health probe and scan call sites
- Package names and URLs stay out of Prometheus labels
2026-10-12 09:41:42 +02:00
|
|
|
metrics.RecordStorageError("read", metrics.FailureDetail{
|
|
|
|
|
PURL: versionPURL, Filename: filename, Path: artifact.StoragePath, Err: err})
|
2026-01-21 22:47:23 +00:00
|
|
|
p.Logger.Warn("cached artifact missing from storage, will refetch",
|
2026-08-13 08:06:41 +01:00
|
|
|
"path", artifact.StoragePath, "error", err)
|
2026-01-21 22:47:23 +00:00
|
|
|
return nil, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-17 09:20:11 +01:00
|
|
|
result.Reader, err = checks.wrap(reader,
|
2026-05-03 10:36:28 +01:00
|
|
|
func(reason string) {
|
|
|
|
|
p.Logger.Error("cached artifact failed integrity check",
|
|
|
|
|
"purl", versionPURL, "filename", filename,
|
2026-08-13 08:06:41 +01:00
|
|
|
"path", artifact.StoragePath, "reason", reason)
|
Record the detail behind each failure counter
- Failure recorders take a FailureDetail: PURL, filename, URL, storage path, error
- Keep the last 20 failures per counter series in memory, reset on restart
- Strip userinfo, query and fragment from URLs, including those inside error text
- Pass the detail from upstream, storage, integrity, health probe and scan call sites
- Package names and URLs stay out of Prometheus labels
2026-10-12 09:41:42 +02:00
|
|
|
metrics.RecordIntegrityFailure(purl.NormalizeEcosystem(artifact.Ecosystem), metrics.FailureDetail{
|
|
|
|
|
PURL: versionPURL, Filename: filename, Path: artifact.StoragePath, Err: errors.New(reason)})
|
Give each fetch its own storage path
Every fetch of an artifact wrote one path, so fetches with different URLs
or digests, which do not share a coalescing key, could overwrite each
other's bytes, serve the wrong ones, or delete them on a digest mismatch
while the other was about to open them.
storeArtifact now writes {ecosystem}/{name}/{version}/{fetch id}/{filename},
with a random id per fetch. Most ecosystems learn the digest only after
the fetch, and storage has no rename, so the id is the one rule that fits
all of them. Existing records keep their paths and stay readable. On a
file:// bucket, Delete now removes an emptied fetch directory, which
fileblob leaves behind, and no other directory, since another fetch may
be creating its own inside it.
A path a record stops pointing at is queued in pending_deletes rather
than deleted, since a request that read the record may still open it. A
loop deletes queued paths after max(1h, direct_serve_ttl), whether or not
max_size is set. UpsertArtifact and clears apply only if the record still
holds the path the caller read, so racing commits each queue the path
they replaced and a clear never orphans a newer commit. Eviction still
deletes inline, and counts space as freed only when the record still
pointed at what it deleted.
2026-09-23 14:40:13 -07:00
|
|
|
if err := p.DB.DiscardArtifact(versionPURL, filename, artifact.StoragePath); err != nil {
|
2026-05-03 10:36:28 +01:00
|
|
|
p.Logger.Warn("failed to clear corrupt artifact from cache", "error", err)
|
|
|
|
|
}
|
|
|
|
|
})
|
2026-08-17 09:20:11 +01:00
|
|
|
if err != nil {
|
|
|
|
|
_ = reader.Close()
|
|
|
|
|
p.rejectUnusableCacheRecord(artifact, versionPURL, filename, err)
|
|
|
|
|
return nil, nil
|
|
|
|
|
}
|
2026-08-13 08:06:41 +01:00
|
|
|
p.recordCacheHit(artifact.Ecosystem, versionPURL, filename)
|
2026-04-27 12:04:38 +01:00
|
|
|
return result, nil
|
|
|
|
|
}
|
2026-02-03 22:40:23 +00:00
|
|
|
|
2026-04-27 12:14:37 +01:00
|
|
|
// rewriteSignedURLHost replaces the scheme and host of a signed URL with those
|
|
|
|
|
// from baseURL, preserving the path and query (which carry the signature).
|
|
|
|
|
// Returns signed unchanged if baseURL is empty or either URL fails to parse.
|
|
|
|
|
func rewriteSignedURLHost(signed, baseURL string) string {
|
|
|
|
|
if baseURL == "" {
|
|
|
|
|
return signed
|
|
|
|
|
}
|
|
|
|
|
s, err := url.Parse(signed)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return signed
|
|
|
|
|
}
|
|
|
|
|
b, err := url.Parse(baseURL)
|
|
|
|
|
if err != nil || b.Scheme == "" || b.Host == "" {
|
|
|
|
|
return signed
|
|
|
|
|
}
|
|
|
|
|
s.Scheme = b.Scheme
|
|
|
|
|
s.Host = b.Host
|
|
|
|
|
return s.String()
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-13 08:06:41 +01:00
|
|
|
func (p *Proxy) recordCacheHit(ecosystem, versionPURL, filename string) {
|
2026-04-27 12:04:38 +01:00
|
|
|
_ = p.DB.RecordArtifactHit(versionPURL, filename)
|
2026-08-16 18:01:59 +01:00
|
|
|
metrics.RecordCacheHit(ecosystem)
|
2026-01-20 21:52:44 +00:00
|
|
|
}
|
|
|
|
|
|
2026-08-17 09:20:11 +01:00
|
|
|
func (p *Proxy) rejectUnusableCacheRecord(artifact *database.CachedArtifact, versionPURL, filename string, cause error) {
|
|
|
|
|
p.Logger.Warn("cached artifact has unusable integrity metadata",
|
|
|
|
|
"purl", versionPURL, "filename", filename,
|
|
|
|
|
"path", artifact.StoragePath, "error", cause)
|
Record the detail behind each failure counter
- Failure recorders take a FailureDetail: PURL, filename, URL, storage path, error
- Keep the last 20 failures per counter series in memory, reset on restart
- Strip userinfo, query and fragment from URLs, including those inside error text
- Pass the detail from upstream, storage, integrity, health probe and scan call sites
- Package names and URLs stay out of Prometheus labels
2026-10-12 09:41:42 +02:00
|
|
|
metrics.RecordIntegrityFailure(purl.NormalizeEcosystem(artifact.Ecosystem), metrics.FailureDetail{
|
|
|
|
|
PURL: versionPURL, Filename: filename, Path: artifact.StoragePath, Err: cause})
|
Give each fetch its own storage path
Every fetch of an artifact wrote one path, so fetches with different URLs
or digests, which do not share a coalescing key, could overwrite each
other's bytes, serve the wrong ones, or delete them on a digest mismatch
while the other was about to open them.
storeArtifact now writes {ecosystem}/{name}/{version}/{fetch id}/{filename},
with a random id per fetch. Most ecosystems learn the digest only after
the fetch, and storage has no rename, so the id is the one rule that fits
all of them. Existing records keep their paths and stay readable. On a
file:// bucket, Delete now removes an emptied fetch directory, which
fileblob leaves behind, and no other directory, since another fetch may
be creating its own inside it.
A path a record stops pointing at is queued in pending_deletes rather
than deleted, since a request that read the record may still open it. A
loop deletes queued paths after max(1h, direct_serve_ttl), whether or not
max_size is set. UpsertArtifact and clears apply only if the record still
holds the path the caller read, so racing commits each queue the path
they replaced and a clear never orphans a newer commit. Eviction still
deletes inline, and counts space as freed only when the record still
pointed at what it deleted.
2026-09-23 14:40:13 -07:00
|
|
|
if err := p.DB.DiscardArtifact(versionPURL, filename, artifact.StoragePath); err != nil {
|
2026-08-17 09:20:11 +01:00
|
|
|
p.Logger.Warn("failed to clear unusable artifact from cache", "error", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-23 17:51:49 +05:30
|
|
|
func (p *Proxy) resolveArtifact(ctx context.Context, ecosystem, name, version string) (*fetch.ArtifactInfo, error) {
|
2026-01-20 21:52:44 +00:00
|
|
|
info, err := p.Resolver.Resolve(ctx, ecosystem, name, version)
|
|
|
|
|
if err != nil {
|
2026-07-26 20:07:46 +02:00
|
|
|
if errors.Is(err, fetch.ErrNotFound) {
|
2026-09-23 17:51:49 +05:30
|
|
|
return nil, ErrUpstreamNotFound
|
2026-07-26 20:07:46 +02:00
|
|
|
}
|
2026-09-23 17:51:49 +05:30
|
|
|
return nil, fmt.Errorf("resolving download URL: %w", err)
|
2026-01-20 21:52:44 +00:00
|
|
|
}
|
2026-09-23 17:51:49 +05:30
|
|
|
return info, nil
|
|
|
|
|
}
|
2026-01-20 21:52:44 +00:00
|
|
|
|
2026-09-23 17:51:49 +05:30
|
|
|
func (p *Proxy) fetchAndCache(ctx context.Context, ecosystem, name, version, filename, pkgPURL, versionPURL string, info *fetch.ArtifactInfo) (artifacts.Artifact, string, error) {
|
|
|
|
|
// Reuse the resolution used for an unnamed cache lookup. Named requests
|
|
|
|
|
// still resolve only on a cache miss, inside the coalesced fetch.
|
|
|
|
|
if info == nil {
|
|
|
|
|
var err error
|
|
|
|
|
info, err = p.resolveArtifact(ctx, ecosystem, name, version)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return artifacts.Artifact{}, "", err
|
|
|
|
|
}
|
2026-01-20 21:52:44 +00:00
|
|
|
}
|
|
|
|
|
|
2026-10-15 13:35:12 +01:00
|
|
|
if err := p.RateLimits.Held(info.URL); err != nil {
|
|
|
|
|
return artifacts.Artifact{}, "", err
|
|
|
|
|
}
|
2026-01-20 21:52:44 +00:00
|
|
|
p.Logger.Info("fetching from upstream",
|
|
|
|
|
"ecosystem", ecosystem, "name", name, "version", version, "url", info.URL)
|
|
|
|
|
|
2026-02-03 22:40:23 +00:00
|
|
|
// Fetch from upstream with timing
|
|
|
|
|
fetchStart := time.Now()
|
2026-01-20 21:52:44 +00:00
|
|
|
artifact, err := p.Fetcher.Fetch(ctx, info.URL)
|
2026-02-03 22:40:23 +00:00
|
|
|
fetchDuration := time.Since(fetchStart)
|
|
|
|
|
|
2026-01-20 21:52:44 +00:00
|
|
|
if err != nil {
|
2026-02-03 22:40:23 +00:00
|
|
|
metrics.RecordUpstreamFetch(ecosystem, fetchDuration)
|
Record the detail behind each failure counter
- Failure recorders take a FailureDetail: PURL, filename, URL, storage path, error
- Keep the last 20 failures per counter series in memory, reset on restart
- Strip userinfo, query and fragment from URLs, including those inside error text
- Pass the detail from upstream, storage, integrity, health probe and scan call sites
- Package names and URLs stay out of Prometheus labels
2026-10-12 09:41:42 +02:00
|
|
|
metrics.RecordUpstreamError(ecosystem, "fetch_failed", metrics.FailureDetail{
|
|
|
|
|
PURL: versionPURL, Filename: filename, URL: info.URL, Err: err})
|
2026-07-26 20:07:46 +02:00
|
|
|
if errors.Is(err, fetch.ErrNotFound) {
|
Fix duplicate fetches and 502s on concurrent cache misses (#329)
* Return the stored artifact from storeArtifact, not a reader
storeArtifact returned a CacheResult holding an open file handle. A
handle has one read position, so it can only ever serve a single caller,
which is what blocks sharing one fetch between concurrent requests.
Return the artifact and its storage path instead, and let each caller
open its own reader through openStoredArtifact. Threading that type
through fetchAndCache, fetchAndCacheFromURL and their error paths is
mechanical; behaviour is unchanged.
* Coalesce concurrent cache misses
A cache miss went from checkCache straight to an upstream fetch with
nothing tracking in-flight work, so N concurrent requests for one
uncached artifact produced N upstream fetches and N stores to the same
key. That is the CI shape: parallel jobs installing overlapping
dependencies against a cold cache. The duplicate stores also fail
requests, racing fileblob's per-key ".attrs" sidecar into a partial read
served as a 502. Over 12 runs of 8 simultaneous requests for one
uncached tarball, against bb2205a: before, 8 fetches per run and 12 of
96 responses were 502; after, 1 fetch per run and none failed.
Route both miss paths through a shared in-flight map keyed on the
artifact, including the download URL and upstream-declared hash so
callers expecting different bytes never share a fetch.
singleflight does not fit: Do gives waiters no way to leave, while
DoChan lets the caller running the fetch abandon it, breaking
storeArtifact's scan-on-disconnect contract. Deciding roles under a
mutex gives both behaviours. The fetch runs on the first caller's
context and is seen through; waiters leave when their own clients do.
This removes the sidecar trigger on this path. The race is in fileblob
and three writers bypass this path entirely, so it is fixed separately.
Fewer failures now reach the circuit breaker, so it trips later.
Sixteen concurrent callers against real file:// storage fail 10 of 10
runs on main and pass 10 of 10 here. Other tests pin key discrimination,
failure propagation, resolver-path coalescing, per-caller readers,
waiter cancellation, key release and panic safety. allocs/op is
unchanged. mockStorage gains a mutex so concurrent tests can use it.
* Normalize digest case in the coalescing key
artifactHashMatches compares digests with strings.EqualFold, but the
coalescing key used the hash verbatim. The same digest in two casings
produced two keys, so two callers for one artifact each ran their own
upstream fetch and store, which is what the coalescing is meant to
prevent.
* Make the panic coalescing test deterministic
The test timed the second caller's arrival with a sleep, so which caller
became the leader was left to the scheduler. When it lost that race the
second caller ran the fetch itself, and its panic was not recovered, so
the test binary died instead of the test failing.
Whether a caller has reached the wait is not observable from outside:
it runs a cache lookup against the database first, so releasing the
leader on a timer races that query. Drive coalesceFetch directly and
hold the shared entry instead, which removes the timing entirely. The
panicking fetcher is no longer needed.
* Recheck the cache before running a shared fetch
A caller checks the cache before it reaches coalesceFetch, so a fetch
that commits in that gap is invisible to it. Arriving after the sharing
entry is gone, it became a new leader and fetched, stored and scanned an
artifact the cache already held.
The leader now rechecks the committed record first. It serves that
record only if its bytes still open, because a record can outlive them,
and refetching is the recovery the cache lookup already makes for that
case. Waiters are unaffected: the record fills the same shared value a
fetch would, and each caller opens its own reader from it.
The recheck is the leader's alone. A waiter has a fetch in flight to
wait on, and rechecking would race it for no gain.
* Lock the mock fetcher's bookkeeping
Coalescing tests call the handler from many goroutines. The key keeps
the fetch itself serialized, but the mock should not lean on that: it
now locks the fields it records, so any concurrency the handler applies
is safe under the race detector.
* Wait for the leader's fetch instead of sleeping
The canceled-waiter test slept 200ms and assumed the leader had taken
the key by then. On a slow scheduler the canceled call could become the
leader and the test would no longer cover waiter cancellation. The
fetcher now signals when its first fetch begins, which happens only once
the key is held.
2026-09-15 11:59:03 -07:00
|
|
|
return artifacts.Artifact{}, "", ErrUpstreamNotFound
|
2026-07-26 20:07:46 +02:00
|
|
|
}
|
Fix duplicate fetches and 502s on concurrent cache misses (#329)
* Return the stored artifact from storeArtifact, not a reader
storeArtifact returned a CacheResult holding an open file handle. A
handle has one read position, so it can only ever serve a single caller,
which is what blocks sharing one fetch between concurrent requests.
Return the artifact and its storage path instead, and let each caller
open its own reader through openStoredArtifact. Threading that type
through fetchAndCache, fetchAndCacheFromURL and their error paths is
mechanical; behaviour is unchanged.
* Coalesce concurrent cache misses
A cache miss went from checkCache straight to an upstream fetch with
nothing tracking in-flight work, so N concurrent requests for one
uncached artifact produced N upstream fetches and N stores to the same
key. That is the CI shape: parallel jobs installing overlapping
dependencies against a cold cache. The duplicate stores also fail
requests, racing fileblob's per-key ".attrs" sidecar into a partial read
served as a 502. Over 12 runs of 8 simultaneous requests for one
uncached tarball, against bb2205a: before, 8 fetches per run and 12 of
96 responses were 502; after, 1 fetch per run and none failed.
Route both miss paths through a shared in-flight map keyed on the
artifact, including the download URL and upstream-declared hash so
callers expecting different bytes never share a fetch.
singleflight does not fit: Do gives waiters no way to leave, while
DoChan lets the caller running the fetch abandon it, breaking
storeArtifact's scan-on-disconnect contract. Deciding roles under a
mutex gives both behaviours. The fetch runs on the first caller's
context and is seen through; waiters leave when their own clients do.
This removes the sidecar trigger on this path. The race is in fileblob
and three writers bypass this path entirely, so it is fixed separately.
Fewer failures now reach the circuit breaker, so it trips later.
Sixteen concurrent callers against real file:// storage fail 10 of 10
runs on main and pass 10 of 10 here. Other tests pin key discrimination,
failure propagation, resolver-path coalescing, per-caller readers,
waiter cancellation, key release and panic safety. allocs/op is
unchanged. mockStorage gains a mutex so concurrent tests can use it.
* Normalize digest case in the coalescing key
artifactHashMatches compares digests with strings.EqualFold, but the
coalescing key used the hash verbatim. The same digest in two casings
produced two keys, so two callers for one artifact each ran their own
upstream fetch and store, which is what the coalescing is meant to
prevent.
* Make the panic coalescing test deterministic
The test timed the second caller's arrival with a sleep, so which caller
became the leader was left to the scheduler. When it lost that race the
second caller ran the fetch itself, and its panic was not recovered, so
the test binary died instead of the test failing.
Whether a caller has reached the wait is not observable from outside:
it runs a cache lookup against the database first, so releasing the
leader on a timer races that query. Drive coalesceFetch directly and
hold the shared entry instead, which removes the timing entirely. The
panicking fetcher is no longer needed.
* Recheck the cache before running a shared fetch
A caller checks the cache before it reaches coalesceFetch, so a fetch
that commits in that gap is invisible to it. Arriving after the sharing
entry is gone, it became a new leader and fetched, stored and scanned an
artifact the cache already held.
The leader now rechecks the committed record first. It serves that
record only if its bytes still open, because a record can outlive them,
and refetching is the recovery the cache lookup already makes for that
case. Waiters are unaffected: the record fills the same shared value a
fetch would, and each caller opens its own reader from it.
The recheck is the leader's alone. A waiter has a fetch in flight to
wait on, and rechecking would race it for no gain.
* Lock the mock fetcher's bookkeeping
Coalescing tests call the handler from many goroutines. The key keeps
the fetch itself serialized, but the mock should not lean on that: it
now locks the fields it records, so any concurrency the handler applies
is safe under the race detector.
* Wait for the leader's fetch instead of sleeping
The canceled-waiter test slept 200ms and assumed the leader had taken
the key by then. On a slow scheduler the canceled call could become the
leader and the test would no longer cover waiter cancellation. The
fetcher now signals when its first fetch begins, which happens only once
the key is held.
2026-09-15 11:59:03 -07:00
|
|
|
return artifacts.Artifact{}, "", fmt.Errorf("fetching from upstream: %w", err)
|
2026-01-20 21:52:44 +00:00
|
|
|
}
|
2026-02-03 22:40:23 +00:00
|
|
|
metrics.RecordUpstreamFetch(ecosystem, fetchDuration)
|
2026-01-20 21:52:44 +00:00
|
|
|
|
2026-10-14 15:36:26 +02:00
|
|
|
return p.storeArtifact(ctx, ecosystem, name, version, filename, pkgPURL, versionPURL, info.URL, "", "", artifact)
|
2026-09-03 05:06:44 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// storeArtifact writes a fetched artifact to storage, verifies it against
|
2026-10-14 15:36:26 +02:00
|
|
|
// upstreamHash and declared, the integrity the registry's metadata declares,
|
|
|
|
|
// if they are non-empty, runs it through the scan gate if scanning is enabled,
|
|
|
|
|
// and commits it to the cache database, recording declared with the version
|
|
|
|
|
// so cache hits are verified against it too.
|
2026-09-03 05:06:44 -04:00
|
|
|
//
|
|
|
|
|
// The scan gate sits between Storage.Store and updateCacheDB: updateCacheDB
|
|
|
|
|
// is the only thing that makes an artifact visible to clients (checkCache
|
|
|
|
|
// looks up its row before touching Storage), so deferring it until after a
|
|
|
|
|
// verdict means a blocked artifact was never reachable by any client. On
|
|
|
|
|
// block, the just-stored bytes are deleted and ErrArtifactBlocked is
|
|
|
|
|
// returned; updateCacheDB is never called.
|
Fix duplicate fetches and 502s on concurrent cache misses (#329)
* Return the stored artifact from storeArtifact, not a reader
storeArtifact returned a CacheResult holding an open file handle. A
handle has one read position, so it can only ever serve a single caller,
which is what blocks sharing one fetch between concurrent requests.
Return the artifact and its storage path instead, and let each caller
open its own reader through openStoredArtifact. Threading that type
through fetchAndCache, fetchAndCacheFromURL and their error paths is
mechanical; behaviour is unchanged.
* Coalesce concurrent cache misses
A cache miss went from checkCache straight to an upstream fetch with
nothing tracking in-flight work, so N concurrent requests for one
uncached artifact produced N upstream fetches and N stores to the same
key. That is the CI shape: parallel jobs installing overlapping
dependencies against a cold cache. The duplicate stores also fail
requests, racing fileblob's per-key ".attrs" sidecar into a partial read
served as a 502. Over 12 runs of 8 simultaneous requests for one
uncached tarball, against bb2205a: before, 8 fetches per run and 12 of
96 responses were 502; after, 1 fetch per run and none failed.
Route both miss paths through a shared in-flight map keyed on the
artifact, including the download URL and upstream-declared hash so
callers expecting different bytes never share a fetch.
singleflight does not fit: Do gives waiters no way to leave, while
DoChan lets the caller running the fetch abandon it, breaking
storeArtifact's scan-on-disconnect contract. Deciding roles under a
mutex gives both behaviours. The fetch runs on the first caller's
context and is seen through; waiters leave when their own clients do.
This removes the sidecar trigger on this path. The race is in fileblob
and three writers bypass this path entirely, so it is fixed separately.
Fewer failures now reach the circuit breaker, so it trips later.
Sixteen concurrent callers against real file:// storage fail 10 of 10
runs on main and pass 10 of 10 here. Other tests pin key discrimination,
failure propagation, resolver-path coalescing, per-caller readers,
waiter cancellation, key release and panic safety. allocs/op is
unchanged. mockStorage gains a mutex so concurrent tests can use it.
* Normalize digest case in the coalescing key
artifactHashMatches compares digests with strings.EqualFold, but the
coalescing key used the hash verbatim. The same digest in two casings
produced two keys, so two callers for one artifact each ran their own
upstream fetch and store, which is what the coalescing is meant to
prevent.
* Make the panic coalescing test deterministic
The test timed the second caller's arrival with a sleep, so which caller
became the leader was left to the scheduler. When it lost that race the
second caller ran the fetch itself, and its panic was not recovered, so
the test binary died instead of the test failing.
Whether a caller has reached the wait is not observable from outside:
it runs a cache lookup against the database first, so releasing the
leader on a timer races that query. Drive coalesceFetch directly and
hold the shared entry instead, which removes the timing entirely. The
panicking fetcher is no longer needed.
* Recheck the cache before running a shared fetch
A caller checks the cache before it reaches coalesceFetch, so a fetch
that commits in that gap is invisible to it. Arriving after the sharing
entry is gone, it became a new leader and fetched, stored and scanned an
artifact the cache already held.
The leader now rechecks the committed record first. It serves that
record only if its bytes still open, because a record can outlive them,
and refetching is the recovery the cache lookup already makes for that
case. Waiters are unaffected: the record fills the same shared value a
fetch would, and each caller opens its own reader from it.
The recheck is the leader's alone. A waiter has a fetch in flight to
wait on, and rechecking would race it for no gain.
* Lock the mock fetcher's bookkeeping
Coalescing tests call the handler from many goroutines. The key keeps
the fetch itself serialized, but the mock should not lean on that: it
now locks the fields it records, so any concurrency the handler applies
is safe under the race detector.
* Wait for the leader's fetch instead of sleeping
The canceled-waiter test slept 200ms and assumed the leader had taken
the key by then. On a slow scheduler the canceled call could become the
leader and the test would no longer cover waiter cancellation. The
fetcher now signals when its first fetch begins, which happens only once
the key is held.
2026-09-15 11:59:03 -07:00
|
|
|
//
|
|
|
|
|
// It returns the artifact and its storage path, not a reader; callers get one
|
|
|
|
|
// from openStoredArtifact.
|
2026-10-14 15:36:26 +02:00
|
|
|
func (p *Proxy) storeArtifact(ctx context.Context, ecosystem, name, version, filename, pkgPURL, versionPURL, upstreamURL, upstreamHash, declared string, artifact *fetch.Artifact) (artifacts.Artifact, string, error) {
|
Give each fetch its own storage path
Every fetch of an artifact wrote one path, so fetches with different URLs
or digests, which do not share a coalescing key, could overwrite each
other's bytes, serve the wrong ones, or delete them on a digest mismatch
while the other was about to open them.
storeArtifact now writes {ecosystem}/{name}/{version}/{fetch id}/{filename},
with a random id per fetch. Most ecosystems learn the digest only after
the fetch, and storage has no rename, so the id is the one rule that fits
all of them. Existing records keep their paths and stay readable. On a
file:// bucket, Delete now removes an emptied fetch directory, which
fileblob leaves behind, and no other directory, since another fetch may
be creating its own inside it.
A path a record stops pointing at is queued in pending_deletes rather
than deleted, since a request that read the record may still open it. A
loop deletes queued paths after max(1h, direct_serve_ttl), whether or not
max_size is set. UpsertArtifact and clears apply only if the record still
holds the path the caller read, so racing commits each queue the path
they replaced and a clear never orphans a newer commit. Eviction still
deletes inline, and counts space as freed only when the record still
pointed at what it deleted.
2026-09-23 14:40:13 -07:00
|
|
|
storagePath := storage.FetchPath(ecosystem, name, version, storage.NewFetchID(), filename)
|
2026-09-03 05:06:44 -04:00
|
|
|
|
2026-10-14 15:36:26 +02:00
|
|
|
body, verified, err := verifyIntegrity(artifact.Body, declared)
|
|
|
|
|
if err != nil {
|
|
|
|
|
_ = artifact.Body.Close()
|
|
|
|
|
return artifacts.Artifact{}, "", err
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-03 22:40:23 +00:00
|
|
|
storeStart := time.Now()
|
2026-10-14 15:36:26 +02:00
|
|
|
size, hash, err := p.Storage.Store(ctx, storagePath, body)
|
2026-01-20 21:52:44 +00:00
|
|
|
_ = artifact.Body.Close()
|
2026-02-03 22:40:23 +00:00
|
|
|
metrics.RecordStorageOperation("write", time.Since(storeStart))
|
2026-01-20 21:52:44 +00:00
|
|
|
if err != nil {
|
Record the detail behind each failure counter
- Failure recorders take a FailureDetail: PURL, filename, URL, storage path, error
- Keep the last 20 failures per counter series in memory, reset on restart
- Strip userinfo, query and fragment from URLs, including those inside error text
- Pass the detail from upstream, storage, integrity, health probe and scan call sites
- Package names and URLs stay out of Prometheus labels
2026-10-12 09:41:42 +02:00
|
|
|
metrics.RecordStorageError("write", metrics.FailureDetail{
|
|
|
|
|
PURL: versionPURL, Filename: filename, URL: upstreamURL, Path: storagePath, Err: err})
|
Fix duplicate fetches and 502s on concurrent cache misses (#329)
* Return the stored artifact from storeArtifact, not a reader
storeArtifact returned a CacheResult holding an open file handle. A
handle has one read position, so it can only ever serve a single caller,
which is what blocks sharing one fetch between concurrent requests.
Return the artifact and its storage path instead, and let each caller
open its own reader through openStoredArtifact. Threading that type
through fetchAndCache, fetchAndCacheFromURL and their error paths is
mechanical; behaviour is unchanged.
* Coalesce concurrent cache misses
A cache miss went from checkCache straight to an upstream fetch with
nothing tracking in-flight work, so N concurrent requests for one
uncached artifact produced N upstream fetches and N stores to the same
key. That is the CI shape: parallel jobs installing overlapping
dependencies against a cold cache. The duplicate stores also fail
requests, racing fileblob's per-key ".attrs" sidecar into a partial read
served as a 502. Over 12 runs of 8 simultaneous requests for one
uncached tarball, against bb2205a: before, 8 fetches per run and 12 of
96 responses were 502; after, 1 fetch per run and none failed.
Route both miss paths through a shared in-flight map keyed on the
artifact, including the download URL and upstream-declared hash so
callers expecting different bytes never share a fetch.
singleflight does not fit: Do gives waiters no way to leave, while
DoChan lets the caller running the fetch abandon it, breaking
storeArtifact's scan-on-disconnect contract. Deciding roles under a
mutex gives both behaviours. The fetch runs on the first caller's
context and is seen through; waiters leave when their own clients do.
This removes the sidecar trigger on this path. The race is in fileblob
and three writers bypass this path entirely, so it is fixed separately.
Fewer failures now reach the circuit breaker, so it trips later.
Sixteen concurrent callers against real file:// storage fail 10 of 10
runs on main and pass 10 of 10 here. Other tests pin key discrimination,
failure propagation, resolver-path coalescing, per-caller readers,
waiter cancellation, key release and panic safety. allocs/op is
unchanged. mockStorage gains a mutex so concurrent tests can use it.
* Normalize digest case in the coalescing key
artifactHashMatches compares digests with strings.EqualFold, but the
coalescing key used the hash verbatim. The same digest in two casings
produced two keys, so two callers for one artifact each ran their own
upstream fetch and store, which is what the coalescing is meant to
prevent.
* Make the panic coalescing test deterministic
The test timed the second caller's arrival with a sleep, so which caller
became the leader was left to the scheduler. When it lost that race the
second caller ran the fetch itself, and its panic was not recovered, so
the test binary died instead of the test failing.
Whether a caller has reached the wait is not observable from outside:
it runs a cache lookup against the database first, so releasing the
leader on a timer races that query. Drive coalesceFetch directly and
hold the shared entry instead, which removes the timing entirely. The
panicking fetcher is no longer needed.
* Recheck the cache before running a shared fetch
A caller checks the cache before it reaches coalesceFetch, so a fetch
that commits in that gap is invisible to it. Arriving after the sharing
entry is gone, it became a new leader and fetched, stored and scanned an
artifact the cache already held.
The leader now rechecks the committed record first. It serves that
record only if its bytes still open, because a record can outlive them,
and refetching is the recovery the cache lookup already makes for that
case. Waiters are unaffected: the record fills the same shared value a
fetch would, and each caller opens its own reader from it.
The recheck is the leader's alone. A waiter has a fetch in flight to
wait on, and rechecking would race it for no gain.
* Lock the mock fetcher's bookkeeping
Coalescing tests call the handler from many goroutines. The key keeps
the fetch itself serialized, but the mock should not lean on that: it
now locks the fields it records, so any concurrency the handler applies
is safe under the race detector.
* Wait for the leader's fetch instead of sleeping
The canceled-waiter test slept 200ms and assumed the leader had taken
the key by then. On a slow scheduler the canceled call could become the
leader and the test would no longer cover waiter cancellation. The
fetcher now signals when its first fetch begins, which happens only once
the key is held.
2026-09-15 11:59:03 -07:00
|
|
|
return artifacts.Artifact{}, "", fmt.Errorf("storing artifact: %w", err)
|
2026-01-20 21:52:44 +00:00
|
|
|
}
|
|
|
|
|
|
2026-09-03 05:06:44 -04:00
|
|
|
if !artifactHashMatches(hash, upstreamHash) {
|
|
|
|
|
if delErr := p.Storage.Delete(ctx, storagePath); delErr != nil {
|
|
|
|
|
p.Logger.Warn("failed to discard artifact with mismatched checksum", "path", storagePath, "error", delErr)
|
|
|
|
|
}
|
Fix duplicate fetches and 502s on concurrent cache misses (#329)
* Return the stored artifact from storeArtifact, not a reader
storeArtifact returned a CacheResult holding an open file handle. A
handle has one read position, so it can only ever serve a single caller,
which is what blocks sharing one fetch between concurrent requests.
Return the artifact and its storage path instead, and let each caller
open its own reader through openStoredArtifact. Threading that type
through fetchAndCache, fetchAndCacheFromURL and their error paths is
mechanical; behaviour is unchanged.
* Coalesce concurrent cache misses
A cache miss went from checkCache straight to an upstream fetch with
nothing tracking in-flight work, so N concurrent requests for one
uncached artifact produced N upstream fetches and N stores to the same
key. That is the CI shape: parallel jobs installing overlapping
dependencies against a cold cache. The duplicate stores also fail
requests, racing fileblob's per-key ".attrs" sidecar into a partial read
served as a 502. Over 12 runs of 8 simultaneous requests for one
uncached tarball, against bb2205a: before, 8 fetches per run and 12 of
96 responses were 502; after, 1 fetch per run and none failed.
Route both miss paths through a shared in-flight map keyed on the
artifact, including the download URL and upstream-declared hash so
callers expecting different bytes never share a fetch.
singleflight does not fit: Do gives waiters no way to leave, while
DoChan lets the caller running the fetch abandon it, breaking
storeArtifact's scan-on-disconnect contract. Deciding roles under a
mutex gives both behaviours. The fetch runs on the first caller's
context and is seen through; waiters leave when their own clients do.
This removes the sidecar trigger on this path. The race is in fileblob
and three writers bypass this path entirely, so it is fixed separately.
Fewer failures now reach the circuit breaker, so it trips later.
Sixteen concurrent callers against real file:// storage fail 10 of 10
runs on main and pass 10 of 10 here. Other tests pin key discrimination,
failure propagation, resolver-path coalescing, per-caller readers,
waiter cancellation, key release and panic safety. allocs/op is
unchanged. mockStorage gains a mutex so concurrent tests can use it.
* Normalize digest case in the coalescing key
artifactHashMatches compares digests with strings.EqualFold, but the
coalescing key used the hash verbatim. The same digest in two casings
produced two keys, so two callers for one artifact each ran their own
upstream fetch and store, which is what the coalescing is meant to
prevent.
* Make the panic coalescing test deterministic
The test timed the second caller's arrival with a sleep, so which caller
became the leader was left to the scheduler. When it lost that race the
second caller ran the fetch itself, and its panic was not recovered, so
the test binary died instead of the test failing.
Whether a caller has reached the wait is not observable from outside:
it runs a cache lookup against the database first, so releasing the
leader on a timer races that query. Drive coalesceFetch directly and
hold the shared entry instead, which removes the timing entirely. The
panicking fetcher is no longer needed.
* Recheck the cache before running a shared fetch
A caller checks the cache before it reaches coalesceFetch, so a fetch
that commits in that gap is invisible to it. Arriving after the sharing
entry is gone, it became a new leader and fetched, stored and scanned an
artifact the cache already held.
The leader now rechecks the committed record first. It serves that
record only if its bytes still open, because a record can outlive them,
and refetching is the recovery the cache lookup already makes for that
case. Waiters are unaffected: the record fills the same shared value a
fetch would, and each caller opens its own reader from it.
The recheck is the leader's alone. A waiter has a fetch in flight to
wait on, and rechecking would race it for no gain.
* Lock the mock fetcher's bookkeeping
Coalescing tests call the handler from many goroutines. The key keeps
the fetch itself serialized, but the mock should not lean on that: it
now locks the fields it records, so any concurrency the handler applies
is safe under the race detector.
* Wait for the leader's fetch instead of sleeping
The canceled-waiter test slept 200ms and assumed the leader had taken
the key by then. On a slow scheduler the canceled call could become the
leader and the test would no longer cover waiter cancellation. The
fetcher now signals when its first fetch begins, which happens only once
the key is held.
2026-09-15 11:59:03 -07:00
|
|
|
return artifacts.Artifact{}, "", fmt.Errorf("%w: upstream declared %s, got %s", ErrArtifactDigestMismatch, upstreamHash, hash)
|
2026-09-03 05:06:44 -04:00
|
|
|
}
|
2026-10-14 15:36:26 +02:00
|
|
|
if err := verified(); err != nil {
|
|
|
|
|
p.Logger.Error("fetched artifact failed integrity check",
|
|
|
|
|
"purl", versionPURL, "filename", filename, "url", upstreamURL, "error", err)
|
|
|
|
|
metrics.RecordIntegrityFailure(purl.NormalizeEcosystem(ecosystem), metrics.FailureDetail{
|
|
|
|
|
PURL: versionPURL, Filename: filename, URL: upstreamURL, Err: err})
|
|
|
|
|
if delErr := p.Storage.Delete(ctx, storagePath); delErr != nil {
|
|
|
|
|
p.Logger.Warn("failed to discard artifact with mismatched integrity", "path", storagePath, "error", delErr)
|
|
|
|
|
}
|
|
|
|
|
return artifacts.Artifact{}, "", err
|
|
|
|
|
}
|
2026-09-03 05:06:44 -04:00
|
|
|
|
|
|
|
|
if p.Scanners != nil && p.Scanners.Enabled() {
|
|
|
|
|
if err := p.runScan(ctx, ecosystem, name, version, filename, versionPURL, storagePath, size, artifact.ContentType); err != nil {
|
|
|
|
|
// Detached from ctx: a client disconnecting must not abort
|
|
|
|
|
// cleanup of a genuinely blocked artifact and leave its bytes
|
|
|
|
|
// orphaned in storage with no DB row pointing at them.
|
|
|
|
|
if delErr := p.Storage.Delete(context.WithoutCancel(ctx), storagePath); delErr != nil {
|
|
|
|
|
p.Logger.Warn("failed to delete blocked artifact from storage",
|
|
|
|
|
"path", storagePath, "error", delErr)
|
|
|
|
|
}
|
Fix duplicate fetches and 502s on concurrent cache misses (#329)
* Return the stored artifact from storeArtifact, not a reader
storeArtifact returned a CacheResult holding an open file handle. A
handle has one read position, so it can only ever serve a single caller,
which is what blocks sharing one fetch between concurrent requests.
Return the artifact and its storage path instead, and let each caller
open its own reader through openStoredArtifact. Threading that type
through fetchAndCache, fetchAndCacheFromURL and their error paths is
mechanical; behaviour is unchanged.
* Coalesce concurrent cache misses
A cache miss went from checkCache straight to an upstream fetch with
nothing tracking in-flight work, so N concurrent requests for one
uncached artifact produced N upstream fetches and N stores to the same
key. That is the CI shape: parallel jobs installing overlapping
dependencies against a cold cache. The duplicate stores also fail
requests, racing fileblob's per-key ".attrs" sidecar into a partial read
served as a 502. Over 12 runs of 8 simultaneous requests for one
uncached tarball, against bb2205a: before, 8 fetches per run and 12 of
96 responses were 502; after, 1 fetch per run and none failed.
Route both miss paths through a shared in-flight map keyed on the
artifact, including the download URL and upstream-declared hash so
callers expecting different bytes never share a fetch.
singleflight does not fit: Do gives waiters no way to leave, while
DoChan lets the caller running the fetch abandon it, breaking
storeArtifact's scan-on-disconnect contract. Deciding roles under a
mutex gives both behaviours. The fetch runs on the first caller's
context and is seen through; waiters leave when their own clients do.
This removes the sidecar trigger on this path. The race is in fileblob
and three writers bypass this path entirely, so it is fixed separately.
Fewer failures now reach the circuit breaker, so it trips later.
Sixteen concurrent callers against real file:// storage fail 10 of 10
runs on main and pass 10 of 10 here. Other tests pin key discrimination,
failure propagation, resolver-path coalescing, per-caller readers,
waiter cancellation, key release and panic safety. allocs/op is
unchanged. mockStorage gains a mutex so concurrent tests can use it.
* Normalize digest case in the coalescing key
artifactHashMatches compares digests with strings.EqualFold, but the
coalescing key used the hash verbatim. The same digest in two casings
produced two keys, so two callers for one artifact each ran their own
upstream fetch and store, which is what the coalescing is meant to
prevent.
* Make the panic coalescing test deterministic
The test timed the second caller's arrival with a sleep, so which caller
became the leader was left to the scheduler. When it lost that race the
second caller ran the fetch itself, and its panic was not recovered, so
the test binary died instead of the test failing.
Whether a caller has reached the wait is not observable from outside:
it runs a cache lookup against the database first, so releasing the
leader on a timer races that query. Drive coalesceFetch directly and
hold the shared entry instead, which removes the timing entirely. The
panicking fetcher is no longer needed.
* Recheck the cache before running a shared fetch
A caller checks the cache before it reaches coalesceFetch, so a fetch
that commits in that gap is invisible to it. Arriving after the sharing
entry is gone, it became a new leader and fetched, stored and scanned an
artifact the cache already held.
The leader now rechecks the committed record first. It serves that
record only if its bytes still open, because a record can outlive them,
and refetching is the recovery the cache lookup already makes for that
case. Waiters are unaffected: the record fills the same shared value a
fetch would, and each caller opens its own reader from it.
The recheck is the leader's alone. A waiter has a fetch in flight to
wait on, and rechecking would race it for no gain.
* Lock the mock fetcher's bookkeeping
Coalescing tests call the handler from many goroutines. The key keeps
the fetch itself serialized, but the mock should not lean on that: it
now locks the fields it records, so any concurrency the handler applies
is safe under the race detector.
* Wait for the leader's fetch instead of sleeping
The canceled-waiter test slept 200ms and assumed the leader had taken
the key by then. On a slow scheduler the canceled call could become the
leader and the test would no longer cover waiter cancellation. The
fetcher now signals when its first fetch begins, which happens only once
the key is held.
2026-09-15 11:59:03 -07:00
|
|
|
return artifacts.Artifact{}, "", err
|
2026-09-03 05:06:44 -04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-04 10:58:22 +01:00
|
|
|
sharedArtifact := artifacts.Artifact{
|
|
|
|
|
PURL: versionPURL,
|
|
|
|
|
Digest: digest.Digest("sha256:" + hash),
|
|
|
|
|
Size: size,
|
|
|
|
|
Filename: filename,
|
|
|
|
|
MediaType: artifact.ContentType,
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-20 21:52:44 +00:00
|
|
|
// Update database
|
2026-10-14 15:36:26 +02:00
|
|
|
if err := p.updateCacheDB(ecosystem, name, pkgPURL, upstreamURL, storagePath, sharedArtifact, declared); err != nil {
|
2026-01-20 21:52:44 +00:00
|
|
|
p.Logger.Warn("failed to update cache database", "error", err)
|
Give each fetch its own storage path
Every fetch of an artifact wrote one path, so fetches with different URLs
or digests, which do not share a coalescing key, could overwrite each
other's bytes, serve the wrong ones, or delete them on a digest mismatch
while the other was about to open them.
storeArtifact now writes {ecosystem}/{name}/{version}/{fetch id}/{filename},
with a random id per fetch. Most ecosystems learn the digest only after
the fetch, and storage has no rename, so the id is the one rule that fits
all of them. Existing records keep their paths and stay readable. On a
file:// bucket, Delete now removes an emptied fetch directory, which
fileblob leaves behind, and no other directory, since another fetch may
be creating its own inside it.
A path a record stops pointing at is queued in pending_deletes rather
than deleted, since a request that read the record may still open it. A
loop deletes queued paths after max(1h, direct_serve_ttl), whether or not
max_size is set. UpsertArtifact and clears apply only if the record still
holds the path the caller read, so racing commits each queue the path
they replaced and a clear never orphans a newer commit. Eviction still
deletes inline, and counts space as freed only when the record still
pointed at what it deleted.
2026-09-23 14:40:13 -07:00
|
|
|
// Continue anyway - we have the file. Queue it for deletion in case
|
|
|
|
|
// no record points at it; the queue skips it while one does.
|
|
|
|
|
if qErr := p.DB.QueuePendingDelete(storagePath); qErr != nil {
|
|
|
|
|
p.Logger.Warn("failed to queue unrecorded artifact for deletion", "path", storagePath, "error", qErr)
|
|
|
|
|
}
|
2026-01-20 21:52:44 +00:00
|
|
|
}
|
|
|
|
|
|
Fix duplicate fetches and 502s on concurrent cache misses (#329)
* Return the stored artifact from storeArtifact, not a reader
storeArtifact returned a CacheResult holding an open file handle. A
handle has one read position, so it can only ever serve a single caller,
which is what blocks sharing one fetch between concurrent requests.
Return the artifact and its storage path instead, and let each caller
open its own reader through openStoredArtifact. Threading that type
through fetchAndCache, fetchAndCacheFromURL and their error paths is
mechanical; behaviour is unchanged.
* Coalesce concurrent cache misses
A cache miss went from checkCache straight to an upstream fetch with
nothing tracking in-flight work, so N concurrent requests for one
uncached artifact produced N upstream fetches and N stores to the same
key. That is the CI shape: parallel jobs installing overlapping
dependencies against a cold cache. The duplicate stores also fail
requests, racing fileblob's per-key ".attrs" sidecar into a partial read
served as a 502. Over 12 runs of 8 simultaneous requests for one
uncached tarball, against bb2205a: before, 8 fetches per run and 12 of
96 responses were 502; after, 1 fetch per run and none failed.
Route both miss paths through a shared in-flight map keyed on the
artifact, including the download URL and upstream-declared hash so
callers expecting different bytes never share a fetch.
singleflight does not fit: Do gives waiters no way to leave, while
DoChan lets the caller running the fetch abandon it, breaking
storeArtifact's scan-on-disconnect contract. Deciding roles under a
mutex gives both behaviours. The fetch runs on the first caller's
context and is seen through; waiters leave when their own clients do.
This removes the sidecar trigger on this path. The race is in fileblob
and three writers bypass this path entirely, so it is fixed separately.
Fewer failures now reach the circuit breaker, so it trips later.
Sixteen concurrent callers against real file:// storage fail 10 of 10
runs on main and pass 10 of 10 here. Other tests pin key discrimination,
failure propagation, resolver-path coalescing, per-caller readers,
waiter cancellation, key release and panic safety. allocs/op is
unchanged. mockStorage gains a mutex so concurrent tests can use it.
* Normalize digest case in the coalescing key
artifactHashMatches compares digests with strings.EqualFold, but the
coalescing key used the hash verbatim. The same digest in two casings
produced two keys, so two callers for one artifact each ran their own
upstream fetch and store, which is what the coalescing is meant to
prevent.
* Make the panic coalescing test deterministic
The test timed the second caller's arrival with a sleep, so which caller
became the leader was left to the scheduler. When it lost that race the
second caller ran the fetch itself, and its panic was not recovered, so
the test binary died instead of the test failing.
Whether a caller has reached the wait is not observable from outside:
it runs a cache lookup against the database first, so releasing the
leader on a timer races that query. Drive coalesceFetch directly and
hold the shared entry instead, which removes the timing entirely. The
panicking fetcher is no longer needed.
* Recheck the cache before running a shared fetch
A caller checks the cache before it reaches coalesceFetch, so a fetch
that commits in that gap is invisible to it. Arriving after the sharing
entry is gone, it became a new leader and fetched, stored and scanned an
artifact the cache already held.
The leader now rechecks the committed record first. It serves that
record only if its bytes still open, because a record can outlive them,
and refetching is the recovery the cache lookup already makes for that
case. Waiters are unaffected: the record fills the same shared value a
fetch would, and each caller opens its own reader from it.
The recheck is the leader's alone. A waiter has a fetch in flight to
wait on, and rechecking would race it for no gain.
* Lock the mock fetcher's bookkeeping
Coalescing tests call the handler from many goroutines. The key keeps
the fetch itself serialized, but the mock should not lean on that: it
now locks the fields it records, so any concurrency the handler applies
is safe under the race detector.
* Wait for the leader's fetch instead of sleeping
The canceled-waiter test slept 200ms and assumed the leader had taken
the key by then. On a slow scheduler the canceled call could become the
leader and the test would no longer cover waiter cancellation. The
fetcher now signals when its first fetch begins, which happens only once
the key is held.
2026-09-15 11:59:03 -07:00
|
|
|
return sharedArtifact, storagePath, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// openStoredArtifact gives one caller its own reader over just-committed
|
|
|
|
|
// bytes. Callers sharing a fetch cannot share a handle: it has one read
|
|
|
|
|
// position, so they would consume each other's bytes and the first Close would
|
|
|
|
|
// break the rest.
|
|
|
|
|
func (p *Proxy) openStoredArtifact(ctx context.Context, artifact artifacts.Artifact, storagePath string) (*CacheResult, error) {
|
2026-02-03 22:40:23 +00:00
|
|
|
readStart := time.Now()
|
2026-01-20 21:52:44 +00:00
|
|
|
reader, err := p.Storage.Open(ctx, storagePath)
|
2026-02-03 22:40:23 +00:00
|
|
|
metrics.RecordStorageOperation("read", time.Since(readStart))
|
|
|
|
|
|
2026-01-20 21:52:44 +00:00
|
|
|
if err != nil {
|
Record the detail behind each failure counter
- Failure recorders take a FailureDetail: PURL, filename, URL, storage path, error
- Keep the last 20 failures per counter series in memory, reset on restart
- Strip userinfo, query and fragment from URLs, including those inside error text
- Pass the detail from upstream, storage, integrity, health probe and scan call sites
- Package names and URLs stay out of Prometheus labels
2026-10-12 09:41:42 +02:00
|
|
|
metrics.RecordStorageError("read", metrics.FailureDetail{
|
|
|
|
|
PURL: artifact.PURL, Filename: artifact.Filename, Path: storagePath, Err: err})
|
2026-01-20 21:52:44 +00:00
|
|
|
return nil, fmt.Errorf("opening cached artifact: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return &CacheResult{
|
2026-09-04 10:58:22 +01:00
|
|
|
Reader: reader,
|
Fix duplicate fetches and 502s on concurrent cache misses (#329)
* Return the stored artifact from storeArtifact, not a reader
storeArtifact returned a CacheResult holding an open file handle. A
handle has one read position, so it can only ever serve a single caller,
which is what blocks sharing one fetch between concurrent requests.
Return the artifact and its storage path instead, and let each caller
open its own reader through openStoredArtifact. Threading that type
through fetchAndCache, fetchAndCacheFromURL and their error paths is
mechanical; behaviour is unchanged.
* Coalesce concurrent cache misses
A cache miss went from checkCache straight to an upstream fetch with
nothing tracking in-flight work, so N concurrent requests for one
uncached artifact produced N upstream fetches and N stores to the same
key. That is the CI shape: parallel jobs installing overlapping
dependencies against a cold cache. The duplicate stores also fail
requests, racing fileblob's per-key ".attrs" sidecar into a partial read
served as a 502. Over 12 runs of 8 simultaneous requests for one
uncached tarball, against bb2205a: before, 8 fetches per run and 12 of
96 responses were 502; after, 1 fetch per run and none failed.
Route both miss paths through a shared in-flight map keyed on the
artifact, including the download URL and upstream-declared hash so
callers expecting different bytes never share a fetch.
singleflight does not fit: Do gives waiters no way to leave, while
DoChan lets the caller running the fetch abandon it, breaking
storeArtifact's scan-on-disconnect contract. Deciding roles under a
mutex gives both behaviours. The fetch runs on the first caller's
context and is seen through; waiters leave when their own clients do.
This removes the sidecar trigger on this path. The race is in fileblob
and three writers bypass this path entirely, so it is fixed separately.
Fewer failures now reach the circuit breaker, so it trips later.
Sixteen concurrent callers against real file:// storage fail 10 of 10
runs on main and pass 10 of 10 here. Other tests pin key discrimination,
failure propagation, resolver-path coalescing, per-caller readers,
waiter cancellation, key release and panic safety. allocs/op is
unchanged. mockStorage gains a mutex so concurrent tests can use it.
* Normalize digest case in the coalescing key
artifactHashMatches compares digests with strings.EqualFold, but the
coalescing key used the hash verbatim. The same digest in two casings
produced two keys, so two callers for one artifact each ran their own
upstream fetch and store, which is what the coalescing is meant to
prevent.
* Make the panic coalescing test deterministic
The test timed the second caller's arrival with a sleep, so which caller
became the leader was left to the scheduler. When it lost that race the
second caller ran the fetch itself, and its panic was not recovered, so
the test binary died instead of the test failing.
Whether a caller has reached the wait is not observable from outside:
it runs a cache lookup against the database first, so releasing the
leader on a timer races that query. Drive coalesceFetch directly and
hold the shared entry instead, which removes the timing entirely. The
panicking fetcher is no longer needed.
* Recheck the cache before running a shared fetch
A caller checks the cache before it reaches coalesceFetch, so a fetch
that commits in that gap is invisible to it. Arriving after the sharing
entry is gone, it became a new leader and fetched, stored and scanned an
artifact the cache already held.
The leader now rechecks the committed record first. It serves that
record only if its bytes still open, because a record can outlive them,
and refetching is the recovery the cache lookup already makes for that
case. Waiters are unaffected: the record fills the same shared value a
fetch would, and each caller opens its own reader from it.
The recheck is the leader's alone. A waiter has a fetch in flight to
wait on, and rechecking would race it for no gain.
* Lock the mock fetcher's bookkeeping
Coalescing tests call the handler from many goroutines. The key keeps
the fetch itself serialized, but the mock should not lean on that: it
now locks the fields it records, so any concurrency the handler applies
is safe under the race detector.
* Wait for the leader's fetch instead of sleeping
The canceled-waiter test slept 200ms and assumed the leader had taken
the key by then. On a slow scheduler the canceled call could become the
leader and the test would no longer cover waiter cancellation. The
fetcher now signals when its first fetch begins, which happens only once
the key is held.
2026-09-15 11:59:03 -07:00
|
|
|
Artifact: artifact,
|
2026-09-04 10:58:22 +01:00
|
|
|
Cached: false,
|
2026-01-20 21:52:44 +00:00
|
|
|
}, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-14 15:36:26 +02:00
|
|
|
// artifactCoalesceKey identifies one artifact fetch. downloadURL, upstreamHash
|
|
|
|
|
// and declared are included so callers expecting different bytes (multiple
|
Fix duplicate fetches and 502s on concurrent cache misses (#329)
* Return the stored artifact from storeArtifact, not a reader
storeArtifact returned a CacheResult holding an open file handle. A
handle has one read position, so it can only ever serve a single caller,
which is what blocks sharing one fetch between concurrent requests.
Return the artifact and its storage path instead, and let each caller
open its own reader through openStoredArtifact. Threading that type
through fetchAndCache, fetchAndCacheFromURL and their error paths is
mechanical; behaviour is unchanged.
* Coalesce concurrent cache misses
A cache miss went from checkCache straight to an upstream fetch with
nothing tracking in-flight work, so N concurrent requests for one
uncached artifact produced N upstream fetches and N stores to the same
key. That is the CI shape: parallel jobs installing overlapping
dependencies against a cold cache. The duplicate stores also fail
requests, racing fileblob's per-key ".attrs" sidecar into a partial read
served as a 502. Over 12 runs of 8 simultaneous requests for one
uncached tarball, against bb2205a: before, 8 fetches per run and 12 of
96 responses were 502; after, 1 fetch per run and none failed.
Route both miss paths through a shared in-flight map keyed on the
artifact, including the download URL and upstream-declared hash so
callers expecting different bytes never share a fetch.
singleflight does not fit: Do gives waiters no way to leave, while
DoChan lets the caller running the fetch abandon it, breaking
storeArtifact's scan-on-disconnect contract. Deciding roles under a
mutex gives both behaviours. The fetch runs on the first caller's
context and is seen through; waiters leave when their own clients do.
This removes the sidecar trigger on this path. The race is in fileblob
and three writers bypass this path entirely, so it is fixed separately.
Fewer failures now reach the circuit breaker, so it trips later.
Sixteen concurrent callers against real file:// storage fail 10 of 10
runs on main and pass 10 of 10 here. Other tests pin key discrimination,
failure propagation, resolver-path coalescing, per-caller readers,
waiter cancellation, key release and panic safety. allocs/op is
unchanged. mockStorage gains a mutex so concurrent tests can use it.
* Normalize digest case in the coalescing key
artifactHashMatches compares digests with strings.EqualFold, but the
coalescing key used the hash verbatim. The same digest in two casings
produced two keys, so two callers for one artifact each ran their own
upstream fetch and store, which is what the coalescing is meant to
prevent.
* Make the panic coalescing test deterministic
The test timed the second caller's arrival with a sleep, so which caller
became the leader was left to the scheduler. When it lost that race the
second caller ran the fetch itself, and its panic was not recovered, so
the test binary died instead of the test failing.
Whether a caller has reached the wait is not observable from outside:
it runs a cache lookup against the database first, so releasing the
leader on a timer races that query. Drive coalesceFetch directly and
hold the shared entry instead, which removes the timing entirely. The
panicking fetcher is no longer needed.
* Recheck the cache before running a shared fetch
A caller checks the cache before it reaches coalesceFetch, so a fetch
that commits in that gap is invisible to it. Arriving after the sharing
entry is gone, it became a new leader and fetched, stored and scanned an
artifact the cache already held.
The leader now rechecks the committed record first. It serves that
record only if its bytes still open, because a record can outlive them,
and refetching is the recovery the cache lookup already makes for that
case. Waiters are unaffected: the record fills the same shared value a
fetch would, and each caller opens its own reader from it.
The recheck is the leader's alone. A waiter has a fetch in flight to
wait on, and rechecking would race it for no gain.
* Lock the mock fetcher's bookkeeping
Coalescing tests call the handler from many goroutines. The key keeps
the fetch itself serialized, but the mock should not lean on that: it
now locks the fields it records, so any concurrency the handler applies
is safe under the race detector.
* Wait for the leader's fetch instead of sleeping
The canceled-waiter test slept 200ms and assumed the leader had taken
the key by then. On a slow scheduler the canceled call could become the
leader and the test would no longer cover waiter cancellation. The
fetcher now signals when its first fetch begins, which happens only once
the key is held.
2026-09-15 11:59:03 -07:00
|
|
|
// upstreams, or a re-published version) never share a fetch. The hash is
|
|
|
|
|
// lowercased because artifactHashMatches compares case-insensitively, so one
|
|
|
|
|
// digest in two casings describes one artifact and must not split the fetch.
|
2026-10-14 15:36:26 +02:00
|
|
|
func artifactCoalesceKey(versionPURL, filename, downloadURL, upstreamHash, declared string) string {
|
|
|
|
|
return strings.Join([]string{versionPURL, filename, downloadURL, strings.ToLower(upstreamHash), declared}, "\x00")
|
Fix duplicate fetches and 502s on concurrent cache misses (#329)
* Return the stored artifact from storeArtifact, not a reader
storeArtifact returned a CacheResult holding an open file handle. A
handle has one read position, so it can only ever serve a single caller,
which is what blocks sharing one fetch between concurrent requests.
Return the artifact and its storage path instead, and let each caller
open its own reader through openStoredArtifact. Threading that type
through fetchAndCache, fetchAndCacheFromURL and their error paths is
mechanical; behaviour is unchanged.
* Coalesce concurrent cache misses
A cache miss went from checkCache straight to an upstream fetch with
nothing tracking in-flight work, so N concurrent requests for one
uncached artifact produced N upstream fetches and N stores to the same
key. That is the CI shape: parallel jobs installing overlapping
dependencies against a cold cache. The duplicate stores also fail
requests, racing fileblob's per-key ".attrs" sidecar into a partial read
served as a 502. Over 12 runs of 8 simultaneous requests for one
uncached tarball, against bb2205a: before, 8 fetches per run and 12 of
96 responses were 502; after, 1 fetch per run and none failed.
Route both miss paths through a shared in-flight map keyed on the
artifact, including the download URL and upstream-declared hash so
callers expecting different bytes never share a fetch.
singleflight does not fit: Do gives waiters no way to leave, while
DoChan lets the caller running the fetch abandon it, breaking
storeArtifact's scan-on-disconnect contract. Deciding roles under a
mutex gives both behaviours. The fetch runs on the first caller's
context and is seen through; waiters leave when their own clients do.
This removes the sidecar trigger on this path. The race is in fileblob
and three writers bypass this path entirely, so it is fixed separately.
Fewer failures now reach the circuit breaker, so it trips later.
Sixteen concurrent callers against real file:// storage fail 10 of 10
runs on main and pass 10 of 10 here. Other tests pin key discrimination,
failure propagation, resolver-path coalescing, per-caller readers,
waiter cancellation, key release and panic safety. allocs/op is
unchanged. mockStorage gains a mutex so concurrent tests can use it.
* Normalize digest case in the coalescing key
artifactHashMatches compares digests with strings.EqualFold, but the
coalescing key used the hash verbatim. The same digest in two casings
produced two keys, so two callers for one artifact each ran their own
upstream fetch and store, which is what the coalescing is meant to
prevent.
* Make the panic coalescing test deterministic
The test timed the second caller's arrival with a sleep, so which caller
became the leader was left to the scheduler. When it lost that race the
second caller ran the fetch itself, and its panic was not recovered, so
the test binary died instead of the test failing.
Whether a caller has reached the wait is not observable from outside:
it runs a cache lookup against the database first, so releasing the
leader on a timer races that query. Drive coalesceFetch directly and
hold the shared entry instead, which removes the timing entirely. The
panicking fetcher is no longer needed.
* Recheck the cache before running a shared fetch
A caller checks the cache before it reaches coalesceFetch, so a fetch
that commits in that gap is invisible to it. Arriving after the sharing
entry is gone, it became a new leader and fetched, stored and scanned an
artifact the cache already held.
The leader now rechecks the committed record first. It serves that
record only if its bytes still open, because a record can outlive them,
and refetching is the recovery the cache lookup already makes for that
case. Waiters are unaffected: the record fills the same shared value a
fetch would, and each caller opens its own reader from it.
The recheck is the leader's alone. A waiter has a fetch in flight to
wait on, and rechecking would race it for no gain.
* Lock the mock fetcher's bookkeeping
Coalescing tests call the handler from many goroutines. The key keeps
the fetch itself serialized, but the mock should not lean on that: it
now locks the fields it records, so any concurrency the handler applies
is safe under the race detector.
* Wait for the leader's fetch instead of sleeping
The canceled-waiter test slept 200ms and assumed the leader had taken
the key by then. On a slow scheduler the canceled call could become the
leader and the test would no longer cover waiter cancellation. The
fetcher now signals when its first fetch begins, which happens only once
the key is held.
2026-09-15 11:59:03 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// cachedArtifactRecord reports an artifact already committed to the cache,
|
|
|
|
|
// without opening it. A caller checks the cache before it gets here, so a
|
|
|
|
|
// concurrent fetch can commit the same artifact in between; rechecking the
|
|
|
|
|
// record keeps that caller from fetching it a second time. A lookup error is
|
|
|
|
|
// reported as a miss, which costs a redundant fetch rather than a failure.
|
|
|
|
|
func (p *Proxy) cachedArtifactRecord(pkgPURL, versionPURL, filename, upstreamHash string) (artifacts.Artifact, string, bool) {
|
|
|
|
|
record, err := p.DB.GetCachedArtifact(pkgPURL, versionPURL, filename)
|
|
|
|
|
if err != nil || record == nil {
|
|
|
|
|
return artifacts.Artifact{}, "", false
|
|
|
|
|
}
|
|
|
|
|
if !artifactHashMatches(record.Artifact.Digest.Encoded(), upstreamHash) {
|
|
|
|
|
return artifacts.Artifact{}, "", false
|
|
|
|
|
}
|
|
|
|
|
return record.Artifact, record.StoragePath, true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// errSharedFetchAbandoned is what waiters see if the caller running a shared
|
|
|
|
|
// fetch panicked out of it.
|
|
|
|
|
var errSharedFetchAbandoned = errors.New("shared upstream fetch did not complete")
|
|
|
|
|
|
|
|
|
|
// inflightFetch is one upstream fetch that concurrent callers share. val and
|
|
|
|
|
// err are written before done closes and read only after, so the close is the
|
|
|
|
|
// handoff.
|
|
|
|
|
type inflightFetch struct {
|
|
|
|
|
done chan struct{}
|
|
|
|
|
val fetchedArtifact
|
|
|
|
|
err error
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// coalesceFetch runs commit at most once for concurrent callers sharing key,
|
|
|
|
|
// then gives each its own reader over the stored bytes.
|
|
|
|
|
//
|
|
|
|
|
// The first caller in runs the fetch and the rest wait on it. Roles are
|
|
|
|
|
// decided under fetchMu rather than inferred afterwards, because the two need
|
|
|
|
|
// different cancellation behaviour: a waiter may leave when its own client goes
|
|
|
|
|
// away, while the caller running the fetch must see it through so
|
|
|
|
|
// storeArtifact's scan-on-disconnect handling still decides the outcome.
|
|
|
|
|
//
|
|
|
|
|
// Before fetching, that caller rechecks the cache through recheck: its own
|
|
|
|
|
// lookup happened before it took the key, so a fetch that committed in
|
|
|
|
|
// between would otherwise be repeated. A hit fills the shared value as a
|
|
|
|
|
// fetch would.
|
|
|
|
|
//
|
|
|
|
|
// commit runs on that caller's context, so cancellation behaves as it did
|
|
|
|
|
// uncoalesced and mirroring still relies on it aborting the fetch. If that
|
|
|
|
|
// caller goes away, everyone sharing the fetch gets its error and the key is
|
2026-10-11 23:29:52 +02:00
|
|
|
// released for a later retry. A context marked with DetachUpstreamFetch is the
|
|
|
|
|
// exception: there commit keeps running after the caller goes away, so the
|
|
|
|
|
// artifact still lands in the cache.
|
Fix duplicate fetches and 502s on concurrent cache misses (#329)
* Return the stored artifact from storeArtifact, not a reader
storeArtifact returned a CacheResult holding an open file handle. A
handle has one read position, so it can only ever serve a single caller,
which is what blocks sharing one fetch between concurrent requests.
Return the artifact and its storage path instead, and let each caller
open its own reader through openStoredArtifact. Threading that type
through fetchAndCache, fetchAndCacheFromURL and their error paths is
mechanical; behaviour is unchanged.
* Coalesce concurrent cache misses
A cache miss went from checkCache straight to an upstream fetch with
nothing tracking in-flight work, so N concurrent requests for one
uncached artifact produced N upstream fetches and N stores to the same
key. That is the CI shape: parallel jobs installing overlapping
dependencies against a cold cache. The duplicate stores also fail
requests, racing fileblob's per-key ".attrs" sidecar into a partial read
served as a 502. Over 12 runs of 8 simultaneous requests for one
uncached tarball, against bb2205a: before, 8 fetches per run and 12 of
96 responses were 502; after, 1 fetch per run and none failed.
Route both miss paths through a shared in-flight map keyed on the
artifact, including the download URL and upstream-declared hash so
callers expecting different bytes never share a fetch.
singleflight does not fit: Do gives waiters no way to leave, while
DoChan lets the caller running the fetch abandon it, breaking
storeArtifact's scan-on-disconnect contract. Deciding roles under a
mutex gives both behaviours. The fetch runs on the first caller's
context and is seen through; waiters leave when their own clients do.
This removes the sidecar trigger on this path. The race is in fileblob
and three writers bypass this path entirely, so it is fixed separately.
Fewer failures now reach the circuit breaker, so it trips later.
Sixteen concurrent callers against real file:// storage fail 10 of 10
runs on main and pass 10 of 10 here. Other tests pin key discrimination,
failure propagation, resolver-path coalescing, per-caller readers,
waiter cancellation, key release and panic safety. allocs/op is
unchanged. mockStorage gains a mutex so concurrent tests can use it.
* Normalize digest case in the coalescing key
artifactHashMatches compares digests with strings.EqualFold, but the
coalescing key used the hash verbatim. The same digest in two casings
produced two keys, so two callers for one artifact each ran their own
upstream fetch and store, which is what the coalescing is meant to
prevent.
* Make the panic coalescing test deterministic
The test timed the second caller's arrival with a sleep, so which caller
became the leader was left to the scheduler. When it lost that race the
second caller ran the fetch itself, and its panic was not recovered, so
the test binary died instead of the test failing.
Whether a caller has reached the wait is not observable from outside:
it runs a cache lookup against the database first, so releasing the
leader on a timer races that query. Drive coalesceFetch directly and
hold the shared entry instead, which removes the timing entirely. The
panicking fetcher is no longer needed.
* Recheck the cache before running a shared fetch
A caller checks the cache before it reaches coalesceFetch, so a fetch
that commits in that gap is invisible to it. Arriving after the sharing
entry is gone, it became a new leader and fetched, stored and scanned an
artifact the cache already held.
The leader now rechecks the committed record first. It serves that
record only if its bytes still open, because a record can outlive them,
and refetching is the recovery the cache lookup already makes for that
case. Waiters are unaffected: the record fills the same shared value a
fetch would, and each caller opens its own reader from it.
The recheck is the leader's alone. A waiter has a fetch in flight to
wait on, and rechecking would race it for no gain.
* Lock the mock fetcher's bookkeeping
Coalescing tests call the handler from many goroutines. The key keeps
the fetch itself serialized, but the mock should not lean on that: it
now locks the fields it records, so any concurrency the handler applies
is safe under the race detector.
* Wait for the leader's fetch instead of sleeping
The canceled-waiter test slept 200ms and assumed the leader had taken
the key by then. On a slow scheduler the canceled call could become the
leader and the test would no longer cover waiter cancellation. The
fetcher now signals when its first fetch begins, which happens only once
the key is held.
2026-09-15 11:59:03 -07:00
|
|
|
//
|
|
|
|
|
// Every sharing caller still records a cache miss, so the gap between
|
|
|
|
|
// proxy_cache_misses_total and upstream fetch observations is what coalescing
|
|
|
|
|
// saved.
|
|
|
|
|
func (p *Proxy) coalesceFetch(ctx context.Context, key string, recheck func() (artifacts.Artifact, string, bool), commit func(context.Context) (artifacts.Artifact, string, error)) (*CacheResult, error) {
|
|
|
|
|
p.fetchMu.Lock()
|
|
|
|
|
if p.inFlight == nil {
|
|
|
|
|
p.inFlight = make(map[string]*inflightFetch)
|
|
|
|
|
}
|
|
|
|
|
f, joined := p.inFlight[key]
|
|
|
|
|
if !joined {
|
|
|
|
|
f = &inflightFetch{done: make(chan struct{})}
|
|
|
|
|
p.inFlight[key] = f
|
|
|
|
|
}
|
|
|
|
|
p.fetchMu.Unlock()
|
|
|
|
|
|
|
|
|
|
if !joined {
|
|
|
|
|
return p.runSharedFetch(ctx, key, f, recheck, commit)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
select {
|
|
|
|
|
case <-ctx.Done():
|
|
|
|
|
// This caller gave up; the fetch continues for everyone else.
|
|
|
|
|
return nil, ctx.Err()
|
|
|
|
|
case <-f.done:
|
|
|
|
|
}
|
|
|
|
|
if f.err != nil {
|
|
|
|
|
return nil, f.err
|
|
|
|
|
}
|
|
|
|
|
return p.openStoredArtifact(ctx, f.val.artifact, f.val.storagePath)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// runSharedFetch performs the fetch that joined callers are waiting on. It is
|
|
|
|
|
// never abandoned early, and always releases the key and wakes the waiters.
|
|
|
|
|
func (p *Proxy) runSharedFetch(ctx context.Context, key string, f *inflightFetch, recheck func() (artifacts.Artifact, string, bool), commit func(context.Context) (artifacts.Artifact, string, error)) (*CacheResult, error) {
|
|
|
|
|
// Set before running so a panicking commit leaves waiters with an error
|
|
|
|
|
// rather than a zero-valued artifact.
|
|
|
|
|
f.err = errSharedFetchAbandoned
|
|
|
|
|
defer func() {
|
|
|
|
|
p.fetchMu.Lock()
|
|
|
|
|
delete(p.inFlight, key)
|
|
|
|
|
p.fetchMu.Unlock()
|
|
|
|
|
close(f.done)
|
|
|
|
|
}()
|
|
|
|
|
|
|
|
|
|
// A caller checks the cache before reaching here, so a fetch that finished
|
|
|
|
|
// in between would otherwise be repeated. Serve that record only if its
|
|
|
|
|
// bytes are still present: a record can outlive them, and refetching is
|
|
|
|
|
// the same recovery the cache lookup makes.
|
|
|
|
|
if stored, path, ok := recheck(); ok {
|
|
|
|
|
if res, err := p.openStoredArtifact(ctx, stored, path); err == nil {
|
|
|
|
|
f.val, f.err = fetchedArtifact{artifact: stored, storagePath: path}, nil
|
|
|
|
|
return res, nil
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-11 23:29:52 +02:00
|
|
|
commitCtx := ctx
|
2026-10-11 23:51:43 +02:00
|
|
|
if upstreamFetchDetached(ctx) && p.acquireDetachSlot() {
|
|
|
|
|
defer p.releaseDetachSlot()
|
2026-10-11 23:29:52 +02:00
|
|
|
commitCtx = context.WithoutCancel(ctx)
|
|
|
|
|
}
|
|
|
|
|
stored, path, err := commit(commitCtx)
|
Fix duplicate fetches and 502s on concurrent cache misses (#329)
* Return the stored artifact from storeArtifact, not a reader
storeArtifact returned a CacheResult holding an open file handle. A
handle has one read position, so it can only ever serve a single caller,
which is what blocks sharing one fetch between concurrent requests.
Return the artifact and its storage path instead, and let each caller
open its own reader through openStoredArtifact. Threading that type
through fetchAndCache, fetchAndCacheFromURL and their error paths is
mechanical; behaviour is unchanged.
* Coalesce concurrent cache misses
A cache miss went from checkCache straight to an upstream fetch with
nothing tracking in-flight work, so N concurrent requests for one
uncached artifact produced N upstream fetches and N stores to the same
key. That is the CI shape: parallel jobs installing overlapping
dependencies against a cold cache. The duplicate stores also fail
requests, racing fileblob's per-key ".attrs" sidecar into a partial read
served as a 502. Over 12 runs of 8 simultaneous requests for one
uncached tarball, against bb2205a: before, 8 fetches per run and 12 of
96 responses were 502; after, 1 fetch per run and none failed.
Route both miss paths through a shared in-flight map keyed on the
artifact, including the download URL and upstream-declared hash so
callers expecting different bytes never share a fetch.
singleflight does not fit: Do gives waiters no way to leave, while
DoChan lets the caller running the fetch abandon it, breaking
storeArtifact's scan-on-disconnect contract. Deciding roles under a
mutex gives both behaviours. The fetch runs on the first caller's
context and is seen through; waiters leave when their own clients do.
This removes the sidecar trigger on this path. The race is in fileblob
and three writers bypass this path entirely, so it is fixed separately.
Fewer failures now reach the circuit breaker, so it trips later.
Sixteen concurrent callers against real file:// storage fail 10 of 10
runs on main and pass 10 of 10 here. Other tests pin key discrimination,
failure propagation, resolver-path coalescing, per-caller readers,
waiter cancellation, key release and panic safety. allocs/op is
unchanged. mockStorage gains a mutex so concurrent tests can use it.
* Normalize digest case in the coalescing key
artifactHashMatches compares digests with strings.EqualFold, but the
coalescing key used the hash verbatim. The same digest in two casings
produced two keys, so two callers for one artifact each ran their own
upstream fetch and store, which is what the coalescing is meant to
prevent.
* Make the panic coalescing test deterministic
The test timed the second caller's arrival with a sleep, so which caller
became the leader was left to the scheduler. When it lost that race the
second caller ran the fetch itself, and its panic was not recovered, so
the test binary died instead of the test failing.
Whether a caller has reached the wait is not observable from outside:
it runs a cache lookup against the database first, so releasing the
leader on a timer races that query. Drive coalesceFetch directly and
hold the shared entry instead, which removes the timing entirely. The
panicking fetcher is no longer needed.
* Recheck the cache before running a shared fetch
A caller checks the cache before it reaches coalesceFetch, so a fetch
that commits in that gap is invisible to it. Arriving after the sharing
entry is gone, it became a new leader and fetched, stored and scanned an
artifact the cache already held.
The leader now rechecks the committed record first. It serves that
record only if its bytes still open, because a record can outlive them,
and refetching is the recovery the cache lookup already makes for that
case. Waiters are unaffected: the record fills the same shared value a
fetch would, and each caller opens its own reader from it.
The recheck is the leader's alone. A waiter has a fetch in flight to
wait on, and rechecking would race it for no gain.
* Lock the mock fetcher's bookkeeping
Coalescing tests call the handler from many goroutines. The key keeps
the fetch itself serialized, but the mock should not lean on that: it
now locks the fields it records, so any concurrency the handler applies
is safe under the race detector.
* Wait for the leader's fetch instead of sleeping
The canceled-waiter test slept 200ms and assumed the leader had taken
the key by then. On a slow scheduler the canceled call could become the
leader and the test would no longer cover waiter cancellation. The
fetcher now signals when its first fetch begins, which happens only once
the key is held.
2026-09-15 11:59:03 -07:00
|
|
|
f.val, f.err = fetchedArtifact{artifact: stored, storagePath: path}, err
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
return p.openStoredArtifact(ctx, stored, path)
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-11 23:29:52 +02:00
|
|
|
type detachUpstreamFetchKey struct{}
|
|
|
|
|
|
|
|
|
|
// DetachUpstreamFetch marks ctx so that an artifact fetch started under it
|
|
|
|
|
// runs to completion even when ctx is cancelled. The server sets it on client
|
|
|
|
|
// requests: a client that gives up on a slow cache miss (pacman aborts after
|
|
|
|
|
// 10 s without data) would otherwise cancel the fetch, and every retry would
|
|
|
|
|
// start again from zero, so a large artifact never reaches the cache. The
|
2026-10-11 23:51:43 +02:00
|
|
|
// fetch stays bounded by the artifact HTTP client's timeout, and at most
|
|
|
|
|
// maxDetachedFetches run detached at once. Mirror jobs leave it unset so
|
|
|
|
|
// cancelling a job still aborts its fetches.
|
2026-10-11 23:29:52 +02:00
|
|
|
func DetachUpstreamFetch(ctx context.Context) context.Context {
|
|
|
|
|
return context.WithValue(ctx, detachUpstreamFetchKey{}, true)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func upstreamFetchDetached(ctx context.Context) bool {
|
|
|
|
|
detached, _ := ctx.Value(detachUpstreamFetchKey{}).(bool)
|
|
|
|
|
return detached
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-11 23:51:43 +02:00
|
|
|
// maxDetachedFetches caps the artifact fetches that may outlive their client.
|
|
|
|
|
// Without a cap a client could start any number of large uncached downloads
|
|
|
|
|
// and hang up straight away, leaving each to run to the artifact client's
|
|
|
|
|
// timeout. Fetches beyond the cap stay tied to their client, as before
|
|
|
|
|
// detaching existed.
|
|
|
|
|
const maxDetachedFetches = 64
|
|
|
|
|
|
|
|
|
|
func (p *Proxy) acquireDetachSlot() bool {
|
|
|
|
|
p.fetchMu.Lock()
|
|
|
|
|
if p.detachSlots == nil {
|
|
|
|
|
p.detachSlots = make(chan struct{}, maxDetachedFetches)
|
|
|
|
|
}
|
|
|
|
|
slots := p.detachSlots
|
|
|
|
|
p.fetchMu.Unlock()
|
|
|
|
|
|
|
|
|
|
select {
|
|
|
|
|
case slots <- struct{}{}:
|
|
|
|
|
return true
|
|
|
|
|
default:
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (p *Proxy) releaseDetachSlot() {
|
|
|
|
|
<-p.detachSlots
|
|
|
|
|
}
|
|
|
|
|
|
Fix duplicate fetches and 502s on concurrent cache misses (#329)
* Return the stored artifact from storeArtifact, not a reader
storeArtifact returned a CacheResult holding an open file handle. A
handle has one read position, so it can only ever serve a single caller,
which is what blocks sharing one fetch between concurrent requests.
Return the artifact and its storage path instead, and let each caller
open its own reader through openStoredArtifact. Threading that type
through fetchAndCache, fetchAndCacheFromURL and their error paths is
mechanical; behaviour is unchanged.
* Coalesce concurrent cache misses
A cache miss went from checkCache straight to an upstream fetch with
nothing tracking in-flight work, so N concurrent requests for one
uncached artifact produced N upstream fetches and N stores to the same
key. That is the CI shape: parallel jobs installing overlapping
dependencies against a cold cache. The duplicate stores also fail
requests, racing fileblob's per-key ".attrs" sidecar into a partial read
served as a 502. Over 12 runs of 8 simultaneous requests for one
uncached tarball, against bb2205a: before, 8 fetches per run and 12 of
96 responses were 502; after, 1 fetch per run and none failed.
Route both miss paths through a shared in-flight map keyed on the
artifact, including the download URL and upstream-declared hash so
callers expecting different bytes never share a fetch.
singleflight does not fit: Do gives waiters no way to leave, while
DoChan lets the caller running the fetch abandon it, breaking
storeArtifact's scan-on-disconnect contract. Deciding roles under a
mutex gives both behaviours. The fetch runs on the first caller's
context and is seen through; waiters leave when their own clients do.
This removes the sidecar trigger on this path. The race is in fileblob
and three writers bypass this path entirely, so it is fixed separately.
Fewer failures now reach the circuit breaker, so it trips later.
Sixteen concurrent callers against real file:// storage fail 10 of 10
runs on main and pass 10 of 10 here. Other tests pin key discrimination,
failure propagation, resolver-path coalescing, per-caller readers,
waiter cancellation, key release and panic safety. allocs/op is
unchanged. mockStorage gains a mutex so concurrent tests can use it.
* Normalize digest case in the coalescing key
artifactHashMatches compares digests with strings.EqualFold, but the
coalescing key used the hash verbatim. The same digest in two casings
produced two keys, so two callers for one artifact each ran their own
upstream fetch and store, which is what the coalescing is meant to
prevent.
* Make the panic coalescing test deterministic
The test timed the second caller's arrival with a sleep, so which caller
became the leader was left to the scheduler. When it lost that race the
second caller ran the fetch itself, and its panic was not recovered, so
the test binary died instead of the test failing.
Whether a caller has reached the wait is not observable from outside:
it runs a cache lookup against the database first, so releasing the
leader on a timer races that query. Drive coalesceFetch directly and
hold the shared entry instead, which removes the timing entirely. The
panicking fetcher is no longer needed.
* Recheck the cache before running a shared fetch
A caller checks the cache before it reaches coalesceFetch, so a fetch
that commits in that gap is invisible to it. Arriving after the sharing
entry is gone, it became a new leader and fetched, stored and scanned an
artifact the cache already held.
The leader now rechecks the committed record first. It serves that
record only if its bytes still open, because a record can outlive them,
and refetching is the recovery the cache lookup already makes for that
case. Waiters are unaffected: the record fills the same shared value a
fetch would, and each caller opens its own reader from it.
The recheck is the leader's alone. A waiter has a fetch in flight to
wait on, and rechecking would race it for no gain.
* Lock the mock fetcher's bookkeeping
Coalescing tests call the handler from many goroutines. The key keeps
the fetch itself serialized, but the mock should not lean on that: it
now locks the fields it records, so any concurrency the handler applies
is safe under the race detector.
* Wait for the leader's fetch instead of sleeping
The canceled-waiter test slept 200ms and assumed the leader had taken
the key by then. On a slow scheduler the canceled call could become the
leader and the test would no longer cover waiter cancellation. The
fetcher now signals when its first fetch begins, which happens only once
the key is held.
2026-09-15 11:59:03 -07:00
|
|
|
// fetchedArtifact is what a shared fetch hands its callers: metadata and a
|
|
|
|
|
// storage path, neither holding reader state.
|
|
|
|
|
type fetchedArtifact struct {
|
|
|
|
|
artifact artifacts.Artifact
|
|
|
|
|
storagePath string
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-03 05:06:44 -04:00
|
|
|
// runScan generates a signed fetch URL for the just-staged artifact and
|
|
|
|
|
// asks the configured scanners for a verdict. Returns a wrapped
|
|
|
|
|
// ErrArtifactBlocked if any scanner blocks, or a scan-infrastructure error.
|
|
|
|
|
//
|
|
|
|
|
// The scan call runs on a context detached from ctx's cancellation
|
|
|
|
|
// (context.WithoutCancel): ctx is the original client request's context, and
|
|
|
|
|
// a client disconnecting mid-scan must not be indistinguishable from a real
|
|
|
|
|
// scanner verdict. Group.Scan still bounds the call with its own configured
|
|
|
|
|
// timeout, so a detached context cannot hang forever.
|
|
|
|
|
func (p *Proxy) runScan(ctx context.Context, ecosystem, name, version, filename, purlStr, storagePath string, size int64, contentType string) error {
|
|
|
|
|
fetchURL := p.scanFetchURL(storagePath, p.Scanners.Timeout())
|
|
|
|
|
result := p.Scanners.Scan(context.WithoutCancel(ctx), scanner.Request{
|
|
|
|
|
Ecosystem: ecosystem,
|
|
|
|
|
Name: name,
|
|
|
|
|
Version: version,
|
|
|
|
|
Filename: filename,
|
|
|
|
|
PURL: purlStr,
|
|
|
|
|
FetchURL: fetchURL,
|
|
|
|
|
Size: size,
|
|
|
|
|
ContentType: contentType,
|
|
|
|
|
})
|
|
|
|
|
if !result.Allowed {
|
|
|
|
|
p.Logger.Warn("artifact blocked by security scan",
|
|
|
|
|
"ecosystem", ecosystem, "name", name, "version", version, "filename", filename,
|
|
|
|
|
"scanner", result.ScannerName, "reason", result.Reason, "infra_error", result.InfraError)
|
|
|
|
|
reason := result.Reason
|
|
|
|
|
if result.InfraError {
|
|
|
|
|
// result.Reason may contain raw scanner-infrastructure details
|
|
|
|
|
// (internal hostnames, ports, connection errors) that must not
|
|
|
|
|
// reach an untrusted client via the 403 response body.
|
|
|
|
|
reason = "scan could not be completed"
|
|
|
|
|
}
|
|
|
|
|
return fmt.Errorf("%w: %s", ErrArtifactBlocked, reason)
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-14 15:36:26 +02:00
|
|
|
func (p *Proxy) updateCacheDB(ecosystem, name, pkgPURL, upstreamURL, storagePath string, artifact artifacts.Artifact, declared string) error {
|
2026-01-20 21:52:44 +00:00
|
|
|
now := time.Now()
|
|
|
|
|
|
|
|
|
|
// Upsert package
|
|
|
|
|
pkg := &database.Package{
|
2026-04-18 07:43:22 -04:00
|
|
|
PURL: pkgPURL,
|
|
|
|
|
Ecosystem: ecosystem,
|
|
|
|
|
Name: name,
|
2026-01-29 16:44:01 +00:00
|
|
|
RegistryURL: sql.NullString{String: upstreamURL, Valid: true},
|
2026-04-18 07:43:22 -04:00
|
|
|
EnrichedAt: sql.NullTime{Time: now, Valid: true},
|
2026-01-20 21:52:44 +00:00
|
|
|
}
|
2026-01-29 16:44:01 +00:00
|
|
|
if err := p.DB.UpsertPackage(pkg); err != nil {
|
2026-01-20 21:52:44 +00:00
|
|
|
return fmt.Errorf("upserting package: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Upsert version
|
|
|
|
|
ver := &database.Version{
|
2026-09-04 10:58:22 +01:00
|
|
|
PURL: artifact.PURL,
|
2026-01-29 16:44:01 +00:00
|
|
|
PackagePURL: pkgPURL,
|
2026-10-14 15:36:26 +02:00
|
|
|
Integrity: sql.NullString{String: declared, Valid: declared != ""},
|
2026-01-29 16:44:01 +00:00
|
|
|
EnrichedAt: sql.NullTime{Time: now, Valid: true},
|
2026-01-20 21:52:44 +00:00
|
|
|
}
|
2026-01-29 16:44:01 +00:00
|
|
|
if err := p.DB.UpsertVersion(ver); err != nil {
|
2026-01-20 21:52:44 +00:00
|
|
|
return fmt.Errorf("upserting version: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Upsert artifact
|
|
|
|
|
art := &database.Artifact{
|
2026-09-04 10:58:22 +01:00
|
|
|
VersionPURL: artifact.PURL,
|
|
|
|
|
Filename: artifact.Filename,
|
2026-01-20 21:52:44 +00:00
|
|
|
UpstreamURL: upstreamURL,
|
|
|
|
|
StoragePath: sql.NullString{String: storagePath, Valid: true},
|
2026-09-04 10:58:22 +01:00
|
|
|
ContentHash: sql.NullString{String: artifact.Digest.Encoded(), Valid: true},
|
|
|
|
|
Size: sql.NullInt64{Int64: artifact.Size, Valid: true},
|
|
|
|
|
ContentType: sql.NullString{String: artifact.MediaType, Valid: true},
|
2026-01-20 21:52:44 +00:00
|
|
|
FetchedAt: sql.NullTime{Time: now, Valid: true},
|
|
|
|
|
}
|
2026-01-29 16:44:01 +00:00
|
|
|
if err := p.DB.UpsertArtifact(art); err != nil {
|
2026-01-20 21:52:44 +00:00
|
|
|
return fmt.Errorf("upserting artifact: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ServeArtifact writes a CacheResult to an HTTP response.
|
|
|
|
|
func ServeArtifact(w http.ResponseWriter, result *CacheResult) {
|
2026-08-13 07:35:07 +01:00
|
|
|
serveArtifact(w, http.MethodGet, result)
|
|
|
|
|
}
|
|
|
|
|
|
Support HTTP byte-range requests for local file cached artifact downloads (#404)
* feat(storage): expose efficient seek for local files
Open file-backed artifacts with os.Open so local readers support
efficient seeking. Leave cloud storage readers unchanged.
Keep the legacy sidecar mapping test focused on fileblob's reader:
Blob.Open now reads local files directly and no longer consults the
sidecar. Continue checking sidecar cleanup and post-store reads.
* feat(handler): preserve efficient seek through integrity checks
Keep the cache result's single Reader field and preserve io.Seeker
when the underlying storage reader supports efficient seeking.
Sequential reads continue through whole-object integrity verification.
A successful seek switches subsequent reads to the source for range
responses, where full-object verification is not possible.
Test both modes and verify the source is closed once.
* feat(handler): add request-aware byte range responses
Add a request-aware artifact serving helper for bounded, open-ended,
and suffix byte ranges, including If-Range handling, 206 responses,
and 416 responses for valid unsatisfiable ranges.
Ignore malformed and multi-range requests by serving the full artifact.
Advertise byte-range support only for seekable readers, preserving HEAD,
redirect, and non-seekable behavior. Clarify that net/http recovers
ErrAbortHandler per request and keeps the server running.
* feat(handler): enable byte ranges across artifact downloads
Forward each artifact request to the shared range-aware response helper,
including cached generic release assets and OCI blobs. This lets the
handler honor ranges only when its reader supports efficient seeking,
without changing direct-storage redirect behavior.
Add warm-cache endpoint tests that assert partial response headers and
bytes without an upstream refetch. Document supported range behavior,
unsupported reader fallback, and the integrity limitation of partial reads.
2026-10-04 09:48:41 -07:00
|
|
|
// ServeArtifactRequest writes a CacheResult to an HTTP response using request
|
|
|
|
|
// headers such as Range and If-Range.
|
|
|
|
|
func ServeArtifactRequest(w http.ResponseWriter, r *http.Request, result *CacheResult) {
|
|
|
|
|
if r == nil {
|
|
|
|
|
ServeArtifact(w, result)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
serveArtifactResponse(w, r.Method, r, result)
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-13 07:35:07 +01:00
|
|
|
func serveArtifact(w http.ResponseWriter, method string, result *CacheResult) {
|
Support HTTP byte-range requests for local file cached artifact downloads (#404)
* feat(storage): expose efficient seek for local files
Open file-backed artifacts with os.Open so local readers support
efficient seeking. Leave cloud storage readers unchanged.
Keep the legacy sidecar mapping test focused on fileblob's reader:
Blob.Open now reads local files directly and no longer consults the
sidecar. Continue checking sidecar cleanup and post-store reads.
* feat(handler): preserve efficient seek through integrity checks
Keep the cache result's single Reader field and preserve io.Seeker
when the underlying storage reader supports efficient seeking.
Sequential reads continue through whole-object integrity verification.
A successful seek switches subsequent reads to the source for range
responses, where full-object verification is not possible.
Test both modes and verify the source is closed once.
* feat(handler): add request-aware byte range responses
Add a request-aware artifact serving helper for bounded, open-ended,
and suffix byte ranges, including If-Range handling, 206 responses,
and 416 responses for valid unsatisfiable ranges.
Ignore malformed and multi-range requests by serving the full artifact.
Advertise byte-range support only for seekable readers, preserving HEAD,
redirect, and non-seekable behavior. Clarify that net/http recovers
ErrAbortHandler per request and keeps the server running.
* feat(handler): enable byte ranges across artifact downloads
Forward each artifact request to the shared range-aware response helper,
including cached generic release assets and OCI blobs. This lets the
handler honor ranges only when its reader supports efficient seeking,
without changing direct-storage redirect behavior.
Add warm-cache endpoint tests that assert partial response headers and
bytes without an upstream refetch. Document supported range behavior,
unsupported reader fallback, and the integrity limitation of partial reads.
2026-10-04 09:48:41 -07:00
|
|
|
serveArtifactResponse(w, method, nil, result)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func serveArtifactResponse(w http.ResponseWriter, method string, request *http.Request, result *CacheResult) {
|
2026-09-04 10:58:22 +01:00
|
|
|
contentHash := ""
|
|
|
|
|
if result.Artifact.Digest != "" {
|
|
|
|
|
contentHash = result.Artifact.Digest.Encoded()
|
|
|
|
|
}
|
2026-04-27 12:04:38 +01:00
|
|
|
if result.RedirectURL != "" {
|
2026-09-04 10:58:22 +01:00
|
|
|
if contentHash != "" {
|
|
|
|
|
w.Header().Set(headerETag, `"`+contentHash+`"`)
|
2026-04-27 12:04:38 +01:00
|
|
|
}
|
|
|
|
|
w.Header().Set("Location", result.RedirectURL)
|
|
|
|
|
w.WriteHeader(http.StatusFound)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-13 07:35:07 +01:00
|
|
|
if result.Reader != nil {
|
|
|
|
|
defer func() { _ = result.Reader.Close() }()
|
|
|
|
|
}
|
2026-01-20 21:52:44 +00:00
|
|
|
|
2026-09-04 10:58:22 +01:00
|
|
|
if result.Artifact.MediaType != "" {
|
|
|
|
|
w.Header().Set(headerContentType, result.Artifact.MediaType)
|
2026-01-20 21:52:44 +00:00
|
|
|
}
|
2026-09-04 10:58:22 +01:00
|
|
|
if contentHash != "" {
|
|
|
|
|
w.Header().Set(headerETag, `"`+contentHash+`"`)
|
2026-01-20 21:52:44 +00:00
|
|
|
}
|
|
|
|
|
|
Support HTTP byte-range requests for local file cached artifact downloads (#404)
* feat(storage): expose efficient seek for local files
Open file-backed artifacts with os.Open so local readers support
efficient seeking. Leave cloud storage readers unchanged.
Keep the legacy sidecar mapping test focused on fileblob's reader:
Blob.Open now reads local files directly and no longer consults the
sidecar. Continue checking sidecar cleanup and post-store reads.
* feat(handler): preserve efficient seek through integrity checks
Keep the cache result's single Reader field and preserve io.Seeker
when the underlying storage reader supports efficient seeking.
Sequential reads continue through whole-object integrity verification.
A successful seek switches subsequent reads to the source for range
responses, where full-object verification is not possible.
Test both modes and verify the source is closed once.
* feat(handler): add request-aware byte range responses
Add a request-aware artifact serving helper for bounded, open-ended,
and suffix byte ranges, including If-Range handling, 206 responses,
and 416 responses for valid unsatisfiable ranges.
Ignore malformed and multi-range requests by serving the full artifact.
Advertise byte-range support only for seekable readers, preserving HEAD,
redirect, and non-seekable behavior. Clarify that net/http recovers
ErrAbortHandler per request and keeps the server running.
* feat(handler): enable byte ranges across artifact downloads
Forward each artifact request to the shared range-aware response helper,
including cached generic release assets and OCI blobs. This lets the
handler honor ranges only when its reader supports efficient seeking,
without changing direct-storage redirect behavior.
Add warm-cache endpoint tests that assert partial response headers and
bytes without an upstream refetch. Document supported range behavior,
unsupported reader fallback, and the integrity limitation of partial reads.
2026-10-04 09:48:41 -07:00
|
|
|
var seeker io.Seeker
|
|
|
|
|
if result.Reader != nil {
|
|
|
|
|
seeker, _ = result.Reader.(io.Seeker)
|
|
|
|
|
}
|
|
|
|
|
if seeker != nil && result.Artifact.Size >= 0 {
|
|
|
|
|
w.Header().Set("Accept-Ranges", "bytes")
|
|
|
|
|
if request != nil && serveArtifactRange(w, request, result, seeker) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if result.Artifact.Size > 0 || (method == http.MethodHead && result.Artifact.Size == 0) {
|
|
|
|
|
w.Header().Set(headerContentLength, strconv.FormatInt(result.Artifact.Size, 10))
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-20 21:52:44 +00:00
|
|
|
w.WriteHeader(http.StatusOK)
|
2026-08-13 07:35:07 +01:00
|
|
|
if method != http.MethodHead && result.Reader != nil {
|
Support HTTP byte-range requests for local file cached artifact downloads (#404)
* feat(storage): expose efficient seek for local files
Open file-backed artifacts with os.Open so local readers support
efficient seeking. Leave cloud storage readers unchanged.
Keep the legacy sidecar mapping test focused on fileblob's reader:
Blob.Open now reads local files directly and no longer consults the
sidecar. Continue checking sidecar cleanup and post-store reads.
* feat(handler): preserve efficient seek through integrity checks
Keep the cache result's single Reader field and preserve io.Seeker
when the underlying storage reader supports efficient seeking.
Sequential reads continue through whole-object integrity verification.
A successful seek switches subsequent reads to the source for range
responses, where full-object verification is not possible.
Test both modes and verify the source is closed once.
* feat(handler): add request-aware byte range responses
Add a request-aware artifact serving helper for bounded, open-ended,
and suffix byte ranges, including If-Range handling, 206 responses,
and 416 responses for valid unsatisfiable ranges.
Ignore malformed and multi-range requests by serving the full artifact.
Advertise byte-range support only for seekable readers, preserving HEAD,
redirect, and non-seekable behavior. Clarify that net/http recovers
ErrAbortHandler per request and keeps the server running.
* feat(handler): enable byte ranges across artifact downloads
Forward each artifact request to the shared range-aware response helper,
including cached generic release assets and OCI blobs. This lets the
handler honor ranges only when its reader supports efficient seeking,
without changing direct-storage redirect behavior.
Add warm-cache endpoint tests that assert partial response headers and
bytes without an upstream refetch. Document supported range behavior,
unsupported reader fallback, and the integrity limitation of partial reads.
2026-10-04 09:48:41 -07:00
|
|
|
copyArtifactBody(w, result.Reader, result.Artifact.Size, false)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type parsedByteRange struct {
|
|
|
|
|
start int64
|
|
|
|
|
end int64
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func serveArtifactRange(w http.ResponseWriter, request *http.Request, result *CacheResult, seeker io.Seeker) bool {
|
|
|
|
|
if request.Method != http.MethodGet {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
ranges := request.Header.Values("Range")
|
|
|
|
|
if len(ranges) != 1 || !ifRangeMatches(request, w.Header().Get(headerETag)) {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
byteRange, valid, satisfiable := parseByteRange(ranges[0], result.Artifact.Size)
|
|
|
|
|
if !valid {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
if !satisfiable {
|
|
|
|
|
w.Header().Set("Content-Range", fmt.Sprintf("bytes */%d", result.Artifact.Size))
|
|
|
|
|
w.Header().Set(headerContentLength, "0")
|
|
|
|
|
w.WriteHeader(http.StatusRequestedRangeNotSatisfiable)
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if _, err := seeker.Seek(byteRange.start, io.SeekStart); err != nil {
|
|
|
|
|
w.Header().Del(headerContentType)
|
|
|
|
|
w.Header().Del(headerContentLength)
|
|
|
|
|
w.Header().Del(headerETag)
|
|
|
|
|
w.Header().Del("Accept-Ranges")
|
|
|
|
|
http.Error(w, "failed to seek cached artifact", http.StatusInternalServerError)
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
length := byteRange.end - byteRange.start + 1
|
|
|
|
|
w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", byteRange.start, byteRange.end, result.Artifact.Size))
|
|
|
|
|
w.Header().Set(headerContentLength, strconv.FormatInt(length, 10))
|
|
|
|
|
w.WriteHeader(http.StatusPartialContent)
|
|
|
|
|
copyArtifactBody(w, result.Reader, length, true)
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func ifRangeMatches(request *http.Request, etag string) bool {
|
|
|
|
|
values := request.Header.Values("If-Range")
|
|
|
|
|
if len(values) == 0 {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
return len(values) == 1 && etag != "" && strings.TrimSpace(values[0]) == etag
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func parseByteRange(value string, size int64) (parsedByteRange, bool, bool) {
|
|
|
|
|
unit, spec, ok := strings.Cut(strings.TrimSpace(value), "=")
|
|
|
|
|
if !ok || !strings.EqualFold(strings.TrimSpace(unit), "bytes") {
|
|
|
|
|
return parsedByteRange{}, false, false
|
|
|
|
|
}
|
|
|
|
|
spec = strings.TrimSpace(spec)
|
|
|
|
|
if spec == "" || strings.Contains(spec, ",") {
|
|
|
|
|
return parsedByteRange{}, false, false
|
|
|
|
|
}
|
|
|
|
|
first, last, ok := strings.Cut(spec, "-")
|
|
|
|
|
if !ok {
|
|
|
|
|
return parsedByteRange{}, false, false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if first == "" {
|
|
|
|
|
suffixLength, ok := parseRangeNumber(last)
|
|
|
|
|
if !ok {
|
|
|
|
|
return parsedByteRange{}, false, false
|
Add storage.cache_artifacts to serve artifacts without storing them (#387)
* Add storage.passthrough to serve artifacts without storing them
With storage.passthrough enabled the proxy streams every artifact from
upstream to the client and never writes it to storage or the cache
database. Metadata filtering, cooldown and the denylist work as before,
so the proxy can sit behind another cache (e.g. an Artifactory remote)
purely as a policy layer without holding a second copy of every package.
Artifacts whose digest is known up front (OCI, Swift, Helm) are verified
while streaming. Their responses are sent chunked and the connection is
aborted on a mismatch, so a client never receives a tampered artifact as
a complete response.
Passthrough is rejected together with scanning, direct_serve and
mirror_api, and the mirror command refuses to run with it, since all of
them depend on stored artifacts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Address review: abort truncated streams, guard every cache read
- serveArtifact aborts the response on any body read error, and when fewer
bytes than the declared size were written, not only on a digest mismatch.
A failed or short upstream body in passthrough mode no longer reaches the
client as a complete 200.
- Streamed upstream read failures are logged and counted as
stream_failed upstream errors before the response is aborted.
- checkCache reports a miss when artifact caching is off, so every cache
read path, including Swift archive HEAD requests, ignores entries stored
before the mode was enabled. Denylisted versions are still rejected.
- Rename storage.passthrough to storage.cache_artifacts (default true,
PROXY_STORAGE_CACHE_ARTIFACTS), which names what actually changes.
Tests cover an OCI blob shorter than its Content-Length and an npm tarball
whose chunked response ends without the final chunk, both served through
the HTTP handlers, plus a Swift HEAD request against a cached archive.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Rename Proxy.Passthrough to StreamArtifacts
Match the storage.cache_artifacts option: the field says what changes
(artifacts are streamed instead of stored) and its zero value keeps the
usual caching behaviour for every Proxy built without the server config,
such as the mirror command and tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 15:28:20 +07:00
|
|
|
}
|
Support HTTP byte-range requests for local file cached artifact downloads (#404)
* feat(storage): expose efficient seek for local files
Open file-backed artifacts with os.Open so local readers support
efficient seeking. Leave cloud storage readers unchanged.
Keep the legacy sidecar mapping test focused on fileblob's reader:
Blob.Open now reads local files directly and no longer consults the
sidecar. Continue checking sidecar cleanup and post-store reads.
* feat(handler): preserve efficient seek through integrity checks
Keep the cache result's single Reader field and preserve io.Seeker
when the underlying storage reader supports efficient seeking.
Sequential reads continue through whole-object integrity verification.
A successful seek switches subsequent reads to the source for range
responses, where full-object verification is not possible.
Test both modes and verify the source is closed once.
* feat(handler): add request-aware byte range responses
Add a request-aware artifact serving helper for bounded, open-ended,
and suffix byte ranges, including If-Range handling, 206 responses,
and 416 responses for valid unsatisfiable ranges.
Ignore malformed and multi-range requests by serving the full artifact.
Advertise byte-range support only for seekable readers, preserving HEAD,
redirect, and non-seekable behavior. Clarify that net/http recovers
ErrAbortHandler per request and keeps the server running.
* feat(handler): enable byte ranges across artifact downloads
Forward each artifact request to the shared range-aware response helper,
including cached generic release assets and OCI blobs. This lets the
handler honor ranges only when its reader supports efficient seeking,
without changing direct-storage redirect behavior.
Add warm-cache endpoint tests that assert partial response headers and
bytes without an upstream refetch. Document supported range behavior,
unsupported reader fallback, and the integrity limitation of partial reads.
2026-10-04 09:48:41 -07:00
|
|
|
if size == 0 || suffixLength == 0 {
|
|
|
|
|
return parsedByteRange{}, true, false
|
|
|
|
|
}
|
|
|
|
|
if suffixLength >= size {
|
|
|
|
|
return parsedByteRange{start: 0, end: size - 1}, true, true
|
|
|
|
|
}
|
|
|
|
|
return parsedByteRange{start: size - suffixLength, end: size - 1}, true, true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
start, ok := parseRangeNumber(first)
|
|
|
|
|
if !ok {
|
|
|
|
|
return parsedByteRange{}, false, false
|
|
|
|
|
}
|
|
|
|
|
if last == "" {
|
|
|
|
|
if size == 0 || start >= size {
|
|
|
|
|
return parsedByteRange{}, true, false
|
|
|
|
|
}
|
|
|
|
|
return parsedByteRange{start: start, end: size - 1}, true, true
|
|
|
|
|
}
|
|
|
|
|
end, ok := parseRangeNumber(last)
|
|
|
|
|
if !ok || start > end {
|
|
|
|
|
return parsedByteRange{}, false, false
|
|
|
|
|
}
|
|
|
|
|
if size == 0 || start >= size {
|
|
|
|
|
return parsedByteRange{}, true, false
|
|
|
|
|
}
|
|
|
|
|
if end >= size {
|
|
|
|
|
end = size - 1
|
|
|
|
|
}
|
|
|
|
|
return parsedByteRange{start: start, end: end}, true, true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func parseRangeNumber(value string) (int64, bool) {
|
|
|
|
|
if value == "" {
|
|
|
|
|
return 0, false
|
|
|
|
|
}
|
|
|
|
|
for _, digit := range value {
|
|
|
|
|
if digit < '0' || digit > '9' {
|
|
|
|
|
return 0, false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
number, err := strconv.ParseInt(value, 10, 64)
|
|
|
|
|
return number, err == nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func copyArtifactBody(w http.ResponseWriter, reader io.Reader, expectedSize int64, bounded bool) {
|
|
|
|
|
buffer := artifactCopyBufferPool.Get().(*[]byte)
|
|
|
|
|
defer artifactCopyBufferPool.Put(buffer)
|
|
|
|
|
|
|
|
|
|
source := reader
|
|
|
|
|
if bounded {
|
|
|
|
|
source = io.LimitReader(reader, expectedSize)
|
|
|
|
|
}
|
|
|
|
|
// Hide optional ReaderFrom methods so io.CopyBuffer uses the pooled buffer.
|
|
|
|
|
written, err := io.CopyBuffer(struct{ io.Writer }{w}, source, *buffer)
|
|
|
|
|
if err != nil || (bounded && written != expectedSize) || (!bounded && expectedSize > 0 && written != expectedSize) {
|
|
|
|
|
// Headers are already committed, so an error status is no longer
|
|
|
|
|
// possible. Aborting leaves the response unterminated so clients discard
|
|
|
|
|
// a truncated or unverified artifact.
|
|
|
|
|
// net/http recovers ErrAbortHandler for this request and keeps the server
|
|
|
|
|
// running; on HTTP/1.x it may close this connection as well.
|
|
|
|
|
panic(http.ErrAbortHandler)
|
2026-08-13 07:35:07 +01:00
|
|
|
}
|
2026-01-20 21:52:44 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-18 10:59:29 +00:00
|
|
|
// ProxyUpstream forwards a request to an upstream URL without caching.
|
|
|
|
|
// It copies the request, forwards specified headers, and streams the response back.
|
2026-09-26 15:44:28 +05:30
|
|
|
// forwardHeaders controls the request headers sent upstream. End-to-end response
|
|
|
|
|
// headers and trailers are relayed independently of that list.
|
2026-03-18 10:59:29 +00:00
|
|
|
func (p *Proxy) ProxyUpstream(w http.ResponseWriter, r *http.Request, upstreamURL string, forwardHeaders []string) {
|
|
|
|
|
p.Logger.Debug("proxying to upstream", "url", upstreamURL)
|
|
|
|
|
|
|
|
|
|
req, err := http.NewRequestWithContext(r.Context(), r.Method, upstreamURL, nil)
|
|
|
|
|
if err != nil {
|
|
|
|
|
http.Error(w, "failed to create request", http.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Copy request headers that affect content negotiation / caching
|
|
|
|
|
for _, header := range forwardHeaders {
|
|
|
|
|
if v := r.Header.Get(header); v != "" {
|
|
|
|
|
req.Header.Set(header, v)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
resp, err := p.HTTPClient.Do(req)
|
|
|
|
|
if err != nil {
|
2026-10-14 12:51:43 +01:00
|
|
|
if writeUpstreamHeld(w, err) {
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-03-18 10:59:29 +00:00
|
|
|
p.Logger.Error("upstream request failed", "error", err)
|
|
|
|
|
http.Error(w, "upstream request failed", http.StatusBadGateway)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
|
|
2026-09-26 15:44:28 +05:30
|
|
|
p.relayResponse(w, r, resp, nil)
|
2026-03-18 10:59:29 +00:00
|
|
|
}
|
|
|
|
|
|
2026-09-26 15:44:28 +05:30
|
|
|
// ProxyFile forwards a file request, relaying end-to-end headers and trailers.
|
2026-03-19 21:06:02 +00:00
|
|
|
func (p *Proxy) ProxyFile(w http.ResponseWriter, r *http.Request, upstreamURL string) {
|
2026-03-18 10:59:29 +00:00
|
|
|
req, err := http.NewRequestWithContext(r.Context(), r.Method, upstreamURL, nil)
|
|
|
|
|
if err != nil {
|
|
|
|
|
http.Error(w, "failed to create request", http.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
resp, err := p.HTTPClient.Do(req)
|
|
|
|
|
if err != nil {
|
2026-10-14 12:51:43 +01:00
|
|
|
if writeUpstreamHeld(w, err) {
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-03-18 10:59:29 +00:00
|
|
|
http.Error(w, "failed to fetch from upstream", http.StatusBadGateway)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
|
|
2026-09-26 15:44:28 +05:30
|
|
|
p.relayResponse(w, r, resp, nil)
|
2026-03-18 10:59:29 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-19 21:06:02 +00:00
|
|
|
// JSONError writes a JSON error response.
|
|
|
|
|
func JSONError(w http.ResponseWriter, status int, message string) {
|
2026-09-02 13:22:02 +01:00
|
|
|
w.Header().Set(headerContentType, contentTypeJSON)
|
2026-03-19 21:06:02 +00:00
|
|
|
w.WriteHeader(status)
|
|
|
|
|
_, _ = fmt.Fprintf(w, `{"error":%q}`, message)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ErrUpstreamNotFound indicates the upstream returned 404.
|
2026-07-26 20:07:46 +02:00
|
|
|
var ErrUpstreamNotFound = fmt.Errorf("upstream: %w", fetch.ErrNotFound)
|
|
|
|
|
|
2026-10-10 09:57:43 +02:00
|
|
|
// upstreamNotFoundBodyLimit caps the part of an upstream 404 body kept in
|
|
|
|
|
// upstreamNotFoundError.
|
|
|
|
|
const upstreamNotFoundBodyLimit = 4 << 10
|
|
|
|
|
|
|
|
|
|
// upstreamNotFoundError is ErrUpstreamNotFound with the start of the
|
|
|
|
|
// upstream's 404 body, for handlers that relay the reason to clients.
|
|
|
|
|
type upstreamNotFoundError struct {
|
|
|
|
|
body []byte
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (e *upstreamNotFoundError) Error() string { return ErrUpstreamNotFound.Error() }
|
|
|
|
|
|
|
|
|
|
func (e *upstreamNotFoundError) Unwrap() error { return ErrUpstreamNotFound }
|
|
|
|
|
|
2026-09-03 05:06:44 -04:00
|
|
|
// ErrArtifactBlocked indicates a pre-cache security scan blocked the artifact.
|
|
|
|
|
var ErrArtifactBlocked = errors.New("artifact blocked by security scan")
|
|
|
|
|
|
2026-09-20 14:42:30 +05:30
|
|
|
// ErrVersionDenied indicates an operator explicitly denied this package version.
|
|
|
|
|
var ErrVersionDenied = errors.New("package version blocked by denylist")
|
|
|
|
|
|
2026-10-12 14:45:34 +02:00
|
|
|
// ErrVersionNotFound indicates the requested version is not available through
|
|
|
|
|
// the proxy, including when cooldown or other filtering removed it.
|
|
|
|
|
var ErrVersionNotFound = errors.New("version not found")
|
|
|
|
|
|
|
|
|
|
// ErrPackageNotFound indicates the requested package metadata is not available
|
|
|
|
|
// through the proxy or upstream registry.
|
|
|
|
|
var ErrPackageNotFound = errors.New("package not found")
|
|
|
|
|
|
2026-07-26 20:07:46 +02:00
|
|
|
// serveArtifactError writes response for a failed fetch:
|
2026-09-20 14:42:30 +05:30
|
|
|
// 404 when upstream reports artifact missing, 403 when a security scan or
|
|
|
|
|
// denylist blocked the artifact, 502 otherwise.
|
2026-07-26 20:07:46 +02:00
|
|
|
func (p *Proxy) serveArtifactError(w http.ResponseWriter, err error, clientMsg string) {
|
|
|
|
|
if errors.Is(err, ErrUpstreamNotFound) {
|
|
|
|
|
http.Error(w, "not found", http.StatusNotFound)
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-09-20 14:42:30 +05:30
|
|
|
if errors.Is(err, ErrArtifactBlocked) || errors.Is(err, ErrVersionDenied) {
|
2026-09-03 05:06:44 -04:00
|
|
|
JSONError(w, http.StatusForbidden, err.Error())
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-10-13 15:16:55 +02:00
|
|
|
if errors.Is(err, ErrHostedArtifactMissing) {
|
|
|
|
|
http.Error(w, "not found", http.StatusNotFound)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if errors.Is(err, ErrHostedArtifactUnavailable) {
|
|
|
|
|
p.Logger.Error("failed to read pushed artifact", "error", err)
|
|
|
|
|
http.Error(w, "pushed package is temporarily unavailable", http.StatusServiceUnavailable)
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-10-14 12:51:43 +01:00
|
|
|
if writeUpstreamHeld(w, err) {
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-07-26 20:07:46 +02:00
|
|
|
p.Logger.Error("failed to get artifact", "error", err)
|
|
|
|
|
http.Error(w, clientMsg, http.StatusBadGateway)
|
|
|
|
|
}
|
2026-03-19 21:06:02 +00:00
|
|
|
|
|
|
|
|
// errStale304 is returned when upstream sends 304 but the cached file is missing.
|
|
|
|
|
var errStale304 = fmt.Errorf("upstream returned 304 but cached file is missing")
|
|
|
|
|
|
|
|
|
|
// metadataStoragePath builds a storage path for cached metadata.
|
|
|
|
|
func metadataStoragePath(ecosystem, cacheKey string) string {
|
|
|
|
|
return "_metadata/" + ecosystem + "/" + cacheKey + "/metadata"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// FetchOrCacheMetadata fetches metadata from upstream with caching.
|
|
|
|
|
// On success it returns the raw response bytes and content type.
|
|
|
|
|
// If upstream fails and a cached copy exists, the cached version is returned.
|
|
|
|
|
// cacheKey is typically the package name but can include subpath components.
|
|
|
|
|
// Optional acceptHeaders specify the Accept header(s) to send; defaults to application/json.
|
|
|
|
|
func (p *Proxy) FetchOrCacheMetadata(ctx context.Context, ecosystem, cacheKey, upstreamURL string, acceptHeaders ...string) ([]byte, string, error) {
|
2026-09-16 12:57:44 +05:30
|
|
|
body, contentType, _, err := p.fetchOrCacheMetadata(ctx, ecosystem, cacheKey, upstreamURL, "", nil, acceptHeaders...)
|
fix(handler): let the ProxyCached path request a per-call Accept-Encoding (#324)
* fix(handler): fetch conda repodata gzip-compressed on both hops
#304 made the ProxyCached path request Accept-Encoding: identity so the
metadata cache stores upstream bytes verbatim. That is required for the
signed / hash-pinned index ecosystems, but conda's repodata.json is large
plain JSON: linux-64 repodata.json is ~441 MB uncompressed (over the
metadata_max_size cap, so it 502s today) versus ~34 MB gzip.
Replace the ProxyCached path's verbatim bool with an explicit
acceptEncoding string ('' = leave unset / transparent, 'identity', or
'gzip'), reusing #304's existing store-and-replay of Content-Encoding
unchanged. ProxyCached keeps its exported signature and continues to send
identity, so the nine other ecosystems and helm/maven are untouched; only
conda's repodata.json / current_repodata.json now request gzip. Setting
Accept-Encoding explicitly disables Go's transparent decompression, so the
compressed bytes and the Content-Encoding: gzip header are cached and
replayed exactly as identity bytes are. conda, mamba and pixi solicit and
decode gzip on .json URLs; repodata.json.bz2 stays identity.
Fixes #305
* fix(handler): pass metadata content-encoding with the body it describes
The adversarial review of the conda gzip route found a reachable
regression: writeMetadataCachedResponse took Content-Encoding from a
fresh cache-row read while cacheMetadataBlob skips the row write when
Storage.Store fails. Under identity that was benign (the body was plain
anyway), but on the new gzip route a disk-full or object-store outage
served raw gzip bytes as Content-Type: application/json with no
Content-Encoding and HTTP 200 -- conda, mamba and pixi fail to parse
them, with no HTTP signal and only a Warn log, on every request until a
cache write succeeds.
fetchOrCacheMetadata now returns the encoding of the body it hands back
(the upstream value on a fetch, the stored row's value on a TTL hit or
stale fallback) and proxyCachedWithEncoding passes it to
writeMetadataCachedResponse, so the header always describes the bytes
actually written. cachedMeta drops its now-unused content_encoding
field. helm and maven pass "" -- both fetch transparently, so their
stored encoding was always empty and behaviour is unchanged.
Also fixes a vacuous assertion in the new conda test: the upstream
request counter incremented behind the availability gate, so the
cached-replay block could never observe a refetch.
* fix(handler): pin the stale-fallback content-encoding and drop a dead guard
Follow-ups from the adversarial review of the #305 branch, limited to
code this branch introduced:
- proxyMetadataStream is only ever reached with an explicit
Accept-Encoding (ProxyCached passes identity, conda passes gzip or
identity), so the guard around the header set was unreachable; replace
it with the plain one-token substitution of the former literal, which
is the smallest change from main.
- The stale-fallback return of fetchOrCacheMetadata (encoding taken from
the cache row) was the one #305 return site no test pinned: replacing
it with an empty encoding survived the whole suite. Add a conda test
that expires the entry, fails the upstream, and asserts the stored
gzip blob is served with Content-Encoding: gzip.
Not changed, by scope: cacheMetadataBlob still discards the
UpsertMetadataCache error (pre-existing on main). If Storage.Store
succeeds and the row write fails, a later stale fallback or TTL hit can
serve the gzip blob with the row's stale encoding; that needs a DB write
failure plus a second event and is tracked separately.
* fix(handler): restore the pre-existing cachedMeta content-encoding field
The third adversarial review classified deleting cachedMeta.contentEncoding
and its lookupCachedMeta populate as elective: neither line was created by
this branch nor forced by the fix (writeMetadataCachedResponse now reads
the encoding from its parameter and ignores the row value). Under the rule
that pre-existing code this branch did not have to touch stays untouched,
restore both as they are on main. No behaviour change.
Residuals the review documented, unchanged by scope (both share one root
cause: the encoding lives in the cache row and the bytes in the blob, and
neither is written or read atomically):
- cacheMetadataBlob discards the UpsertMetadataCache error, so after a
successful gzip Store and a failed row write a later stale fallback or
TTL hit can serve the gzip blob with the row's stale encoding.
- During the one-time identity->gzip rollout, a request that read a
pre-branch identity row, lost the upstream race to a request that stored
the gzip blob, and then failed upstream serves the gzip bytes with no
Content-Encoding for that one response; later requests self-heal.
- helm and maven now pass an empty encoding; on main a spec-violating
upstream that answered a transparent gzip request with an encoding Go
does not decode (e.g. br) would have had that header replayed from the
row. Degenerate; documented rather than changed.
* fix(handler): keep conda's proxyCached and .bz2 route as on main
Threading acceptEncoding through CondaHandler.proxyCached changed the
form of two pieces of original code the fix did not need to touch: the
repodata.json.bz2 route (method value rewritten as a closure) and
proxyCached itself (new parameter, new call). Restore both exactly as on
main; ProxyCached still sends identity, so the .bz2 route is unchanged in
behaviour. handleRepodata's non-cooldown branch now derives the cache key
inline and calls proxyCachedWithEncoding with gzip directly, so the only
original conda.go line that changes is that one call.
* fix(handler): keep writeMetadataCachedResponse and its callers as on main
Adding a contentEncoding parameter to writeMetadataCachedResponse changed
a signature that predates #304 and dragged its two pre-#304 callers
(helm.go, maven.go) into the diff, even though #304 only ever added the
cm.contentEncoding block inside the function body.
Restore writeMetadataCachedResponse's doc and signature exactly as on
main and make it a delegate that passes an empty encoding to a new
unexported writeMetadataCachedResponseWithEncoding, which carries the
original body with #304's block reading the parameter instead of the
cache row. proxyCachedWithEncoding calls the sibling with the encoding
returned alongside the body. helm.go and maven.go drop out of the diff;
their behaviour is unchanged (both fetch transparently, so their stored
encoding was always empty). Same split pattern as ProxyCached ->
proxyCachedWithEncoding.
* fix(handler): move the conda gzip change to its own branch
The conda call site in handleRepodata predates #304 and #304 never
touched it, so under the rule that this PR only corrects code and
behaviour #304 introduced it does not belong here. Restore conda.go and
conda_test.go as on main; the conda change continues on a stacked branch
against its own issue.
Replace the conda-route tests with tests that exercise
proxyCachedWithEncoding directly, so this PR still pins its own plumbing:
gzip is requested and the compressed bytes plus Content-Encoding are
cached and replayed (cached and streaming paths), the header survives a
metadata cache write failure, and the stale fallback keeps the stored
encoding.
* fix(homebrew): fetch the JSON API gzip-compressed on both hops
Homebrew (#254) routes every API path through ProxyCached and so, since
#304, fetches formula.jws.json (~33 MB plain, ~5 MB gzip) uncompressed on
every refresh -- the case that motivated #305.
Request gzip for the JSON API via proxyCachedWithEncoding: brew fetches
every API download with curl --compressed and decodes Content-Encoding
itself, so the compressed bytes and header are cached and served as-is
and both hops stay compressed. The analytics endpoints are the one brew
consumer fetched without --compressed; they stay on identity.
* fix(handler): leave Accept-Encoding unset in proxyMetadataStream for an empty value
fetchUpstreamMetadata treats an empty acceptEncoding as 'do not set the
header'; proxyMetadataStream set it unconditionally, which would send an
empty Accept-Encoding line if a caller ever passed . Guard it the same
way so both paths agree. No caller passes today.
* fix(handler): keep the metadata row and blob from describing different bytes
Two ways the cache row could stop describing the stored blob once a
caller requests gzip, both raised by the review of #324:
- cacheMetadataBlob stored the blob and then discarded the
UpsertMetadataCache error. After a successful gzip store and a failed
row write, a later TTL hit or stale fallback served the gzip blob with
the previous row's encoding. On a row-write failure, log it and delete
the blob just written, so the next request refetches instead.
- fetchOrCacheMetadata read the row once up front and reused it for the
stale fallback. A request that read an identity row, lost the upstream
race to a request that stored the gzip blob, and then failed upstream
labelled the new blob with the old row. Re-read the row before falling
back so the encoding matches the blob as it is now.
Both only become harmful with an encoding change, which this branch
introduces; the pre-existing validator-from-row read is tracked
separately.
* Drop unused cachedMeta.contentEncoding and fix stale doc reference
The field was added by #304 and its only reader is replaced in this
branch by the encoding parameter passed alongside the body. The
proxyCachedWithEncoding comment named conda repodata, which was moved
out of this branch in 5991d95; Homebrew is the caller that ships here.
---------
Co-authored-by: Andrew Nesbitt <andrewnez@gmail.com>
2026-09-15 20:50:44 +02:00
|
|
|
return body, contentType, err
|
fix(handler): preserve upstream content-encoding for cached metadata (#304)
* fix(handler): preserve upstream content-encoding for cached metadata
Signed and hash-pinned index files (debian Release/Packages.gz, rpm
repomd.xml, helm index.yaml, conda repodata.json, apk APKINDEX.tar.gz)
were cached after Go's transport transparently decompressed any
Content-Encoding: gzip response, so the proxy served bytes that differ
from what the upstream signed and broke client verification.
Request the identity encoding on the shared metadata fetch so Go no
longer auto-decompresses, and persist the upstream Content-Encoding in
a new metadata_cache column so both the cached and offline responses
replay the exact bytes and header. The uncached streaming path now
forwards Content-Encoding too.
Fixes #300
* fix(handler): scope verbatim metadata fetch to the ProxyCached path
The first cut forced Accept-Encoding: identity in the shared
fetchUpstreamMetadata, which an adversarial review showed both missed
the bug and regressed unrelated ecosystems:
- proxyMetadataStream (the default path, since cache_metadata is off)
never forced identity, so a client sending no Accept-Encoding still
triggered Go's transparent gzip decompression and served altered bytes
for apk/debian/rpm/conda indexes.
- Direct FetchOrCacheMetadata callers that parse or rewrite the body
(npm, pypi, cargo, composer, pub, nuget, swift, maven, helm) were
forced to identity too, losing wire compression and 502-ing against
upstreams that ignore identity and gzip anyway (helm rewriteIndex).
- Rows cached before the fix kept serving decompressed bytes via ETag
304 revalidation.
Scope the verbatim behavior to the ProxyCached code path, which serves
upstream bytes through unchanged (apk, debian, rpm, go, hex, conda,
cran, gem, conan, julia). That path now requests identity on both the
cached fetch and the uncached stream branch and replays Content-Encoding;
direct callers keep transparent compression, matching main. Migration
008 clears etag/fetched_at so legacy rows refetch once with identity.
Tests now exercise the stream path with no client Accept-Encoding and
pin that direct callers are not forced to identity.
2026-09-03 10:58:58 +02:00
|
|
|
}
|
|
|
|
|
|
fix(handler): let the ProxyCached path request a per-call Accept-Encoding (#324)
* fix(handler): fetch conda repodata gzip-compressed on both hops
#304 made the ProxyCached path request Accept-Encoding: identity so the
metadata cache stores upstream bytes verbatim. That is required for the
signed / hash-pinned index ecosystems, but conda's repodata.json is large
plain JSON: linux-64 repodata.json is ~441 MB uncompressed (over the
metadata_max_size cap, so it 502s today) versus ~34 MB gzip.
Replace the ProxyCached path's verbatim bool with an explicit
acceptEncoding string ('' = leave unset / transparent, 'identity', or
'gzip'), reusing #304's existing store-and-replay of Content-Encoding
unchanged. ProxyCached keeps its exported signature and continues to send
identity, so the nine other ecosystems and helm/maven are untouched; only
conda's repodata.json / current_repodata.json now request gzip. Setting
Accept-Encoding explicitly disables Go's transparent decompression, so the
compressed bytes and the Content-Encoding: gzip header are cached and
replayed exactly as identity bytes are. conda, mamba and pixi solicit and
decode gzip on .json URLs; repodata.json.bz2 stays identity.
Fixes #305
* fix(handler): pass metadata content-encoding with the body it describes
The adversarial review of the conda gzip route found a reachable
regression: writeMetadataCachedResponse took Content-Encoding from a
fresh cache-row read while cacheMetadataBlob skips the row write when
Storage.Store fails. Under identity that was benign (the body was plain
anyway), but on the new gzip route a disk-full or object-store outage
served raw gzip bytes as Content-Type: application/json with no
Content-Encoding and HTTP 200 -- conda, mamba and pixi fail to parse
them, with no HTTP signal and only a Warn log, on every request until a
cache write succeeds.
fetchOrCacheMetadata now returns the encoding of the body it hands back
(the upstream value on a fetch, the stored row's value on a TTL hit or
stale fallback) and proxyCachedWithEncoding passes it to
writeMetadataCachedResponse, so the header always describes the bytes
actually written. cachedMeta drops its now-unused content_encoding
field. helm and maven pass "" -- both fetch transparently, so their
stored encoding was always empty and behaviour is unchanged.
Also fixes a vacuous assertion in the new conda test: the upstream
request counter incremented behind the availability gate, so the
cached-replay block could never observe a refetch.
* fix(handler): pin the stale-fallback content-encoding and drop a dead guard
Follow-ups from the adversarial review of the #305 branch, limited to
code this branch introduced:
- proxyMetadataStream is only ever reached with an explicit
Accept-Encoding (ProxyCached passes identity, conda passes gzip or
identity), so the guard around the header set was unreachable; replace
it with the plain one-token substitution of the former literal, which
is the smallest change from main.
- The stale-fallback return of fetchOrCacheMetadata (encoding taken from
the cache row) was the one #305 return site no test pinned: replacing
it with an empty encoding survived the whole suite. Add a conda test
that expires the entry, fails the upstream, and asserts the stored
gzip blob is served with Content-Encoding: gzip.
Not changed, by scope: cacheMetadataBlob still discards the
UpsertMetadataCache error (pre-existing on main). If Storage.Store
succeeds and the row write fails, a later stale fallback or TTL hit can
serve the gzip blob with the row's stale encoding; that needs a DB write
failure plus a second event and is tracked separately.
* fix(handler): restore the pre-existing cachedMeta content-encoding field
The third adversarial review classified deleting cachedMeta.contentEncoding
and its lookupCachedMeta populate as elective: neither line was created by
this branch nor forced by the fix (writeMetadataCachedResponse now reads
the encoding from its parameter and ignores the row value). Under the rule
that pre-existing code this branch did not have to touch stays untouched,
restore both as they are on main. No behaviour change.
Residuals the review documented, unchanged by scope (both share one root
cause: the encoding lives in the cache row and the bytes in the blob, and
neither is written or read atomically):
- cacheMetadataBlob discards the UpsertMetadataCache error, so after a
successful gzip Store and a failed row write a later stale fallback or
TTL hit can serve the gzip blob with the row's stale encoding.
- During the one-time identity->gzip rollout, a request that read a
pre-branch identity row, lost the upstream race to a request that stored
the gzip blob, and then failed upstream serves the gzip bytes with no
Content-Encoding for that one response; later requests self-heal.
- helm and maven now pass an empty encoding; on main a spec-violating
upstream that answered a transparent gzip request with an encoding Go
does not decode (e.g. br) would have had that header replayed from the
row. Degenerate; documented rather than changed.
* fix(handler): keep conda's proxyCached and .bz2 route as on main
Threading acceptEncoding through CondaHandler.proxyCached changed the
form of two pieces of original code the fix did not need to touch: the
repodata.json.bz2 route (method value rewritten as a closure) and
proxyCached itself (new parameter, new call). Restore both exactly as on
main; ProxyCached still sends identity, so the .bz2 route is unchanged in
behaviour. handleRepodata's non-cooldown branch now derives the cache key
inline and calls proxyCachedWithEncoding with gzip directly, so the only
original conda.go line that changes is that one call.
* fix(handler): keep writeMetadataCachedResponse and its callers as on main
Adding a contentEncoding parameter to writeMetadataCachedResponse changed
a signature that predates #304 and dragged its two pre-#304 callers
(helm.go, maven.go) into the diff, even though #304 only ever added the
cm.contentEncoding block inside the function body.
Restore writeMetadataCachedResponse's doc and signature exactly as on
main and make it a delegate that passes an empty encoding to a new
unexported writeMetadataCachedResponseWithEncoding, which carries the
original body with #304's block reading the parameter instead of the
cache row. proxyCachedWithEncoding calls the sibling with the encoding
returned alongside the body. helm.go and maven.go drop out of the diff;
their behaviour is unchanged (both fetch transparently, so their stored
encoding was always empty). Same split pattern as ProxyCached ->
proxyCachedWithEncoding.
* fix(handler): move the conda gzip change to its own branch
The conda call site in handleRepodata predates #304 and #304 never
touched it, so under the rule that this PR only corrects code and
behaviour #304 introduced it does not belong here. Restore conda.go and
conda_test.go as on main; the conda change continues on a stacked branch
against its own issue.
Replace the conda-route tests with tests that exercise
proxyCachedWithEncoding directly, so this PR still pins its own plumbing:
gzip is requested and the compressed bytes plus Content-Encoding are
cached and replayed (cached and streaming paths), the header survives a
metadata cache write failure, and the stale fallback keeps the stored
encoding.
* fix(homebrew): fetch the JSON API gzip-compressed on both hops
Homebrew (#254) routes every API path through ProxyCached and so, since
#304, fetches formula.jws.json (~33 MB plain, ~5 MB gzip) uncompressed on
every refresh -- the case that motivated #305.
Request gzip for the JSON API via proxyCachedWithEncoding: brew fetches
every API download with curl --compressed and decodes Content-Encoding
itself, so the compressed bytes and header are cached and served as-is
and both hops stay compressed. The analytics endpoints are the one brew
consumer fetched without --compressed; they stay on identity.
* fix(handler): leave Accept-Encoding unset in proxyMetadataStream for an empty value
fetchUpstreamMetadata treats an empty acceptEncoding as 'do not set the
header'; proxyMetadataStream set it unconditionally, which would send an
empty Accept-Encoding line if a caller ever passed . Guard it the same
way so both paths agree. No caller passes today.
* fix(handler): keep the metadata row and blob from describing different bytes
Two ways the cache row could stop describing the stored blob once a
caller requests gzip, both raised by the review of #324:
- cacheMetadataBlob stored the blob and then discarded the
UpsertMetadataCache error. After a successful gzip store and a failed
row write, a later TTL hit or stale fallback served the gzip blob with
the previous row's encoding. On a row-write failure, log it and delete
the blob just written, so the next request refetches instead.
- fetchOrCacheMetadata read the row once up front and reused it for the
stale fallback. A request that read an identity row, lost the upstream
race to a request that stored the gzip blob, and then failed upstream
labelled the new blob with the old row. Re-read the row before falling
back so the encoding matches the blob as it is now.
Both only become harmful with an encoding change, which this branch
introduces; the pre-existing validator-from-row read is tracked
separately.
* Drop unused cachedMeta.contentEncoding and fix stale doc reference
The field was added by #304 and its only reader is replaced in this
branch by the encoding parameter passed alongside the body. The
proxyCachedWithEncoding comment named conda repodata, which was moved
out of this branch in 5991d95; Homebrew is the caller that ships here.
---------
Co-authored-by: Andrew Nesbitt <andrewnez@gmail.com>
2026-09-15 20:50:44 +02:00
|
|
|
// fetchOrCacheMetadata implements FetchOrCacheMetadata. acceptEncoding controls
|
|
|
|
|
// the upstream Accept-Encoding: an empty string leaves it unset so Go
|
|
|
|
|
// transparently decompresses (for direct callers that parse or rewrite the
|
|
|
|
|
// body); any non-empty value is sent verbatim, which disables Go's
|
|
|
|
|
// decompression so the wire bytes and their Content-Encoding are stored and
|
|
|
|
|
// replayed as sent. The ProxyCached path uses "identity" for signed indexes and
|
|
|
|
|
// "gzip" where both hops should stay compressed.
|
2026-09-16 12:57:44 +05:30
|
|
|
// validate, when supplied, runs before caching or serving a document. Validation
|
Coalesce concurrent metadata fetches (#400)
* Coalesce concurrent metadata fetches
Concurrent cache misses for one artifact already share a single upstream
fetch (#329), but metadata misses did not: every request for a package's
metadata went to the registry, even when the same document was already
being fetched. In CI that is the common case. Composer resolves versions
on every job, since MediaWiki core and its extensions commit no
composer.lock, and each package is one /p2/ document, so a burst of jobs
fetched each document once per job.
Metadata misses now follow the same pattern as artifacts: the first caller
fetches, the rest wait for its result, and the caller that takes the key
rechecks the cache first so a fetch that just finished is not repeated.
The key includes the Accept and Accept-Encoding headers, which change the
bytes upstream returns (npm's abbreviated and full documents), and whether
the caller validates the response.
One difference from artifacts: the shared fetch is detached from the first
caller's cancellation. Artifact fetches keep it for scanning and mirroring,
neither of which applies to metadata, and otherwise one job disconnecting
would fail every job waiting on the same document. runScan detaches the
same way, and the HTTP client's timeout still bounds the fetch.
This covers every ecosystem that goes through fetchOrCacheMetadata
(Composer, npm, PyPI, Maven, Cargo, Swift, Pub, Helm, NuGet), with
metadata caching on or off. Streamed metadata with caching off is not
coalesced.
On an 8 vCPU test instance with caching off, 10 concurrent clients
requesting the npm metadata of 1,263 packages went from 12,630 upstream
requests to 1,898. With cache_metadata on, 20 clients requesting 1,538
packages made exactly 1,538.
* Run each caller's validate on a shared metadata fetch
A caller that joined another's metadata fetch got the shared bytes
without running its own validate. NuGet's cooldown path uses validate to
decode the document for the request, so a joined download found no
publication date and was allowed, bypassing the cooldown.
coalesceMetadata now reports whether the caller joined, and
coalescedMetadataMiss runs that caller's validate on the shared bytes.
Validation before caching is unchanged: the first caller still runs it
inside the fetch.
TestNuGetCooldownConcurrentDownloads reproduces the bypass through
NuGetHandler.Routes() with two overlapping downloads of a version inside
its cooldown.
2026-10-04 09:50:45 -07:00
|
|
|
// failures follow the same stale-cache fallback path as upstream failures. It
|
|
|
|
|
// runs for every caller, including one that shares another caller's fetch, so
|
|
|
|
|
// it can also decode the document into request-local state; a caller that
|
|
|
|
|
// joined a fetch gets its own validation error directly. It must not modify the
|
|
|
|
|
// body, which joined callers share.
|
2026-09-16 12:57:44 +05:30
|
|
|
func (p *Proxy) fetchOrCacheMetadata(ctx context.Context, ecosystem, cacheKey, upstreamURL, acceptEncoding string, validate func([]byte) error, acceptHeaders ...string) ([]byte, string, string, error) {
|
2026-10-09 08:10:41 +02:00
|
|
|
accept := contentTypeJSON
|
|
|
|
|
if len(acceptHeaders) > 0 && acceptHeaders[0] != "" {
|
|
|
|
|
accept = acceptHeaders[0]
|
|
|
|
|
}
|
|
|
|
|
res := p.lookupMetadata(ctx, ecosystem, cacheKey, upstreamURL, accept, acceptEncoding, validate)
|
|
|
|
|
return res.body, res.contentType, res.contentEncoding, res.err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// fetchMetadataDocument is FetchOrCacheMetadata for callers that rewrite the
|
|
|
|
|
// document and need to know which one they got: the result carries its
|
2026-10-10 13:47:39 +02:00
|
|
|
// digest along with the body.
|
2026-10-09 08:10:41 +02:00
|
|
|
func (p *Proxy) fetchMetadataDocument(ctx context.Context, ecosystem, cacheKey, upstreamURL, accept string) metadataResult {
|
|
|
|
|
return p.lookupMetadata(ctx, ecosystem, cacheKey, upstreamURL, accept, "", nil)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// lookupMetadata implements fetchOrCacheMetadata and fetchMetadataDocument.
|
|
|
|
|
func (p *Proxy) lookupMetadata(ctx context.Context, ecosystem, cacheKey, upstreamURL, accept, acceptEncoding string, validate func([]byte) error) metadataResult {
|
2026-03-19 21:06:02 +00:00
|
|
|
if containsPathTraversal(cacheKey) {
|
2026-10-09 08:10:41 +02:00
|
|
|
return metadataResult{err: fmt.Errorf("invalid cache key: %q", cacheKey)}
|
2026-03-19 21:06:02 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-06 19:30:59 +01:00
|
|
|
// Serve from cache if within TTL (skip upstream entirely)
|
Coalesce concurrent metadata fetches (#400)
* Coalesce concurrent metadata fetches
Concurrent cache misses for one artifact already share a single upstream
fetch (#329), but metadata misses did not: every request for a package's
metadata went to the registry, even when the same document was already
being fetched. In CI that is the common case. Composer resolves versions
on every job, since MediaWiki core and its extensions commit no
composer.lock, and each package is one /p2/ document, so a burst of jobs
fetched each document once per job.
Metadata misses now follow the same pattern as artifacts: the first caller
fetches, the rest wait for its result, and the caller that takes the key
rechecks the cache first so a fetch that just finished is not repeated.
The key includes the Accept and Accept-Encoding headers, which change the
bytes upstream returns (npm's abbreviated and full documents), and whether
the caller validates the response.
One difference from artifacts: the shared fetch is detached from the first
caller's cancellation. Artifact fetches keep it for scanning and mirroring,
neither of which applies to metadata, and otherwise one job disconnecting
would fail every job waiting on the same document. runScan detaches the
same way, and the HTTP client's timeout still bounds the fetch.
This covers every ecosystem that goes through fetchOrCacheMetadata
(Composer, npm, PyPI, Maven, Cargo, Swift, Pub, Helm, NuGet), with
metadata caching on or off. Streamed metadata with caching off is not
coalesced.
On an 8 vCPU test instance with caching off, 10 concurrent clients
requesting the npm metadata of 1,263 packages went from 12,630 upstream
requests to 1,898. With cache_metadata on, 20 clients requesting 1,538
packages made exactly 1,538.
* Run each caller's validate on a shared metadata fetch
A caller that joined another's metadata fetch got the shared bytes
without running its own validate. NuGet's cooldown path uses validate to
decode the document for the request, so a joined download found no
publication date and was allowed, bypassing the cooldown.
coalesceMetadata now reports whether the caller joined, and
coalescedMetadataMiss runs that caller's validate on the shared bytes.
Validation before caching is unchanged: the first caller still runs it
inside the fetch.
TestNuGetCooldownConcurrentDownloads reproduces the bypass through
NuGetHandler.Routes() with two overlapping downloads of a version inside
its cooldown.
2026-10-04 09:50:45 -07:00
|
|
|
if _, hit := p.cachedMetadataState(ctx, ecosystem, cacheKey, validate); hit != nil {
|
|
|
|
|
metrics.RecordCacheHit(ecosystem)
|
2026-10-09 08:10:41 +02:00
|
|
|
return *hit
|
2026-04-06 19:30:59 +01:00
|
|
|
}
|
2026-08-16 18:01:59 +01:00
|
|
|
p.recordMetadataCacheMiss(ecosystem)
|
2026-04-06 19:30:59 +01:00
|
|
|
|
2026-10-09 08:10:41 +02:00
|
|
|
return p.coalescedMetadataMiss(ctx, ecosystem, cacheKey, upstreamURL, accept, acceptEncoding, validate)
|
Coalesce concurrent metadata fetches (#400)
* Coalesce concurrent metadata fetches
Concurrent cache misses for one artifact already share a single upstream
fetch (#329), but metadata misses did not: every request for a package's
metadata went to the registry, even when the same document was already
being fetched. In CI that is the common case. Composer resolves versions
on every job, since MediaWiki core and its extensions commit no
composer.lock, and each package is one /p2/ document, so a burst of jobs
fetched each document once per job.
Metadata misses now follow the same pattern as artifacts: the first caller
fetches, the rest wait for its result, and the caller that takes the key
rechecks the cache first so a fetch that just finished is not repeated.
The key includes the Accept and Accept-Encoding headers, which change the
bytes upstream returns (npm's abbreviated and full documents), and whether
the caller validates the response.
One difference from artifacts: the shared fetch is detached from the first
caller's cancellation. Artifact fetches keep it for scanning and mirroring,
neither of which applies to metadata, and otherwise one job disconnecting
would fail every job waiting on the same document. runScan detaches the
same way, and the HTTP client's timeout still bounds the fetch.
This covers every ecosystem that goes through fetchOrCacheMetadata
(Composer, npm, PyPI, Maven, Cargo, Swift, Pub, Helm, NuGet), with
metadata caching on or off. Streamed metadata with caching off is not
coalesced.
On an 8 vCPU test instance with caching off, 10 concurrent clients
requesting the npm metadata of 1,263 packages went from 12,630 upstream
requests to 1,898. With cache_metadata on, 20 clients requesting 1,538
packages made exactly 1,538.
* Run each caller's validate on a shared metadata fetch
A caller that joined another's metadata fetch got the shared bytes
without running its own validate. NuGet's cooldown path uses validate to
decode the document for the request, so a joined download found no
publication date and was allowed, bypassing the cooldown.
coalesceMetadata now reports whether the caller joined, and
coalescedMetadataMiss runs that caller's validate on the shared bytes.
Validation before caching is unchanged: the first caller still runs it
inside the fetch.
TestNuGetCooldownConcurrentDownloads reproduces the bypass through
NuGetHandler.Routes() with two overlapping downloads of a version inside
its cooldown.
2026-10-04 09:50:45 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// coalescedMetadataMiss handles a metadata cache miss, sharing one upstream
|
|
|
|
|
// fetch among concurrent callers with the same key.
|
|
|
|
|
func (p *Proxy) coalescedMetadataMiss(ctx context.Context, ecosystem, cacheKey, upstreamURL, accept, acceptEncoding string, validate func([]byte) error) metadataResult {
|
|
|
|
|
key := metadataCoalesceKey(ecosystem, cacheKey, upstreamURL, accept, acceptEncoding, validate != nil)
|
|
|
|
|
res, shared := p.coalesceMetadata(ctx, key, func(fetchCtx context.Context) metadataResult {
|
|
|
|
|
// The caller's lookup ran before it took the key, so a fetch that
|
|
|
|
|
// finished in between has already refreshed the row. Recheck it
|
|
|
|
|
// rather than fetching again, and revalidate against the row as it
|
|
|
|
|
// is now.
|
|
|
|
|
entry, hit := p.cachedMetadataState(fetchCtx, ecosystem, cacheKey, validate)
|
|
|
|
|
if hit != nil {
|
|
|
|
|
return *hit
|
|
|
|
|
}
|
|
|
|
|
return p.fetchMetadataFromUpstream(fetchCtx, ecosystem, cacheKey, upstreamURL, accept, acceptEncoding, validate, entry)
|
|
|
|
|
})
|
|
|
|
|
// The fetch ran the first caller's validate. A caller that joined it runs
|
|
|
|
|
// its own on the shared bytes, since validate may also decode them for it.
|
|
|
|
|
if shared && res.err == nil && validate != nil {
|
|
|
|
|
if err := validate(res.body); err != nil {
|
|
|
|
|
return metadataResult{err: err}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return res
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// cachedMetadataState reads the cache row for a metadata lookup. hit is set
|
|
|
|
|
// when the row is within TTL and its bytes are usable. Otherwise entry is the
|
|
|
|
|
// row to revalidate against with its ETag: nil when there is none, or when
|
|
|
|
|
// validate rejected the cached body, since revalidating an unusable body would
|
|
|
|
|
// keep it.
|
|
|
|
|
func (p *Proxy) cachedMetadataState(ctx context.Context, ecosystem, cacheKey string, validate func([]byte) error) (*database.MetadataCacheEntry, *metadataResult) {
|
|
|
|
|
if !p.CacheMetadata || p.DB == nil {
|
|
|
|
|
return nil, nil
|
|
|
|
|
}
|
|
|
|
|
entry, _ := p.DB.GetMetadataCache(ecosystem, cacheKey)
|
|
|
|
|
if entry != nil && p.MetadataTTL > 0 && entry.FetchedAt.Valid && time.Since(entry.FetchedAt.Time) < p.MetadataTTL {
|
|
|
|
|
data, ct, err := p.readCachedMetadata(ctx, entry, validate)
|
|
|
|
|
if err == nil {
|
2026-10-09 08:10:41 +02:00
|
|
|
hit := metadataResult{body: data, contentType: ct, contentEncoding: entry.ContentEncoding.String}.describedBy(entry)
|
|
|
|
|
return entry, &hit
|
Coalesce concurrent metadata fetches (#400)
* Coalesce concurrent metadata fetches
Concurrent cache misses for one artifact already share a single upstream
fetch (#329), but metadata misses did not: every request for a package's
metadata went to the registry, even when the same document was already
being fetched. In CI that is the common case. Composer resolves versions
on every job, since MediaWiki core and its extensions commit no
composer.lock, and each package is one /p2/ document, so a burst of jobs
fetched each document once per job.
Metadata misses now follow the same pattern as artifacts: the first caller
fetches, the rest wait for its result, and the caller that takes the key
rechecks the cache first so a fetch that just finished is not repeated.
The key includes the Accept and Accept-Encoding headers, which change the
bytes upstream returns (npm's abbreviated and full documents), and whether
the caller validates the response.
One difference from artifacts: the shared fetch is detached from the first
caller's cancellation. Artifact fetches keep it for scanning and mirroring,
neither of which applies to metadata, and otherwise one job disconnecting
would fail every job waiting on the same document. runScan detaches the
same way, and the HTTP client's timeout still bounds the fetch.
This covers every ecosystem that goes through fetchOrCacheMetadata
(Composer, npm, PyPI, Maven, Cargo, Swift, Pub, Helm, NuGet), with
metadata caching on or off. Streamed metadata with caching off is not
coalesced.
On an 8 vCPU test instance with caching off, 10 concurrent clients
requesting the npm metadata of 1,263 packages went from 12,630 upstream
requests to 1,898. With cache_metadata on, 20 clients requesting 1,538
packages made exactly 1,538.
* Run each caller's validate on a shared metadata fetch
A caller that joined another's metadata fetch got the shared bytes
without running its own validate. NuGet's cooldown path uses validate to
decode the document for the request, so a joined download found no
publication date and was allowed, bypassing the cooldown.
coalesceMetadata now reports whether the caller joined, and
coalescedMetadataMiss runs that caller's validate on the shared bytes.
Validation before caching is unchanged: the first caller still runs it
inside the fetch.
TestNuGetCooldownConcurrentDownloads reproduces the bypass through
NuGetHandler.Routes() with two overlapping downloads of a version inside
its cooldown.
2026-10-04 09:50:45 -07:00
|
|
|
}
|
|
|
|
|
if validate != nil {
|
|
|
|
|
return nil, nil
|
|
|
|
|
}
|
|
|
|
|
// Cache file missing/unreadable, fall through to upstream
|
|
|
|
|
}
|
|
|
|
|
return entry, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// fetchMetadataFromUpstream fetches metadata after a cache miss, caches it,
|
|
|
|
|
// and falls back to the cached copy if upstream fails. entry is the row to
|
|
|
|
|
// revalidate against, or nil.
|
|
|
|
|
func (p *Proxy) fetchMetadataFromUpstream(ctx context.Context, ecosystem, cacheKey, upstreamURL, accept, acceptEncoding string, validate func([]byte) error, entry *database.MetadataCacheEntry) metadataResult {
|
fix(handler): let the ProxyCached path request a per-call Accept-Encoding (#324)
* fix(handler): fetch conda repodata gzip-compressed on both hops
#304 made the ProxyCached path request Accept-Encoding: identity so the
metadata cache stores upstream bytes verbatim. That is required for the
signed / hash-pinned index ecosystems, but conda's repodata.json is large
plain JSON: linux-64 repodata.json is ~441 MB uncompressed (over the
metadata_max_size cap, so it 502s today) versus ~34 MB gzip.
Replace the ProxyCached path's verbatim bool with an explicit
acceptEncoding string ('' = leave unset / transparent, 'identity', or
'gzip'), reusing #304's existing store-and-replay of Content-Encoding
unchanged. ProxyCached keeps its exported signature and continues to send
identity, so the nine other ecosystems and helm/maven are untouched; only
conda's repodata.json / current_repodata.json now request gzip. Setting
Accept-Encoding explicitly disables Go's transparent decompression, so the
compressed bytes and the Content-Encoding: gzip header are cached and
replayed exactly as identity bytes are. conda, mamba and pixi solicit and
decode gzip on .json URLs; repodata.json.bz2 stays identity.
Fixes #305
* fix(handler): pass metadata content-encoding with the body it describes
The adversarial review of the conda gzip route found a reachable
regression: writeMetadataCachedResponse took Content-Encoding from a
fresh cache-row read while cacheMetadataBlob skips the row write when
Storage.Store fails. Under identity that was benign (the body was plain
anyway), but on the new gzip route a disk-full or object-store outage
served raw gzip bytes as Content-Type: application/json with no
Content-Encoding and HTTP 200 -- conda, mamba and pixi fail to parse
them, with no HTTP signal and only a Warn log, on every request until a
cache write succeeds.
fetchOrCacheMetadata now returns the encoding of the body it hands back
(the upstream value on a fetch, the stored row's value on a TTL hit or
stale fallback) and proxyCachedWithEncoding passes it to
writeMetadataCachedResponse, so the header always describes the bytes
actually written. cachedMeta drops its now-unused content_encoding
field. helm and maven pass "" -- both fetch transparently, so their
stored encoding was always empty and behaviour is unchanged.
Also fixes a vacuous assertion in the new conda test: the upstream
request counter incremented behind the availability gate, so the
cached-replay block could never observe a refetch.
* fix(handler): pin the stale-fallback content-encoding and drop a dead guard
Follow-ups from the adversarial review of the #305 branch, limited to
code this branch introduced:
- proxyMetadataStream is only ever reached with an explicit
Accept-Encoding (ProxyCached passes identity, conda passes gzip or
identity), so the guard around the header set was unreachable; replace
it with the plain one-token substitution of the former literal, which
is the smallest change from main.
- The stale-fallback return of fetchOrCacheMetadata (encoding taken from
the cache row) was the one #305 return site no test pinned: replacing
it with an empty encoding survived the whole suite. Add a conda test
that expires the entry, fails the upstream, and asserts the stored
gzip blob is served with Content-Encoding: gzip.
Not changed, by scope: cacheMetadataBlob still discards the
UpsertMetadataCache error (pre-existing on main). If Storage.Store
succeeds and the row write fails, a later stale fallback or TTL hit can
serve the gzip blob with the row's stale encoding; that needs a DB write
failure plus a second event and is tracked separately.
* fix(handler): restore the pre-existing cachedMeta content-encoding field
The third adversarial review classified deleting cachedMeta.contentEncoding
and its lookupCachedMeta populate as elective: neither line was created by
this branch nor forced by the fix (writeMetadataCachedResponse now reads
the encoding from its parameter and ignores the row value). Under the rule
that pre-existing code this branch did not have to touch stays untouched,
restore both as they are on main. No behaviour change.
Residuals the review documented, unchanged by scope (both share one root
cause: the encoding lives in the cache row and the bytes in the blob, and
neither is written or read atomically):
- cacheMetadataBlob discards the UpsertMetadataCache error, so after a
successful gzip Store and a failed row write a later stale fallback or
TTL hit can serve the gzip blob with the row's stale encoding.
- During the one-time identity->gzip rollout, a request that read a
pre-branch identity row, lost the upstream race to a request that stored
the gzip blob, and then failed upstream serves the gzip bytes with no
Content-Encoding for that one response; later requests self-heal.
- helm and maven now pass an empty encoding; on main a spec-violating
upstream that answered a transparent gzip request with an encoding Go
does not decode (e.g. br) would have had that header replayed from the
row. Degenerate; documented rather than changed.
* fix(handler): keep conda's proxyCached and .bz2 route as on main
Threading acceptEncoding through CondaHandler.proxyCached changed the
form of two pieces of original code the fix did not need to touch: the
repodata.json.bz2 route (method value rewritten as a closure) and
proxyCached itself (new parameter, new call). Restore both exactly as on
main; ProxyCached still sends identity, so the .bz2 route is unchanged in
behaviour. handleRepodata's non-cooldown branch now derives the cache key
inline and calls proxyCachedWithEncoding with gzip directly, so the only
original conda.go line that changes is that one call.
* fix(handler): keep writeMetadataCachedResponse and its callers as on main
Adding a contentEncoding parameter to writeMetadataCachedResponse changed
a signature that predates #304 and dragged its two pre-#304 callers
(helm.go, maven.go) into the diff, even though #304 only ever added the
cm.contentEncoding block inside the function body.
Restore writeMetadataCachedResponse's doc and signature exactly as on
main and make it a delegate that passes an empty encoding to a new
unexported writeMetadataCachedResponseWithEncoding, which carries the
original body with #304's block reading the parameter instead of the
cache row. proxyCachedWithEncoding calls the sibling with the encoding
returned alongside the body. helm.go and maven.go drop out of the diff;
their behaviour is unchanged (both fetch transparently, so their stored
encoding was always empty). Same split pattern as ProxyCached ->
proxyCachedWithEncoding.
* fix(handler): move the conda gzip change to its own branch
The conda call site in handleRepodata predates #304 and #304 never
touched it, so under the rule that this PR only corrects code and
behaviour #304 introduced it does not belong here. Restore conda.go and
conda_test.go as on main; the conda change continues on a stacked branch
against its own issue.
Replace the conda-route tests with tests that exercise
proxyCachedWithEncoding directly, so this PR still pins its own plumbing:
gzip is requested and the compressed bytes plus Content-Encoding are
cached and replayed (cached and streaming paths), the header survives a
metadata cache write failure, and the stale fallback keeps the stored
encoding.
* fix(homebrew): fetch the JSON API gzip-compressed on both hops
Homebrew (#254) routes every API path through ProxyCached and so, since
#304, fetches formula.jws.json (~33 MB plain, ~5 MB gzip) uncompressed on
every refresh -- the case that motivated #305.
Request gzip for the JSON API via proxyCachedWithEncoding: brew fetches
every API download with curl --compressed and decodes Content-Encoding
itself, so the compressed bytes and header are cached and served as-is
and both hops stay compressed. The analytics endpoints are the one brew
consumer fetched without --compressed; they stay on identity.
* fix(handler): leave Accept-Encoding unset in proxyMetadataStream for an empty value
fetchUpstreamMetadata treats an empty acceptEncoding as 'do not set the
header'; proxyMetadataStream set it unconditionally, which would send an
empty Accept-Encoding line if a caller ever passed . Guard it the same
way so both paths agree. No caller passes today.
* fix(handler): keep the metadata row and blob from describing different bytes
Two ways the cache row could stop describing the stored blob once a
caller requests gzip, both raised by the review of #324:
- cacheMetadataBlob stored the blob and then discarded the
UpsertMetadataCache error. After a successful gzip store and a failed
row write, a later TTL hit or stale fallback served the gzip blob with
the previous row's encoding. On a row-write failure, log it and delete
the blob just written, so the next request refetches instead.
- fetchOrCacheMetadata read the row once up front and reused it for the
stale fallback. A request that read an identity row, lost the upstream
race to a request that stored the gzip blob, and then failed upstream
labelled the new blob with the old row. Re-read the row before falling
back so the encoding matches the blob as it is now.
Both only become harmful with an encoding change, which this branch
introduces; the pre-existing validator-from-row read is tracked
separately.
* Drop unused cachedMeta.contentEncoding and fix stale doc reference
The field was added by #304 and its only reader is replaced in this
branch by the encoding parameter passed alongside the body. The
proxyCachedWithEncoding comment named conda repodata, which was moved
out of this branch in 5991d95; Homebrew is the caller that ships here.
---------
Co-authored-by: Andrew Nesbitt <andrewnez@gmail.com>
2026-09-15 20:50:44 +02:00
|
|
|
meta, err := p.fetchUpstreamMetadata(ctx, upstreamURL, entry, accept, acceptEncoding)
|
2026-03-19 21:06:02 +00:00
|
|
|
if errors.Is(err, errStale304) {
|
|
|
|
|
// 304 but cached file is gone; retry without ETag
|
fix(handler): let the ProxyCached path request a per-call Accept-Encoding (#324)
* fix(handler): fetch conda repodata gzip-compressed on both hops
#304 made the ProxyCached path request Accept-Encoding: identity so the
metadata cache stores upstream bytes verbatim. That is required for the
signed / hash-pinned index ecosystems, but conda's repodata.json is large
plain JSON: linux-64 repodata.json is ~441 MB uncompressed (over the
metadata_max_size cap, so it 502s today) versus ~34 MB gzip.
Replace the ProxyCached path's verbatim bool with an explicit
acceptEncoding string ('' = leave unset / transparent, 'identity', or
'gzip'), reusing #304's existing store-and-replay of Content-Encoding
unchanged. ProxyCached keeps its exported signature and continues to send
identity, so the nine other ecosystems and helm/maven are untouched; only
conda's repodata.json / current_repodata.json now request gzip. Setting
Accept-Encoding explicitly disables Go's transparent decompression, so the
compressed bytes and the Content-Encoding: gzip header are cached and
replayed exactly as identity bytes are. conda, mamba and pixi solicit and
decode gzip on .json URLs; repodata.json.bz2 stays identity.
Fixes #305
* fix(handler): pass metadata content-encoding with the body it describes
The adversarial review of the conda gzip route found a reachable
regression: writeMetadataCachedResponse took Content-Encoding from a
fresh cache-row read while cacheMetadataBlob skips the row write when
Storage.Store fails. Under identity that was benign (the body was plain
anyway), but on the new gzip route a disk-full or object-store outage
served raw gzip bytes as Content-Type: application/json with no
Content-Encoding and HTTP 200 -- conda, mamba and pixi fail to parse
them, with no HTTP signal and only a Warn log, on every request until a
cache write succeeds.
fetchOrCacheMetadata now returns the encoding of the body it hands back
(the upstream value on a fetch, the stored row's value on a TTL hit or
stale fallback) and proxyCachedWithEncoding passes it to
writeMetadataCachedResponse, so the header always describes the bytes
actually written. cachedMeta drops its now-unused content_encoding
field. helm and maven pass "" -- both fetch transparently, so their
stored encoding was always empty and behaviour is unchanged.
Also fixes a vacuous assertion in the new conda test: the upstream
request counter incremented behind the availability gate, so the
cached-replay block could never observe a refetch.
* fix(handler): pin the stale-fallback content-encoding and drop a dead guard
Follow-ups from the adversarial review of the #305 branch, limited to
code this branch introduced:
- proxyMetadataStream is only ever reached with an explicit
Accept-Encoding (ProxyCached passes identity, conda passes gzip or
identity), so the guard around the header set was unreachable; replace
it with the plain one-token substitution of the former literal, which
is the smallest change from main.
- The stale-fallback return of fetchOrCacheMetadata (encoding taken from
the cache row) was the one #305 return site no test pinned: replacing
it with an empty encoding survived the whole suite. Add a conda test
that expires the entry, fails the upstream, and asserts the stored
gzip blob is served with Content-Encoding: gzip.
Not changed, by scope: cacheMetadataBlob still discards the
UpsertMetadataCache error (pre-existing on main). If Storage.Store
succeeds and the row write fails, a later stale fallback or TTL hit can
serve the gzip blob with the row's stale encoding; that needs a DB write
failure plus a second event and is tracked separately.
* fix(handler): restore the pre-existing cachedMeta content-encoding field
The third adversarial review classified deleting cachedMeta.contentEncoding
and its lookupCachedMeta populate as elective: neither line was created by
this branch nor forced by the fix (writeMetadataCachedResponse now reads
the encoding from its parameter and ignores the row value). Under the rule
that pre-existing code this branch did not have to touch stays untouched,
restore both as they are on main. No behaviour change.
Residuals the review documented, unchanged by scope (both share one root
cause: the encoding lives in the cache row and the bytes in the blob, and
neither is written or read atomically):
- cacheMetadataBlob discards the UpsertMetadataCache error, so after a
successful gzip Store and a failed row write a later stale fallback or
TTL hit can serve the gzip blob with the row's stale encoding.
- During the one-time identity->gzip rollout, a request that read a
pre-branch identity row, lost the upstream race to a request that stored
the gzip blob, and then failed upstream serves the gzip bytes with no
Content-Encoding for that one response; later requests self-heal.
- helm and maven now pass an empty encoding; on main a spec-violating
upstream that answered a transparent gzip request with an encoding Go
does not decode (e.g. br) would have had that header replayed from the
row. Degenerate; documented rather than changed.
* fix(handler): keep conda's proxyCached and .bz2 route as on main
Threading acceptEncoding through CondaHandler.proxyCached changed the
form of two pieces of original code the fix did not need to touch: the
repodata.json.bz2 route (method value rewritten as a closure) and
proxyCached itself (new parameter, new call). Restore both exactly as on
main; ProxyCached still sends identity, so the .bz2 route is unchanged in
behaviour. handleRepodata's non-cooldown branch now derives the cache key
inline and calls proxyCachedWithEncoding with gzip directly, so the only
original conda.go line that changes is that one call.
* fix(handler): keep writeMetadataCachedResponse and its callers as on main
Adding a contentEncoding parameter to writeMetadataCachedResponse changed
a signature that predates #304 and dragged its two pre-#304 callers
(helm.go, maven.go) into the diff, even though #304 only ever added the
cm.contentEncoding block inside the function body.
Restore writeMetadataCachedResponse's doc and signature exactly as on
main and make it a delegate that passes an empty encoding to a new
unexported writeMetadataCachedResponseWithEncoding, which carries the
original body with #304's block reading the parameter instead of the
cache row. proxyCachedWithEncoding calls the sibling with the encoding
returned alongside the body. helm.go and maven.go drop out of the diff;
their behaviour is unchanged (both fetch transparently, so their stored
encoding was always empty). Same split pattern as ProxyCached ->
proxyCachedWithEncoding.
* fix(handler): move the conda gzip change to its own branch
The conda call site in handleRepodata predates #304 and #304 never
touched it, so under the rule that this PR only corrects code and
behaviour #304 introduced it does not belong here. Restore conda.go and
conda_test.go as on main; the conda change continues on a stacked branch
against its own issue.
Replace the conda-route tests with tests that exercise
proxyCachedWithEncoding directly, so this PR still pins its own plumbing:
gzip is requested and the compressed bytes plus Content-Encoding are
cached and replayed (cached and streaming paths), the header survives a
metadata cache write failure, and the stale fallback keeps the stored
encoding.
* fix(homebrew): fetch the JSON API gzip-compressed on both hops
Homebrew (#254) routes every API path through ProxyCached and so, since
#304, fetches formula.jws.json (~33 MB plain, ~5 MB gzip) uncompressed on
every refresh -- the case that motivated #305.
Request gzip for the JSON API via proxyCachedWithEncoding: brew fetches
every API download with curl --compressed and decodes Content-Encoding
itself, so the compressed bytes and header are cached and served as-is
and both hops stay compressed. The analytics endpoints are the one brew
consumer fetched without --compressed; they stay on identity.
* fix(handler): leave Accept-Encoding unset in proxyMetadataStream for an empty value
fetchUpstreamMetadata treats an empty acceptEncoding as 'do not set the
header'; proxyMetadataStream set it unconditionally, which would send an
empty Accept-Encoding line if a caller ever passed . Guard it the same
way so both paths agree. No caller passes today.
* fix(handler): keep the metadata row and blob from describing different bytes
Two ways the cache row could stop describing the stored blob once a
caller requests gzip, both raised by the review of #324:
- cacheMetadataBlob stored the blob and then discarded the
UpsertMetadataCache error. After a successful gzip store and a failed
row write, a later TTL hit or stale fallback served the gzip blob with
the previous row's encoding. On a row-write failure, log it and delete
the blob just written, so the next request refetches instead.
- fetchOrCacheMetadata read the row once up front and reused it for the
stale fallback. A request that read an identity row, lost the upstream
race to a request that stored the gzip blob, and then failed upstream
labelled the new blob with the old row. Re-read the row before falling
back so the encoding matches the blob as it is now.
Both only become harmful with an encoding change, which this branch
introduces; the pre-existing validator-from-row read is tracked
separately.
* Drop unused cachedMeta.contentEncoding and fix stale doc reference
The field was added by #304 and its only reader is replaced in this
branch by the encoding parameter passed alongside the body. The
proxyCachedWithEncoding comment named conda repodata, which was moved
out of this branch in 5991d95; Homebrew is the caller that ships here.
---------
Co-authored-by: Andrew Nesbitt <andrewnez@gmail.com>
2026-09-15 20:50:44 +02:00
|
|
|
meta, err = p.fetchUpstreamMetadata(ctx, upstreamURL, nil, accept, acceptEncoding)
|
2026-03-19 21:06:02 +00:00
|
|
|
}
|
2026-09-16 12:57:44 +05:30
|
|
|
if err == nil && validate != nil {
|
|
|
|
|
err = validate(meta.body)
|
|
|
|
|
}
|
2026-03-19 21:06:02 +00:00
|
|
|
if err == nil {
|
2026-10-09 08:10:41 +02:00
|
|
|
var digest string
|
2026-03-19 21:06:02 +00:00
|
|
|
if p.CacheMetadata {
|
2026-10-09 08:10:41 +02:00
|
|
|
digest = p.cacheMetadataBlob(ctx, ecosystem, cacheKey, metadataStoragePath(ecosystem, cacheKey), meta)
|
2026-03-19 21:06:02 +00:00
|
|
|
}
|
2026-10-10 13:47:39 +02:00
|
|
|
return metadataResult{body: meta.body, contentType: meta.contentType, contentEncoding: meta.contentEncoding, digest: digest}
|
2026-03-19 21:06:02 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Upstream failed -- fall back to cache if available
|
|
|
|
|
if !p.CacheMetadata || entry == nil {
|
Coalesce concurrent metadata fetches (#400)
* Coalesce concurrent metadata fetches
Concurrent cache misses for one artifact already share a single upstream
fetch (#329), but metadata misses did not: every request for a package's
metadata went to the registry, even when the same document was already
being fetched. In CI that is the common case. Composer resolves versions
on every job, since MediaWiki core and its extensions commit no
composer.lock, and each package is one /p2/ document, so a burst of jobs
fetched each document once per job.
Metadata misses now follow the same pattern as artifacts: the first caller
fetches, the rest wait for its result, and the caller that takes the key
rechecks the cache first so a fetch that just finished is not repeated.
The key includes the Accept and Accept-Encoding headers, which change the
bytes upstream returns (npm's abbreviated and full documents), and whether
the caller validates the response.
One difference from artifacts: the shared fetch is detached from the first
caller's cancellation. Artifact fetches keep it for scanning and mirroring,
neither of which applies to metadata, and otherwise one job disconnecting
would fail every job waiting on the same document. runScan detaches the
same way, and the HTTP client's timeout still bounds the fetch.
This covers every ecosystem that goes through fetchOrCacheMetadata
(Composer, npm, PyPI, Maven, Cargo, Swift, Pub, Helm, NuGet), with
metadata caching on or off. Streamed metadata with caching off is not
coalesced.
On an 8 vCPU test instance with caching off, 10 concurrent clients
requesting the npm metadata of 1,263 packages went from 12,630 upstream
requests to 1,898. With cache_metadata on, 20 clients requesting 1,538
packages made exactly 1,538.
* Run each caller's validate on a shared metadata fetch
A caller that joined another's metadata fetch got the shared bytes
without running its own validate. NuGet's cooldown path uses validate to
decode the document for the request, so a joined download found no
publication date and was allowed, bypassing the cooldown.
coalesceMetadata now reports whether the caller joined, and
coalescedMetadataMiss runs that caller's validate on the shared bytes.
Validation before caching is unchanged: the first caller still runs it
inside the fetch.
TestNuGetCooldownConcurrentDownloads reproduces the bypass through
NuGetHandler.Routes() with two overlapping downloads of a version inside
its cooldown.
2026-10-04 09:50:45 -07:00
|
|
|
return metadataResult{err: fmt.Errorf("upstream failed and no cached metadata: %w", err)}
|
2026-03-19 21:06:02 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
p.Logger.Warn("upstream metadata fetch failed, checking cache",
|
|
|
|
|
"ecosystem", ecosystem, "key", cacheKey, "error", err)
|
|
|
|
|
|
fix(handler): let the ProxyCached path request a per-call Accept-Encoding (#324)
* fix(handler): fetch conda repodata gzip-compressed on both hops
#304 made the ProxyCached path request Accept-Encoding: identity so the
metadata cache stores upstream bytes verbatim. That is required for the
signed / hash-pinned index ecosystems, but conda's repodata.json is large
plain JSON: linux-64 repodata.json is ~441 MB uncompressed (over the
metadata_max_size cap, so it 502s today) versus ~34 MB gzip.
Replace the ProxyCached path's verbatim bool with an explicit
acceptEncoding string ('' = leave unset / transparent, 'identity', or
'gzip'), reusing #304's existing store-and-replay of Content-Encoding
unchanged. ProxyCached keeps its exported signature and continues to send
identity, so the nine other ecosystems and helm/maven are untouched; only
conda's repodata.json / current_repodata.json now request gzip. Setting
Accept-Encoding explicitly disables Go's transparent decompression, so the
compressed bytes and the Content-Encoding: gzip header are cached and
replayed exactly as identity bytes are. conda, mamba and pixi solicit and
decode gzip on .json URLs; repodata.json.bz2 stays identity.
Fixes #305
* fix(handler): pass metadata content-encoding with the body it describes
The adversarial review of the conda gzip route found a reachable
regression: writeMetadataCachedResponse took Content-Encoding from a
fresh cache-row read while cacheMetadataBlob skips the row write when
Storage.Store fails. Under identity that was benign (the body was plain
anyway), but on the new gzip route a disk-full or object-store outage
served raw gzip bytes as Content-Type: application/json with no
Content-Encoding and HTTP 200 -- conda, mamba and pixi fail to parse
them, with no HTTP signal and only a Warn log, on every request until a
cache write succeeds.
fetchOrCacheMetadata now returns the encoding of the body it hands back
(the upstream value on a fetch, the stored row's value on a TTL hit or
stale fallback) and proxyCachedWithEncoding passes it to
writeMetadataCachedResponse, so the header always describes the bytes
actually written. cachedMeta drops its now-unused content_encoding
field. helm and maven pass "" -- both fetch transparently, so their
stored encoding was always empty and behaviour is unchanged.
Also fixes a vacuous assertion in the new conda test: the upstream
request counter incremented behind the availability gate, so the
cached-replay block could never observe a refetch.
* fix(handler): pin the stale-fallback content-encoding and drop a dead guard
Follow-ups from the adversarial review of the #305 branch, limited to
code this branch introduced:
- proxyMetadataStream is only ever reached with an explicit
Accept-Encoding (ProxyCached passes identity, conda passes gzip or
identity), so the guard around the header set was unreachable; replace
it with the plain one-token substitution of the former literal, which
is the smallest change from main.
- The stale-fallback return of fetchOrCacheMetadata (encoding taken from
the cache row) was the one #305 return site no test pinned: replacing
it with an empty encoding survived the whole suite. Add a conda test
that expires the entry, fails the upstream, and asserts the stored
gzip blob is served with Content-Encoding: gzip.
Not changed, by scope: cacheMetadataBlob still discards the
UpsertMetadataCache error (pre-existing on main). If Storage.Store
succeeds and the row write fails, a later stale fallback or TTL hit can
serve the gzip blob with the row's stale encoding; that needs a DB write
failure plus a second event and is tracked separately.
* fix(handler): restore the pre-existing cachedMeta content-encoding field
The third adversarial review classified deleting cachedMeta.contentEncoding
and its lookupCachedMeta populate as elective: neither line was created by
this branch nor forced by the fix (writeMetadataCachedResponse now reads
the encoding from its parameter and ignores the row value). Under the rule
that pre-existing code this branch did not have to touch stays untouched,
restore both as they are on main. No behaviour change.
Residuals the review documented, unchanged by scope (both share one root
cause: the encoding lives in the cache row and the bytes in the blob, and
neither is written or read atomically):
- cacheMetadataBlob discards the UpsertMetadataCache error, so after a
successful gzip Store and a failed row write a later stale fallback or
TTL hit can serve the gzip blob with the row's stale encoding.
- During the one-time identity->gzip rollout, a request that read a
pre-branch identity row, lost the upstream race to a request that stored
the gzip blob, and then failed upstream serves the gzip bytes with no
Content-Encoding for that one response; later requests self-heal.
- helm and maven now pass an empty encoding; on main a spec-violating
upstream that answered a transparent gzip request with an encoding Go
does not decode (e.g. br) would have had that header replayed from the
row. Degenerate; documented rather than changed.
* fix(handler): keep conda's proxyCached and .bz2 route as on main
Threading acceptEncoding through CondaHandler.proxyCached changed the
form of two pieces of original code the fix did not need to touch: the
repodata.json.bz2 route (method value rewritten as a closure) and
proxyCached itself (new parameter, new call). Restore both exactly as on
main; ProxyCached still sends identity, so the .bz2 route is unchanged in
behaviour. handleRepodata's non-cooldown branch now derives the cache key
inline and calls proxyCachedWithEncoding with gzip directly, so the only
original conda.go line that changes is that one call.
* fix(handler): keep writeMetadataCachedResponse and its callers as on main
Adding a contentEncoding parameter to writeMetadataCachedResponse changed
a signature that predates #304 and dragged its two pre-#304 callers
(helm.go, maven.go) into the diff, even though #304 only ever added the
cm.contentEncoding block inside the function body.
Restore writeMetadataCachedResponse's doc and signature exactly as on
main and make it a delegate that passes an empty encoding to a new
unexported writeMetadataCachedResponseWithEncoding, which carries the
original body with #304's block reading the parameter instead of the
cache row. proxyCachedWithEncoding calls the sibling with the encoding
returned alongside the body. helm.go and maven.go drop out of the diff;
their behaviour is unchanged (both fetch transparently, so their stored
encoding was always empty). Same split pattern as ProxyCached ->
proxyCachedWithEncoding.
* fix(handler): move the conda gzip change to its own branch
The conda call site in handleRepodata predates #304 and #304 never
touched it, so under the rule that this PR only corrects code and
behaviour #304 introduced it does not belong here. Restore conda.go and
conda_test.go as on main; the conda change continues on a stacked branch
against its own issue.
Replace the conda-route tests with tests that exercise
proxyCachedWithEncoding directly, so this PR still pins its own plumbing:
gzip is requested and the compressed bytes plus Content-Encoding are
cached and replayed (cached and streaming paths), the header survives a
metadata cache write failure, and the stale fallback keeps the stored
encoding.
* fix(homebrew): fetch the JSON API gzip-compressed on both hops
Homebrew (#254) routes every API path through ProxyCached and so, since
#304, fetches formula.jws.json (~33 MB plain, ~5 MB gzip) uncompressed on
every refresh -- the case that motivated #305.
Request gzip for the JSON API via proxyCachedWithEncoding: brew fetches
every API download with curl --compressed and decodes Content-Encoding
itself, so the compressed bytes and header are cached and served as-is
and both hops stay compressed. The analytics endpoints are the one brew
consumer fetched without --compressed; they stay on identity.
* fix(handler): leave Accept-Encoding unset in proxyMetadataStream for an empty value
fetchUpstreamMetadata treats an empty acceptEncoding as 'do not set the
header'; proxyMetadataStream set it unconditionally, which would send an
empty Accept-Encoding line if a caller ever passed . Guard it the same
way so both paths agree. No caller passes today.
* fix(handler): keep the metadata row and blob from describing different bytes
Two ways the cache row could stop describing the stored blob once a
caller requests gzip, both raised by the review of #324:
- cacheMetadataBlob stored the blob and then discarded the
UpsertMetadataCache error. After a successful gzip store and a failed
row write, a later TTL hit or stale fallback served the gzip blob with
the previous row's encoding. On a row-write failure, log it and delete
the blob just written, so the next request refetches instead.
- fetchOrCacheMetadata read the row once up front and reused it for the
stale fallback. A request that read an identity row, lost the upstream
race to a request that stored the gzip blob, and then failed upstream
labelled the new blob with the old row. Re-read the row before falling
back so the encoding matches the blob as it is now.
Both only become harmful with an encoding change, which this branch
introduces; the pre-existing validator-from-row read is tracked
separately.
* Drop unused cachedMeta.contentEncoding and fix stale doc reference
The field was added by #304 and its only reader is replaced in this
branch by the encoding parameter passed alongside the body. The
proxyCachedWithEncoding comment named conda repodata, which was moved
out of this branch in 5991d95; Homebrew is the caller that ships here.
---------
Co-authored-by: Andrew Nesbitt <andrewnez@gmail.com>
2026-09-15 20:50:44 +02:00
|
|
|
// Re-read the row so the encoding describes the blob as it is now: a
|
|
|
|
|
// concurrent refetch may have replaced both since entry was read above
|
|
|
|
|
// (an identity blob swapped for a gzip one during rollout).
|
|
|
|
|
entry = p.currentMetadataEntry(ecosystem, cacheKey, entry)
|
|
|
|
|
|
2026-09-16 12:57:44 +05:30
|
|
|
data, ct, readErr := p.readCachedMetadata(ctx, entry, validate)
|
2026-03-19 21:06:02 +00:00
|
|
|
if readErr != nil {
|
Coalesce concurrent metadata fetches (#400)
* Coalesce concurrent metadata fetches
Concurrent cache misses for one artifact already share a single upstream
fetch (#329), but metadata misses did not: every request for a package's
metadata went to the registry, even when the same document was already
being fetched. In CI that is the common case. Composer resolves versions
on every job, since MediaWiki core and its extensions commit no
composer.lock, and each package is one /p2/ document, so a burst of jobs
fetched each document once per job.
Metadata misses now follow the same pattern as artifacts: the first caller
fetches, the rest wait for its result, and the caller that takes the key
rechecks the cache first so a fetch that just finished is not repeated.
The key includes the Accept and Accept-Encoding headers, which change the
bytes upstream returns (npm's abbreviated and full documents), and whether
the caller validates the response.
One difference from artifacts: the shared fetch is detached from the first
caller's cancellation. Artifact fetches keep it for scanning and mirroring,
neither of which applies to metadata, and otherwise one job disconnecting
would fail every job waiting on the same document. runScan detaches the
same way, and the HTTP client's timeout still bounds the fetch.
This covers every ecosystem that goes through fetchOrCacheMetadata
(Composer, npm, PyPI, Maven, Cargo, Swift, Pub, Helm, NuGet), with
metadata caching on or off. Streamed metadata with caching off is not
coalesced.
On an 8 vCPU test instance with caching off, 10 concurrent clients
requesting the npm metadata of 1,263 packages went from 12,630 upstream
requests to 1,898. With cache_metadata on, 20 clients requesting 1,538
packages made exactly 1,538.
* Run each caller's validate on a shared metadata fetch
A caller that joined another's metadata fetch got the shared bytes
without running its own validate. NuGet's cooldown path uses validate to
decode the document for the request, so a joined download found no
publication date and was allowed, bypassing the cooldown.
coalesceMetadata now reports whether the caller joined, and
coalescedMetadataMiss runs that caller's validate on the shared bytes.
Validation before caching is unchanged: the first caller still runs it
inside the fetch.
TestNuGetCooldownConcurrentDownloads reproduces the bypass through
NuGetHandler.Routes() with two overlapping downloads of a version inside
its cooldown.
2026-10-04 09:50:45 -07:00
|
|
|
return metadataResult{err: fmt.Errorf("upstream failed and cached metadata unusable (%v): %w", readErr, err)}
|
2026-03-19 21:06:02 +00:00
|
|
|
}
|
|
|
|
|
|
2026-09-16 12:57:44 +05:30
|
|
|
p.Logger.Info("serving metadata from cache",
|
|
|
|
|
"ecosystem", ecosystem, "key", cacheKey)
|
Don't let the supported check hang, and don't take a block page for an answer
Two problems with the check behind supported. The fetch it waits on is
shared with other callers and only bounded by the upstream timeout, which
defaults to 30 seconds. A firewall that silently drops packets to
sum.golang.org would stall the first go command every minute for that long,
and the go command itself has no timeout on this request. Now we wait five
seconds at most; the fetch carries on in the background, records how it
went and fills the cache for the next check. A timeout only counts as a
failure if the fetch hasn't succeeded in the meantime.
The fetch reports no error when it falls back to the cached tree after the
database failed, so the metadata result now says when it did, and the
check treats that as the database not answering. A fresh cached tree
(within metadata_ttl) still lets a timed-out check say yes, since the
fetch only falls back once upstream has failed, which a silent database
delays past our timeout. An older tree does not count: once the proxy has
lost the database, clients go there directly, as they would without the
proxy.
And the check believed any 200. Egress proxies tend to answer blocked hosts
with a 200 HTML page, which we would have cached as the signed tree and taken
as proof that the database is reachable. latest now has to start with
"go.sum database tree", or it is refused like a bad tile.
Lookups had the same gap. A block page cached as a lookup would also
replace the good copy that is served during an outage, so a lookup now has
to be a record followed by a signed tree, the way the go command reads it.
2026-10-10 11:31:01 +02:00
|
|
|
return metadataResult{body: data, contentType: ct, contentEncoding: entry.ContentEncoding.String, stale: true}.describedBy(entry)
|
Coalesce concurrent metadata fetches (#400)
* Coalesce concurrent metadata fetches
Concurrent cache misses for one artifact already share a single upstream
fetch (#329), but metadata misses did not: every request for a package's
metadata went to the registry, even when the same document was already
being fetched. In CI that is the common case. Composer resolves versions
on every job, since MediaWiki core and its extensions commit no
composer.lock, and each package is one /p2/ document, so a burst of jobs
fetched each document once per job.
Metadata misses now follow the same pattern as artifacts: the first caller
fetches, the rest wait for its result, and the caller that takes the key
rechecks the cache first so a fetch that just finished is not repeated.
The key includes the Accept and Accept-Encoding headers, which change the
bytes upstream returns (npm's abbreviated and full documents), and whether
the caller validates the response.
One difference from artifacts: the shared fetch is detached from the first
caller's cancellation. Artifact fetches keep it for scanning and mirroring,
neither of which applies to metadata, and otherwise one job disconnecting
would fail every job waiting on the same document. runScan detaches the
same way, and the HTTP client's timeout still bounds the fetch.
This covers every ecosystem that goes through fetchOrCacheMetadata
(Composer, npm, PyPI, Maven, Cargo, Swift, Pub, Helm, NuGet), with
metadata caching on or off. Streamed metadata with caching off is not
coalesced.
On an 8 vCPU test instance with caching off, 10 concurrent clients
requesting the npm metadata of 1,263 packages went from 12,630 upstream
requests to 1,898. With cache_metadata on, 20 clients requesting 1,538
packages made exactly 1,538.
* Run each caller's validate on a shared metadata fetch
A caller that joined another's metadata fetch got the shared bytes
without running its own validate. NuGet's cooldown path uses validate to
decode the document for the request, so a joined download found no
publication date and was allowed, bypassing the cooldown.
coalesceMetadata now reports whether the caller joined, and
coalescedMetadataMiss runs that caller's validate on the shared bytes.
Validation before caching is unchanged: the first caller still runs it
inside the fetch.
TestNuGetCooldownConcurrentDownloads reproduces the bypass through
NuGetHandler.Routes() with two overlapping downloads of a version inside
its cooldown.
2026-10-04 09:50:45 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// metadataResult is what a metadata lookup hands back. A shared fetch gives
|
|
|
|
|
// every waiter the same body, so callers must treat it as read-only.
|
|
|
|
|
type metadataResult struct {
|
|
|
|
|
body []byte
|
|
|
|
|
contentType string
|
|
|
|
|
contentEncoding string
|
2026-10-09 08:10:41 +02:00
|
|
|
// digest identifies body when the metadata cache stored it, and is
|
2026-10-10 13:47:39 +02:00
|
|
|
// empty otherwise.
|
|
|
|
|
digest string
|
|
|
|
|
err error
|
Don't let the supported check hang, and don't take a block page for an answer
Two problems with the check behind supported. The fetch it waits on is
shared with other callers and only bounded by the upstream timeout, which
defaults to 30 seconds. A firewall that silently drops packets to
sum.golang.org would stall the first go command every minute for that long,
and the go command itself has no timeout on this request. Now we wait five
seconds at most; the fetch carries on in the background, records how it
went and fills the cache for the next check. A timeout only counts as a
failure if the fetch hasn't succeeded in the meantime.
The fetch reports no error when it falls back to the cached tree after the
database failed, so the metadata result now says when it did, and the
check treats that as the database not answering. A fresh cached tree
(within metadata_ttl) still lets a timed-out check say yes, since the
fetch only falls back once upstream has failed, which a silent database
delays past our timeout. An older tree does not count: once the proxy has
lost the database, clients go there directly, as they would without the
proxy.
And the check believed any 200. Egress proxies tend to answer blocked hosts
with a 200 HTML page, which we would have cached as the signed tree and taken
as proof that the database is reachable. latest now has to start with
"go.sum database tree", or it is refused like a bad tile.
Lookups had the same gap. A block page cached as a lookup would also
replace the good copy that is served during an outage, so a lookup now has
to be a record followed by a signed tree, the way the go command reads it.
2026-10-10 11:31:01 +02:00
|
|
|
// stale is set when the body is the cached copy served because upstream
|
|
|
|
|
// failed.
|
|
|
|
|
stale bool
|
2026-10-09 08:10:41 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-10 13:47:39 +02:00
|
|
|
// describedBy returns res with the digest that entry, the cache row its body
|
|
|
|
|
// was read for, records.
|
2026-10-09 08:10:41 +02:00
|
|
|
func (res metadataResult) describedBy(entry *database.MetadataCacheEntry) metadataResult {
|
|
|
|
|
if entry.ContentDigest.Valid {
|
|
|
|
|
res.digest = entry.ContentDigest.String
|
|
|
|
|
}
|
|
|
|
|
return res
|
Coalesce concurrent metadata fetches (#400)
* Coalesce concurrent metadata fetches
Concurrent cache misses for one artifact already share a single upstream
fetch (#329), but metadata misses did not: every request for a package's
metadata went to the registry, even when the same document was already
being fetched. In CI that is the common case. Composer resolves versions
on every job, since MediaWiki core and its extensions commit no
composer.lock, and each package is one /p2/ document, so a burst of jobs
fetched each document once per job.
Metadata misses now follow the same pattern as artifacts: the first caller
fetches, the rest wait for its result, and the caller that takes the key
rechecks the cache first so a fetch that just finished is not repeated.
The key includes the Accept and Accept-Encoding headers, which change the
bytes upstream returns (npm's abbreviated and full documents), and whether
the caller validates the response.
One difference from artifacts: the shared fetch is detached from the first
caller's cancellation. Artifact fetches keep it for scanning and mirroring,
neither of which applies to metadata, and otherwise one job disconnecting
would fail every job waiting on the same document. runScan detaches the
same way, and the HTTP client's timeout still bounds the fetch.
This covers every ecosystem that goes through fetchOrCacheMetadata
(Composer, npm, PyPI, Maven, Cargo, Swift, Pub, Helm, NuGet), with
metadata caching on or off. Streamed metadata with caching off is not
coalesced.
On an 8 vCPU test instance with caching off, 10 concurrent clients
requesting the npm metadata of 1,263 packages went from 12,630 upstream
requests to 1,898. With cache_metadata on, 20 clients requesting 1,538
packages made exactly 1,538.
* Run each caller's validate on a shared metadata fetch
A caller that joined another's metadata fetch got the shared bytes
without running its own validate. NuGet's cooldown path uses validate to
decode the document for the request, so a joined download found no
publication date and was allowed, bypassing the cooldown.
coalesceMetadata now reports whether the caller joined, and
coalescedMetadataMiss runs that caller's validate on the shared bytes.
Validation before caching is unchanged: the first caller still runs it
inside the fetch.
TestNuGetCooldownConcurrentDownloads reproduces the bypass through
NuGetHandler.Routes() with two overlapping downloads of a version inside
its cooldown.
2026-10-04 09:50:45 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// inflightMetadata is one metadata fetch that concurrent callers share. res is
|
|
|
|
|
// written before done closes and read only after, so the close is the handoff.
|
|
|
|
|
type inflightMetadata struct {
|
|
|
|
|
done chan struct{}
|
|
|
|
|
res metadataResult
|
|
|
|
|
|
|
|
|
|
// waiters counts callers that joined the fetch, guarded by metaMu. Tests
|
|
|
|
|
// read it to know every caller has joined before the fetch finishes.
|
|
|
|
|
waiters int
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// metadataCoalesceKey identifies metadata requests that can share one fetch.
|
|
|
|
|
// Accept and Accept-Encoding are part of it because they change the bytes
|
|
|
|
|
// upstream returns: npm serves an abbreviated or a full document for the same
|
|
|
|
|
// package, and the cached-proxy paths ask for identity or gzip. Whether the
|
|
|
|
|
// caller validates is part of it so an unvalidated caller is never handed a
|
|
|
|
|
// result that skipped validation, or the reverse.
|
|
|
|
|
func metadataCoalesceKey(ecosystem, cacheKey, upstreamURL, accept, acceptEncoding string, validated bool) string {
|
|
|
|
|
return strings.Join([]string{ecosystem, cacheKey, upstreamURL, accept, acceptEncoding, strconv.FormatBool(validated)}, "\x00")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// coalesceMetadata runs fetch at most once for concurrent callers sharing key
|
|
|
|
|
// and gives each the result, reporting whether this caller joined another's
|
|
|
|
|
// fetch rather than running it. It follows coalesceFetch with one difference:
|
|
|
|
|
// fetch runs on a context detached from the first caller's cancellation. A
|
|
|
|
|
// metadata fetch has no scan or mirror that depends on the caller aborting it,
|
|
|
|
|
// and CI jobs asking for the same Composer or npm metadata would otherwise all
|
|
|
|
|
// fail when the first of them disconnects. It stays bounded by the HTTP
|
|
|
|
|
// client's timeout. Waiters still leave when their own context ends.
|
|
|
|
|
func (p *Proxy) coalesceMetadata(ctx context.Context, key string, fetch func(context.Context) metadataResult) (metadataResult, bool) {
|
|
|
|
|
p.metaMu.Lock()
|
|
|
|
|
if p.inFlightMeta == nil {
|
|
|
|
|
p.inFlightMeta = make(map[string]*inflightMetadata)
|
|
|
|
|
}
|
|
|
|
|
f, joined := p.inFlightMeta[key]
|
|
|
|
|
if joined {
|
|
|
|
|
f.waiters++
|
|
|
|
|
} else {
|
|
|
|
|
f = &inflightMetadata{done: make(chan struct{})}
|
|
|
|
|
p.inFlightMeta[key] = f
|
|
|
|
|
}
|
|
|
|
|
p.metaMu.Unlock()
|
|
|
|
|
|
|
|
|
|
if joined {
|
|
|
|
|
select {
|
|
|
|
|
case <-ctx.Done():
|
|
|
|
|
return metadataResult{err: ctx.Err()}, true
|
|
|
|
|
case <-f.done:
|
|
|
|
|
return f.res, true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Set before running so a panicking fetch leaves waiters with an error
|
|
|
|
|
// rather than an empty body.
|
|
|
|
|
f.res = metadataResult{err: errSharedFetchAbandoned}
|
|
|
|
|
defer func() {
|
|
|
|
|
p.metaMu.Lock()
|
|
|
|
|
delete(p.inFlightMeta, key)
|
|
|
|
|
p.metaMu.Unlock()
|
|
|
|
|
close(f.done)
|
|
|
|
|
}()
|
|
|
|
|
f.res = fetch(context.WithoutCancel(ctx))
|
|
|
|
|
return f.res, false
|
2026-09-16 12:57:44 +05:30
|
|
|
}
|
2026-03-19 21:06:02 +00:00
|
|
|
|
2026-09-16 12:57:44 +05:30
|
|
|
func (p *Proxy) readCachedMetadata(ctx context.Context, entry *database.MetadataCacheEntry, validate func([]byte) error) ([]byte, string, error) {
|
|
|
|
|
cached, err := p.Storage.Open(ctx, entry.StoragePath)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, "", err
|
|
|
|
|
}
|
|
|
|
|
defer func() { _ = cached.Close() }()
|
|
|
|
|
data, err := p.ReadMetadata(cached)
|
|
|
|
|
if err == nil && validate != nil {
|
|
|
|
|
err = validate(data)
|
|
|
|
|
}
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, "", err
|
|
|
|
|
}
|
2026-03-19 21:06:02 +00:00
|
|
|
ct := contentTypeJSON
|
|
|
|
|
if entry.ContentType.Valid {
|
|
|
|
|
ct = entry.ContentType.String
|
|
|
|
|
}
|
2026-09-16 12:57:44 +05:30
|
|
|
return data, ct, nil
|
2026-03-19 21:06:02 +00:00
|
|
|
}
|
|
|
|
|
|
2026-08-16 18:01:59 +01:00
|
|
|
func (p *Proxy) recordMetadataCacheMiss(ecosystem string) {
|
|
|
|
|
if p.CacheMetadata {
|
|
|
|
|
metrics.RecordCacheMiss(ecosystem)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
fix(handler): preserve upstream content-encoding for cached metadata (#304)
* fix(handler): preserve upstream content-encoding for cached metadata
Signed and hash-pinned index files (debian Release/Packages.gz, rpm
repomd.xml, helm index.yaml, conda repodata.json, apk APKINDEX.tar.gz)
were cached after Go's transport transparently decompressed any
Content-Encoding: gzip response, so the proxy served bytes that differ
from what the upstream signed and broke client verification.
Request the identity encoding on the shared metadata fetch so Go no
longer auto-decompresses, and persist the upstream Content-Encoding in
a new metadata_cache column so both the cached and offline responses
replay the exact bytes and header. The uncached streaming path now
forwards Content-Encoding too.
Fixes #300
* fix(handler): scope verbatim metadata fetch to the ProxyCached path
The first cut forced Accept-Encoding: identity in the shared
fetchUpstreamMetadata, which an adversarial review showed both missed
the bug and regressed unrelated ecosystems:
- proxyMetadataStream (the default path, since cache_metadata is off)
never forced identity, so a client sending no Accept-Encoding still
triggered Go's transparent gzip decompression and served altered bytes
for apk/debian/rpm/conda indexes.
- Direct FetchOrCacheMetadata callers that parse or rewrite the body
(npm, pypi, cargo, composer, pub, nuget, swift, maven, helm) were
forced to identity too, losing wire compression and 502-ing against
upstreams that ignore identity and gzip anyway (helm rewriteIndex).
- Rows cached before the fix kept serving decompressed bytes via ETag
304 revalidation.
Scope the verbatim behavior to the ProxyCached code path, which serves
upstream bytes through unchanged (apk, debian, rpm, go, hex, conda,
cran, gem, conan, julia). That path now requests identity on both the
cached fetch and the uncached stream branch and replays Content-Encoding;
direct callers keep transparent compression, matching main. Migration
008 clears etag/fetched_at so legacy rows refetch once with identity.
Tests now exercise the stream path with no client Accept-Encoding and
pin that direct callers are not forced to identity.
2026-09-03 10:58:58 +02:00
|
|
|
// upstreamMetadata holds a fetched metadata response in upstream byte form.
|
|
|
|
|
type upstreamMetadata struct {
|
|
|
|
|
body []byte
|
|
|
|
|
contentType string
|
|
|
|
|
contentEncoding string
|
|
|
|
|
etag string
|
|
|
|
|
lastModified time.Time
|
|
|
|
|
}
|
Fix metadata caching, 404 propagation, mirror progress, and registry stubs
- ProxyCached now stores upstream Last-Modified in the cache and uses it
(along with ETag) for conditional request handling, returning 304 when
client validators match. Adds Content-Length to cached responses.
- Handlers calling FetchOrCacheMetadata (pypi, composer, pub, nuget) now
check for ErrUpstreamNotFound and return 404 instead of 502, matching
the existing npm and cargo behavior.
- Mirror jobs report live progress via a periodic callback while running,
so API polls return real counts instead of zeroed progress.
- Registry mirroring removed from CLI flags, API acceptance, README, and
docs since every enumerator was a stub returning "not yet implemented".
- Added tests for the conditional metadata path (ETag/If-None-Match,
Last-Modified/If-Modified-Since, 304 responses, header omission).
2026-04-01 20:14:11 +01:00
|
|
|
|
fix(handler): preserve upstream content-encoding for cached metadata (#304)
* fix(handler): preserve upstream content-encoding for cached metadata
Signed and hash-pinned index files (debian Release/Packages.gz, rpm
repomd.xml, helm index.yaml, conda repodata.json, apk APKINDEX.tar.gz)
were cached after Go's transport transparently decompressed any
Content-Encoding: gzip response, so the proxy served bytes that differ
from what the upstream signed and broke client verification.
Request the identity encoding on the shared metadata fetch so Go no
longer auto-decompresses, and persist the upstream Content-Encoding in
a new metadata_cache column so both the cached and offline responses
replay the exact bytes and header. The uncached streaming path now
forwards Content-Encoding too.
Fixes #300
* fix(handler): scope verbatim metadata fetch to the ProxyCached path
The first cut forced Accept-Encoding: identity in the shared
fetchUpstreamMetadata, which an adversarial review showed both missed
the bug and regressed unrelated ecosystems:
- proxyMetadataStream (the default path, since cache_metadata is off)
never forced identity, so a client sending no Accept-Encoding still
triggered Go's transparent gzip decompression and served altered bytes
for apk/debian/rpm/conda indexes.
- Direct FetchOrCacheMetadata callers that parse or rewrite the body
(npm, pypi, cargo, composer, pub, nuget, swift, maven, helm) were
forced to identity too, losing wire compression and 502-ing against
upstreams that ignore identity and gzip anyway (helm rewriteIndex).
- Rows cached before the fix kept serving decompressed bytes via ETag
304 revalidation.
Scope the verbatim behavior to the ProxyCached code path, which serves
upstream bytes through unchanged (apk, debian, rpm, go, hex, conda,
cran, gem, conan, julia). That path now requests identity on both the
cached fetch and the uncached stream branch and replays Content-Encoding;
direct callers keep transparent compression, matching main. Migration
008 clears etag/fetched_at so legacy rows refetch once with identity.
Tests now exercise the stream path with no client Accept-Encoding and
pin that direct callers are not forced to identity.
2026-09-03 10:58:58 +02:00
|
|
|
// fetchUpstreamMetadata fetches metadata from upstream, using ETag for conditional revalidation.
|
fix(handler): let the ProxyCached path request a per-call Accept-Encoding (#324)
* fix(handler): fetch conda repodata gzip-compressed on both hops
#304 made the ProxyCached path request Accept-Encoding: identity so the
metadata cache stores upstream bytes verbatim. That is required for the
signed / hash-pinned index ecosystems, but conda's repodata.json is large
plain JSON: linux-64 repodata.json is ~441 MB uncompressed (over the
metadata_max_size cap, so it 502s today) versus ~34 MB gzip.
Replace the ProxyCached path's verbatim bool with an explicit
acceptEncoding string ('' = leave unset / transparent, 'identity', or
'gzip'), reusing #304's existing store-and-replay of Content-Encoding
unchanged. ProxyCached keeps its exported signature and continues to send
identity, so the nine other ecosystems and helm/maven are untouched; only
conda's repodata.json / current_repodata.json now request gzip. Setting
Accept-Encoding explicitly disables Go's transparent decompression, so the
compressed bytes and the Content-Encoding: gzip header are cached and
replayed exactly as identity bytes are. conda, mamba and pixi solicit and
decode gzip on .json URLs; repodata.json.bz2 stays identity.
Fixes #305
* fix(handler): pass metadata content-encoding with the body it describes
The adversarial review of the conda gzip route found a reachable
regression: writeMetadataCachedResponse took Content-Encoding from a
fresh cache-row read while cacheMetadataBlob skips the row write when
Storage.Store fails. Under identity that was benign (the body was plain
anyway), but on the new gzip route a disk-full or object-store outage
served raw gzip bytes as Content-Type: application/json with no
Content-Encoding and HTTP 200 -- conda, mamba and pixi fail to parse
them, with no HTTP signal and only a Warn log, on every request until a
cache write succeeds.
fetchOrCacheMetadata now returns the encoding of the body it hands back
(the upstream value on a fetch, the stored row's value on a TTL hit or
stale fallback) and proxyCachedWithEncoding passes it to
writeMetadataCachedResponse, so the header always describes the bytes
actually written. cachedMeta drops its now-unused content_encoding
field. helm and maven pass "" -- both fetch transparently, so their
stored encoding was always empty and behaviour is unchanged.
Also fixes a vacuous assertion in the new conda test: the upstream
request counter incremented behind the availability gate, so the
cached-replay block could never observe a refetch.
* fix(handler): pin the stale-fallback content-encoding and drop a dead guard
Follow-ups from the adversarial review of the #305 branch, limited to
code this branch introduced:
- proxyMetadataStream is only ever reached with an explicit
Accept-Encoding (ProxyCached passes identity, conda passes gzip or
identity), so the guard around the header set was unreachable; replace
it with the plain one-token substitution of the former literal, which
is the smallest change from main.
- The stale-fallback return of fetchOrCacheMetadata (encoding taken from
the cache row) was the one #305 return site no test pinned: replacing
it with an empty encoding survived the whole suite. Add a conda test
that expires the entry, fails the upstream, and asserts the stored
gzip blob is served with Content-Encoding: gzip.
Not changed, by scope: cacheMetadataBlob still discards the
UpsertMetadataCache error (pre-existing on main). If Storage.Store
succeeds and the row write fails, a later stale fallback or TTL hit can
serve the gzip blob with the row's stale encoding; that needs a DB write
failure plus a second event and is tracked separately.
* fix(handler): restore the pre-existing cachedMeta content-encoding field
The third adversarial review classified deleting cachedMeta.contentEncoding
and its lookupCachedMeta populate as elective: neither line was created by
this branch nor forced by the fix (writeMetadataCachedResponse now reads
the encoding from its parameter and ignores the row value). Under the rule
that pre-existing code this branch did not have to touch stays untouched,
restore both as they are on main. No behaviour change.
Residuals the review documented, unchanged by scope (both share one root
cause: the encoding lives in the cache row and the bytes in the blob, and
neither is written or read atomically):
- cacheMetadataBlob discards the UpsertMetadataCache error, so after a
successful gzip Store and a failed row write a later stale fallback or
TTL hit can serve the gzip blob with the row's stale encoding.
- During the one-time identity->gzip rollout, a request that read a
pre-branch identity row, lost the upstream race to a request that stored
the gzip blob, and then failed upstream serves the gzip bytes with no
Content-Encoding for that one response; later requests self-heal.
- helm and maven now pass an empty encoding; on main a spec-violating
upstream that answered a transparent gzip request with an encoding Go
does not decode (e.g. br) would have had that header replayed from the
row. Degenerate; documented rather than changed.
* fix(handler): keep conda's proxyCached and .bz2 route as on main
Threading acceptEncoding through CondaHandler.proxyCached changed the
form of two pieces of original code the fix did not need to touch: the
repodata.json.bz2 route (method value rewritten as a closure) and
proxyCached itself (new parameter, new call). Restore both exactly as on
main; ProxyCached still sends identity, so the .bz2 route is unchanged in
behaviour. handleRepodata's non-cooldown branch now derives the cache key
inline and calls proxyCachedWithEncoding with gzip directly, so the only
original conda.go line that changes is that one call.
* fix(handler): keep writeMetadataCachedResponse and its callers as on main
Adding a contentEncoding parameter to writeMetadataCachedResponse changed
a signature that predates #304 and dragged its two pre-#304 callers
(helm.go, maven.go) into the diff, even though #304 only ever added the
cm.contentEncoding block inside the function body.
Restore writeMetadataCachedResponse's doc and signature exactly as on
main and make it a delegate that passes an empty encoding to a new
unexported writeMetadataCachedResponseWithEncoding, which carries the
original body with #304's block reading the parameter instead of the
cache row. proxyCachedWithEncoding calls the sibling with the encoding
returned alongside the body. helm.go and maven.go drop out of the diff;
their behaviour is unchanged (both fetch transparently, so their stored
encoding was always empty). Same split pattern as ProxyCached ->
proxyCachedWithEncoding.
* fix(handler): move the conda gzip change to its own branch
The conda call site in handleRepodata predates #304 and #304 never
touched it, so under the rule that this PR only corrects code and
behaviour #304 introduced it does not belong here. Restore conda.go and
conda_test.go as on main; the conda change continues on a stacked branch
against its own issue.
Replace the conda-route tests with tests that exercise
proxyCachedWithEncoding directly, so this PR still pins its own plumbing:
gzip is requested and the compressed bytes plus Content-Encoding are
cached and replayed (cached and streaming paths), the header survives a
metadata cache write failure, and the stale fallback keeps the stored
encoding.
* fix(homebrew): fetch the JSON API gzip-compressed on both hops
Homebrew (#254) routes every API path through ProxyCached and so, since
#304, fetches formula.jws.json (~33 MB plain, ~5 MB gzip) uncompressed on
every refresh -- the case that motivated #305.
Request gzip for the JSON API via proxyCachedWithEncoding: brew fetches
every API download with curl --compressed and decodes Content-Encoding
itself, so the compressed bytes and header are cached and served as-is
and both hops stay compressed. The analytics endpoints are the one brew
consumer fetched without --compressed; they stay on identity.
* fix(handler): leave Accept-Encoding unset in proxyMetadataStream for an empty value
fetchUpstreamMetadata treats an empty acceptEncoding as 'do not set the
header'; proxyMetadataStream set it unconditionally, which would send an
empty Accept-Encoding line if a caller ever passed . Guard it the same
way so both paths agree. No caller passes today.
* fix(handler): keep the metadata row and blob from describing different bytes
Two ways the cache row could stop describing the stored blob once a
caller requests gzip, both raised by the review of #324:
- cacheMetadataBlob stored the blob and then discarded the
UpsertMetadataCache error. After a successful gzip store and a failed
row write, a later TTL hit or stale fallback served the gzip blob with
the previous row's encoding. On a row-write failure, log it and delete
the blob just written, so the next request refetches instead.
- fetchOrCacheMetadata read the row once up front and reused it for the
stale fallback. A request that read an identity row, lost the upstream
race to a request that stored the gzip blob, and then failed upstream
labelled the new blob with the old row. Re-read the row before falling
back so the encoding matches the blob as it is now.
Both only become harmful with an encoding change, which this branch
introduces; the pre-existing validator-from-row read is tracked
separately.
* Drop unused cachedMeta.contentEncoding and fix stale doc reference
The field was added by #304 and its only reader is replaced in this
branch by the encoding parameter passed alongside the body. The
proxyCachedWithEncoding comment named conda repodata, which was moved
out of this branch in 5991d95; Homebrew is the caller that ships here.
---------
Co-authored-by: Andrew Nesbitt <andrewnez@gmail.com>
2026-09-15 20:50:44 +02:00
|
|
|
// When acceptEncoding is non-empty it is sent as the Accept-Encoding header, which disables Go's
|
|
|
|
|
// transparent decompression (it only applies when the transport adds the header itself), so the
|
|
|
|
|
// returned bytes are exactly what the upstream sent and any Content-Encoding it applied is reported
|
|
|
|
|
// alongside for the caller to store and replay. An empty acceptEncoding leaves Go to negotiate and
|
|
|
|
|
// decompress transparently.
|
|
|
|
|
func (p *Proxy) fetchUpstreamMetadata(ctx context.Context, upstreamURL string, entry *database.MetadataCacheEntry, accept, acceptEncoding string) (*upstreamMetadata, error) {
|
2026-03-19 21:06:02 +00:00
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, upstreamURL, nil)
|
2026-03-18 10:59:29 +00:00
|
|
|
if err != nil {
|
fix(handler): preserve upstream content-encoding for cached metadata (#304)
* fix(handler): preserve upstream content-encoding for cached metadata
Signed and hash-pinned index files (debian Release/Packages.gz, rpm
repomd.xml, helm index.yaml, conda repodata.json, apk APKINDEX.tar.gz)
were cached after Go's transport transparently decompressed any
Content-Encoding: gzip response, so the proxy served bytes that differ
from what the upstream signed and broke client verification.
Request the identity encoding on the shared metadata fetch so Go no
longer auto-decompresses, and persist the upstream Content-Encoding in
a new metadata_cache column so both the cached and offline responses
replay the exact bytes and header. The uncached streaming path now
forwards Content-Encoding too.
Fixes #300
* fix(handler): scope verbatim metadata fetch to the ProxyCached path
The first cut forced Accept-Encoding: identity in the shared
fetchUpstreamMetadata, which an adversarial review showed both missed
the bug and regressed unrelated ecosystems:
- proxyMetadataStream (the default path, since cache_metadata is off)
never forced identity, so a client sending no Accept-Encoding still
triggered Go's transparent gzip decompression and served altered bytes
for apk/debian/rpm/conda indexes.
- Direct FetchOrCacheMetadata callers that parse or rewrite the body
(npm, pypi, cargo, composer, pub, nuget, swift, maven, helm) were
forced to identity too, losing wire compression and 502-ing against
upstreams that ignore identity and gzip anyway (helm rewriteIndex).
- Rows cached before the fix kept serving decompressed bytes via ETag
304 revalidation.
Scope the verbatim behavior to the ProxyCached code path, which serves
upstream bytes through unchanged (apk, debian, rpm, go, hex, conda,
cran, gem, conan, julia). That path now requests identity on both the
cached fetch and the uncached stream branch and replays Content-Encoding;
direct callers keep transparent compression, matching main. Migration
008 clears etag/fetched_at so legacy rows refetch once with identity.
Tests now exercise the stream path with no client Accept-Encoding and
pin that direct callers are not forced to identity.
2026-09-03 10:58:58 +02:00
|
|
|
return nil, fmt.Errorf("creating request: %w", err)
|
2026-03-19 21:06:02 +00:00
|
|
|
}
|
|
|
|
|
req.Header.Set("Accept", accept)
|
fix(handler): let the ProxyCached path request a per-call Accept-Encoding (#324)
* fix(handler): fetch conda repodata gzip-compressed on both hops
#304 made the ProxyCached path request Accept-Encoding: identity so the
metadata cache stores upstream bytes verbatim. That is required for the
signed / hash-pinned index ecosystems, but conda's repodata.json is large
plain JSON: linux-64 repodata.json is ~441 MB uncompressed (over the
metadata_max_size cap, so it 502s today) versus ~34 MB gzip.
Replace the ProxyCached path's verbatim bool with an explicit
acceptEncoding string ('' = leave unset / transparent, 'identity', or
'gzip'), reusing #304's existing store-and-replay of Content-Encoding
unchanged. ProxyCached keeps its exported signature and continues to send
identity, so the nine other ecosystems and helm/maven are untouched; only
conda's repodata.json / current_repodata.json now request gzip. Setting
Accept-Encoding explicitly disables Go's transparent decompression, so the
compressed bytes and the Content-Encoding: gzip header are cached and
replayed exactly as identity bytes are. conda, mamba and pixi solicit and
decode gzip on .json URLs; repodata.json.bz2 stays identity.
Fixes #305
* fix(handler): pass metadata content-encoding with the body it describes
The adversarial review of the conda gzip route found a reachable
regression: writeMetadataCachedResponse took Content-Encoding from a
fresh cache-row read while cacheMetadataBlob skips the row write when
Storage.Store fails. Under identity that was benign (the body was plain
anyway), but on the new gzip route a disk-full or object-store outage
served raw gzip bytes as Content-Type: application/json with no
Content-Encoding and HTTP 200 -- conda, mamba and pixi fail to parse
them, with no HTTP signal and only a Warn log, on every request until a
cache write succeeds.
fetchOrCacheMetadata now returns the encoding of the body it hands back
(the upstream value on a fetch, the stored row's value on a TTL hit or
stale fallback) and proxyCachedWithEncoding passes it to
writeMetadataCachedResponse, so the header always describes the bytes
actually written. cachedMeta drops its now-unused content_encoding
field. helm and maven pass "" -- both fetch transparently, so their
stored encoding was always empty and behaviour is unchanged.
Also fixes a vacuous assertion in the new conda test: the upstream
request counter incremented behind the availability gate, so the
cached-replay block could never observe a refetch.
* fix(handler): pin the stale-fallback content-encoding and drop a dead guard
Follow-ups from the adversarial review of the #305 branch, limited to
code this branch introduced:
- proxyMetadataStream is only ever reached with an explicit
Accept-Encoding (ProxyCached passes identity, conda passes gzip or
identity), so the guard around the header set was unreachable; replace
it with the plain one-token substitution of the former literal, which
is the smallest change from main.
- The stale-fallback return of fetchOrCacheMetadata (encoding taken from
the cache row) was the one #305 return site no test pinned: replacing
it with an empty encoding survived the whole suite. Add a conda test
that expires the entry, fails the upstream, and asserts the stored
gzip blob is served with Content-Encoding: gzip.
Not changed, by scope: cacheMetadataBlob still discards the
UpsertMetadataCache error (pre-existing on main). If Storage.Store
succeeds and the row write fails, a later stale fallback or TTL hit can
serve the gzip blob with the row's stale encoding; that needs a DB write
failure plus a second event and is tracked separately.
* fix(handler): restore the pre-existing cachedMeta content-encoding field
The third adversarial review classified deleting cachedMeta.contentEncoding
and its lookupCachedMeta populate as elective: neither line was created by
this branch nor forced by the fix (writeMetadataCachedResponse now reads
the encoding from its parameter and ignores the row value). Under the rule
that pre-existing code this branch did not have to touch stays untouched,
restore both as they are on main. No behaviour change.
Residuals the review documented, unchanged by scope (both share one root
cause: the encoding lives in the cache row and the bytes in the blob, and
neither is written or read atomically):
- cacheMetadataBlob discards the UpsertMetadataCache error, so after a
successful gzip Store and a failed row write a later stale fallback or
TTL hit can serve the gzip blob with the row's stale encoding.
- During the one-time identity->gzip rollout, a request that read a
pre-branch identity row, lost the upstream race to a request that stored
the gzip blob, and then failed upstream serves the gzip bytes with no
Content-Encoding for that one response; later requests self-heal.
- helm and maven now pass an empty encoding; on main a spec-violating
upstream that answered a transparent gzip request with an encoding Go
does not decode (e.g. br) would have had that header replayed from the
row. Degenerate; documented rather than changed.
* fix(handler): keep conda's proxyCached and .bz2 route as on main
Threading acceptEncoding through CondaHandler.proxyCached changed the
form of two pieces of original code the fix did not need to touch: the
repodata.json.bz2 route (method value rewritten as a closure) and
proxyCached itself (new parameter, new call). Restore both exactly as on
main; ProxyCached still sends identity, so the .bz2 route is unchanged in
behaviour. handleRepodata's non-cooldown branch now derives the cache key
inline and calls proxyCachedWithEncoding with gzip directly, so the only
original conda.go line that changes is that one call.
* fix(handler): keep writeMetadataCachedResponse and its callers as on main
Adding a contentEncoding parameter to writeMetadataCachedResponse changed
a signature that predates #304 and dragged its two pre-#304 callers
(helm.go, maven.go) into the diff, even though #304 only ever added the
cm.contentEncoding block inside the function body.
Restore writeMetadataCachedResponse's doc and signature exactly as on
main and make it a delegate that passes an empty encoding to a new
unexported writeMetadataCachedResponseWithEncoding, which carries the
original body with #304's block reading the parameter instead of the
cache row. proxyCachedWithEncoding calls the sibling with the encoding
returned alongside the body. helm.go and maven.go drop out of the diff;
their behaviour is unchanged (both fetch transparently, so their stored
encoding was always empty). Same split pattern as ProxyCached ->
proxyCachedWithEncoding.
* fix(handler): move the conda gzip change to its own branch
The conda call site in handleRepodata predates #304 and #304 never
touched it, so under the rule that this PR only corrects code and
behaviour #304 introduced it does not belong here. Restore conda.go and
conda_test.go as on main; the conda change continues on a stacked branch
against its own issue.
Replace the conda-route tests with tests that exercise
proxyCachedWithEncoding directly, so this PR still pins its own plumbing:
gzip is requested and the compressed bytes plus Content-Encoding are
cached and replayed (cached and streaming paths), the header survives a
metadata cache write failure, and the stale fallback keeps the stored
encoding.
* fix(homebrew): fetch the JSON API gzip-compressed on both hops
Homebrew (#254) routes every API path through ProxyCached and so, since
#304, fetches formula.jws.json (~33 MB plain, ~5 MB gzip) uncompressed on
every refresh -- the case that motivated #305.
Request gzip for the JSON API via proxyCachedWithEncoding: brew fetches
every API download with curl --compressed and decodes Content-Encoding
itself, so the compressed bytes and header are cached and served as-is
and both hops stay compressed. The analytics endpoints are the one brew
consumer fetched without --compressed; they stay on identity.
* fix(handler): leave Accept-Encoding unset in proxyMetadataStream for an empty value
fetchUpstreamMetadata treats an empty acceptEncoding as 'do not set the
header'; proxyMetadataStream set it unconditionally, which would send an
empty Accept-Encoding line if a caller ever passed . Guard it the same
way so both paths agree. No caller passes today.
* fix(handler): keep the metadata row and blob from describing different bytes
Two ways the cache row could stop describing the stored blob once a
caller requests gzip, both raised by the review of #324:
- cacheMetadataBlob stored the blob and then discarded the
UpsertMetadataCache error. After a successful gzip store and a failed
row write, a later TTL hit or stale fallback served the gzip blob with
the previous row's encoding. On a row-write failure, log it and delete
the blob just written, so the next request refetches instead.
- fetchOrCacheMetadata read the row once up front and reused it for the
stale fallback. A request that read an identity row, lost the upstream
race to a request that stored the gzip blob, and then failed upstream
labelled the new blob with the old row. Re-read the row before falling
back so the encoding matches the blob as it is now.
Both only become harmful with an encoding change, which this branch
introduces; the pre-existing validator-from-row read is tracked
separately.
* Drop unused cachedMeta.contentEncoding and fix stale doc reference
The field was added by #304 and its only reader is replaced in this
branch by the encoding parameter passed alongside the body. The
proxyCachedWithEncoding comment named conda repodata, which was moved
out of this branch in 5991d95; Homebrew is the caller that ships here.
---------
Co-authored-by: Andrew Nesbitt <andrewnez@gmail.com>
2026-09-15 20:50:44 +02:00
|
|
|
if acceptEncoding != "" {
|
|
|
|
|
req.Header.Set(headerAcceptEncoding, acceptEncoding)
|
fix(handler): preserve upstream content-encoding for cached metadata (#304)
* fix(handler): preserve upstream content-encoding for cached metadata
Signed and hash-pinned index files (debian Release/Packages.gz, rpm
repomd.xml, helm index.yaml, conda repodata.json, apk APKINDEX.tar.gz)
were cached after Go's transport transparently decompressed any
Content-Encoding: gzip response, so the proxy served bytes that differ
from what the upstream signed and broke client verification.
Request the identity encoding on the shared metadata fetch so Go no
longer auto-decompresses, and persist the upstream Content-Encoding in
a new metadata_cache column so both the cached and offline responses
replay the exact bytes and header. The uncached streaming path now
forwards Content-Encoding too.
Fixes #300
* fix(handler): scope verbatim metadata fetch to the ProxyCached path
The first cut forced Accept-Encoding: identity in the shared
fetchUpstreamMetadata, which an adversarial review showed both missed
the bug and regressed unrelated ecosystems:
- proxyMetadataStream (the default path, since cache_metadata is off)
never forced identity, so a client sending no Accept-Encoding still
triggered Go's transparent gzip decompression and served altered bytes
for apk/debian/rpm/conda indexes.
- Direct FetchOrCacheMetadata callers that parse or rewrite the body
(npm, pypi, cargo, composer, pub, nuget, swift, maven, helm) were
forced to identity too, losing wire compression and 502-ing against
upstreams that ignore identity and gzip anyway (helm rewriteIndex).
- Rows cached before the fix kept serving decompressed bytes via ETag
304 revalidation.
Scope the verbatim behavior to the ProxyCached code path, which serves
upstream bytes through unchanged (apk, debian, rpm, go, hex, conda,
cran, gem, conan, julia). That path now requests identity on both the
cached fetch and the uncached stream branch and replays Content-Encoding;
direct callers keep transparent compression, matching main. Migration
008 clears etag/fetched_at so legacy rows refetch once with identity.
Tests now exercise the stream path with no client Accept-Encoding and
pin that direct callers are not forced to identity.
2026-09-03 10:58:58 +02:00
|
|
|
}
|
2026-03-19 21:06:02 +00:00
|
|
|
|
|
|
|
|
if entry != nil && entry.ETag.Valid {
|
|
|
|
|
req.Header.Set("If-None-Match", entry.ETag.String)
|
2026-09-03 16:59:12 +01:00
|
|
|
} else if entry != nil && entry.LastModified.Valid {
|
|
|
|
|
req.Header.Set("If-Modified-Since", entry.LastModified.Time.UTC().Format(http.TimeFormat))
|
2026-03-18 10:59:29 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
resp, err := p.HTTPClient.Do(req)
|
|
|
|
|
if err != nil {
|
fix(handler): preserve upstream content-encoding for cached metadata (#304)
* fix(handler): preserve upstream content-encoding for cached metadata
Signed and hash-pinned index files (debian Release/Packages.gz, rpm
repomd.xml, helm index.yaml, conda repodata.json, apk APKINDEX.tar.gz)
were cached after Go's transport transparently decompressed any
Content-Encoding: gzip response, so the proxy served bytes that differ
from what the upstream signed and broke client verification.
Request the identity encoding on the shared metadata fetch so Go no
longer auto-decompresses, and persist the upstream Content-Encoding in
a new metadata_cache column so both the cached and offline responses
replay the exact bytes and header. The uncached streaming path now
forwards Content-Encoding too.
Fixes #300
* fix(handler): scope verbatim metadata fetch to the ProxyCached path
The first cut forced Accept-Encoding: identity in the shared
fetchUpstreamMetadata, which an adversarial review showed both missed
the bug and regressed unrelated ecosystems:
- proxyMetadataStream (the default path, since cache_metadata is off)
never forced identity, so a client sending no Accept-Encoding still
triggered Go's transparent gzip decompression and served altered bytes
for apk/debian/rpm/conda indexes.
- Direct FetchOrCacheMetadata callers that parse or rewrite the body
(npm, pypi, cargo, composer, pub, nuget, swift, maven, helm) were
forced to identity too, losing wire compression and 502-ing against
upstreams that ignore identity and gzip anyway (helm rewriteIndex).
- Rows cached before the fix kept serving decompressed bytes via ETag
304 revalidation.
Scope the verbatim behavior to the ProxyCached code path, which serves
upstream bytes through unchanged (apk, debian, rpm, go, hex, conda,
cran, gem, conan, julia). That path now requests identity on both the
cached fetch and the uncached stream branch and replays Content-Encoding;
direct callers keep transparent compression, matching main. Migration
008 clears etag/fetched_at so legacy rows refetch once with identity.
Tests now exercise the stream path with no client Accept-Encoding and
pin that direct callers are not forced to identity.
2026-09-03 10:58:58 +02:00
|
|
|
return nil, fmt.Errorf("fetching metadata: %w", err)
|
2026-03-18 10:59:29 +00:00
|
|
|
}
|
|
|
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
|
|
2026-03-19 21:06:02 +00:00
|
|
|
// 304 Not Modified -- our cached copy is still good
|
|
|
|
|
if resp.StatusCode == http.StatusNotModified && entry != nil {
|
|
|
|
|
cached, readErr := p.Storage.Open(ctx, entry.StoragePath)
|
|
|
|
|
if readErr != nil {
|
fix(handler): preserve upstream content-encoding for cached metadata (#304)
* fix(handler): preserve upstream content-encoding for cached metadata
Signed and hash-pinned index files (debian Release/Packages.gz, rpm
repomd.xml, helm index.yaml, conda repodata.json, apk APKINDEX.tar.gz)
were cached after Go's transport transparently decompressed any
Content-Encoding: gzip response, so the proxy served bytes that differ
from what the upstream signed and broke client verification.
Request the identity encoding on the shared metadata fetch so Go no
longer auto-decompresses, and persist the upstream Content-Encoding in
a new metadata_cache column so both the cached and offline responses
replay the exact bytes and header. The uncached streaming path now
forwards Content-Encoding too.
Fixes #300
* fix(handler): scope verbatim metadata fetch to the ProxyCached path
The first cut forced Accept-Encoding: identity in the shared
fetchUpstreamMetadata, which an adversarial review showed both missed
the bug and regressed unrelated ecosystems:
- proxyMetadataStream (the default path, since cache_metadata is off)
never forced identity, so a client sending no Accept-Encoding still
triggered Go's transparent gzip decompression and served altered bytes
for apk/debian/rpm/conda indexes.
- Direct FetchOrCacheMetadata callers that parse or rewrite the body
(npm, pypi, cargo, composer, pub, nuget, swift, maven, helm) were
forced to identity too, losing wire compression and 502-ing against
upstreams that ignore identity and gzip anyway (helm rewriteIndex).
- Rows cached before the fix kept serving decompressed bytes via ETag
304 revalidation.
Scope the verbatim behavior to the ProxyCached code path, which serves
upstream bytes through unchanged (apk, debian, rpm, go, hex, conda,
cran, gem, conan, julia). That path now requests identity on both the
cached fetch and the uncached stream branch and replays Content-Encoding;
direct callers keep transparent compression, matching main. Migration
008 clears etag/fetched_at so legacy rows refetch once with identity.
Tests now exercise the stream path with no client Accept-Encoding and
pin that direct callers are not forced to identity.
2026-09-03 10:58:58 +02:00
|
|
|
return nil, errStale304
|
2026-03-18 10:59:29 +00:00
|
|
|
}
|
2026-03-19 21:06:02 +00:00
|
|
|
defer func() { _ = cached.Close() }()
|
2026-06-02 07:59:00 +01:00
|
|
|
data, readErr := p.ReadMetadata(cached)
|
2026-03-19 21:06:02 +00:00
|
|
|
if readErr != nil {
|
fix(handler): preserve upstream content-encoding for cached metadata (#304)
* fix(handler): preserve upstream content-encoding for cached metadata
Signed and hash-pinned index files (debian Release/Packages.gz, rpm
repomd.xml, helm index.yaml, conda repodata.json, apk APKINDEX.tar.gz)
were cached after Go's transport transparently decompressed any
Content-Encoding: gzip response, so the proxy served bytes that differ
from what the upstream signed and broke client verification.
Request the identity encoding on the shared metadata fetch so Go no
longer auto-decompresses, and persist the upstream Content-Encoding in
a new metadata_cache column so both the cached and offline responses
replay the exact bytes and header. The uncached streaming path now
forwards Content-Encoding too.
Fixes #300
* fix(handler): scope verbatim metadata fetch to the ProxyCached path
The first cut forced Accept-Encoding: identity in the shared
fetchUpstreamMetadata, which an adversarial review showed both missed
the bug and regressed unrelated ecosystems:
- proxyMetadataStream (the default path, since cache_metadata is off)
never forced identity, so a client sending no Accept-Encoding still
triggered Go's transparent gzip decompression and served altered bytes
for apk/debian/rpm/conda indexes.
- Direct FetchOrCacheMetadata callers that parse or rewrite the body
(npm, pypi, cargo, composer, pub, nuget, swift, maven, helm) were
forced to identity too, losing wire compression and 502-ing against
upstreams that ignore identity and gzip anyway (helm rewriteIndex).
- Rows cached before the fix kept serving decompressed bytes via ETag
304 revalidation.
Scope the verbatim behavior to the ProxyCached code path, which serves
upstream bytes through unchanged (apk, debian, rpm, go, hex, conda,
cran, gem, conan, julia). That path now requests identity on both the
cached fetch and the uncached stream branch and replays Content-Encoding;
direct callers keep transparent compression, matching main. Migration
008 clears etag/fetched_at so legacy rows refetch once with identity.
Tests now exercise the stream path with no client Accept-Encoding and
pin that direct callers are not forced to identity.
2026-09-03 10:58:58 +02:00
|
|
|
return nil, errStale304
|
2026-03-19 21:06:02 +00:00
|
|
|
}
|
fix(handler): preserve upstream content-encoding for cached metadata (#304)
* fix(handler): preserve upstream content-encoding for cached metadata
Signed and hash-pinned index files (debian Release/Packages.gz, rpm
repomd.xml, helm index.yaml, conda repodata.json, apk APKINDEX.tar.gz)
were cached after Go's transport transparently decompressed any
Content-Encoding: gzip response, so the proxy served bytes that differ
from what the upstream signed and broke client verification.
Request the identity encoding on the shared metadata fetch so Go no
longer auto-decompresses, and persist the upstream Content-Encoding in
a new metadata_cache column so both the cached and offline responses
replay the exact bytes and header. The uncached streaming path now
forwards Content-Encoding too.
Fixes #300
* fix(handler): scope verbatim metadata fetch to the ProxyCached path
The first cut forced Accept-Encoding: identity in the shared
fetchUpstreamMetadata, which an adversarial review showed both missed
the bug and regressed unrelated ecosystems:
- proxyMetadataStream (the default path, since cache_metadata is off)
never forced identity, so a client sending no Accept-Encoding still
triggered Go's transparent gzip decompression and served altered bytes
for apk/debian/rpm/conda indexes.
- Direct FetchOrCacheMetadata callers that parse or rewrite the body
(npm, pypi, cargo, composer, pub, nuget, swift, maven, helm) were
forced to identity too, losing wire compression and 502-ing against
upstreams that ignore identity and gzip anyway (helm rewriteIndex).
- Rows cached before the fix kept serving decompressed bytes via ETag
304 revalidation.
Scope the verbatim behavior to the ProxyCached code path, which serves
upstream bytes through unchanged (apk, debian, rpm, go, hex, conda,
cran, gem, conan, julia). That path now requests identity on both the
cached fetch and the uncached stream branch and replays Content-Encoding;
direct callers keep transparent compression, matching main. Migration
008 clears etag/fetched_at so legacy rows refetch once with identity.
Tests now exercise the stream path with no client Accept-Encoding and
pin that direct callers are not forced to identity.
2026-09-03 10:58:58 +02:00
|
|
|
meta := &upstreamMetadata{body: data, contentType: contentTypeJSON, etag: entry.ETag.String}
|
2026-03-19 21:06:02 +00:00
|
|
|
if entry.ContentType.Valid {
|
fix(handler): preserve upstream content-encoding for cached metadata (#304)
* fix(handler): preserve upstream content-encoding for cached metadata
Signed and hash-pinned index files (debian Release/Packages.gz, rpm
repomd.xml, helm index.yaml, conda repodata.json, apk APKINDEX.tar.gz)
were cached after Go's transport transparently decompressed any
Content-Encoding: gzip response, so the proxy served bytes that differ
from what the upstream signed and broke client verification.
Request the identity encoding on the shared metadata fetch so Go no
longer auto-decompresses, and persist the upstream Content-Encoding in
a new metadata_cache column so both the cached and offline responses
replay the exact bytes and header. The uncached streaming path now
forwards Content-Encoding too.
Fixes #300
* fix(handler): scope verbatim metadata fetch to the ProxyCached path
The first cut forced Accept-Encoding: identity in the shared
fetchUpstreamMetadata, which an adversarial review showed both missed
the bug and regressed unrelated ecosystems:
- proxyMetadataStream (the default path, since cache_metadata is off)
never forced identity, so a client sending no Accept-Encoding still
triggered Go's transparent gzip decompression and served altered bytes
for apk/debian/rpm/conda indexes.
- Direct FetchOrCacheMetadata callers that parse or rewrite the body
(npm, pypi, cargo, composer, pub, nuget, swift, maven, helm) were
forced to identity too, losing wire compression and 502-ing against
upstreams that ignore identity and gzip anyway (helm rewriteIndex).
- Rows cached before the fix kept serving decompressed bytes via ETag
304 revalidation.
Scope the verbatim behavior to the ProxyCached code path, which serves
upstream bytes through unchanged (apk, debian, rpm, go, hex, conda,
cran, gem, conan, julia). That path now requests identity on both the
cached fetch and the uncached stream branch and replays Content-Encoding;
direct callers keep transparent compression, matching main. Migration
008 clears etag/fetched_at so legacy rows refetch once with identity.
Tests now exercise the stream path with no client Accept-Encoding and
pin that direct callers are not forced to identity.
2026-09-03 10:58:58 +02:00
|
|
|
meta.contentType = entry.ContentType.String
|
|
|
|
|
}
|
|
|
|
|
if entry.ContentEncoding.Valid {
|
|
|
|
|
meta.contentEncoding = entry.ContentEncoding.String
|
2026-03-19 21:06:02 +00:00
|
|
|
}
|
Fix metadata caching, 404 propagation, mirror progress, and registry stubs
- ProxyCached now stores upstream Last-Modified in the cache and uses it
(along with ETag) for conditional request handling, returning 304 when
client validators match. Adds Content-Length to cached responses.
- Handlers calling FetchOrCacheMetadata (pypi, composer, pub, nuget) now
check for ErrUpstreamNotFound and return 404 instead of 502, matching
the existing npm and cargo behavior.
- Mirror jobs report live progress via a periodic callback while running,
so API polls return real counts instead of zeroed progress.
- Registry mirroring removed from CLI flags, API acceptance, README, and
docs since every enumerator was a stub returning "not yet implemented".
- Added tests for the conditional metadata path (ETag/If-None-Match,
Last-Modified/If-Modified-Since, 304 responses, header omission).
2026-04-01 20:14:11 +01:00
|
|
|
if entry.LastModified.Valid {
|
fix(handler): preserve upstream content-encoding for cached metadata (#304)
* fix(handler): preserve upstream content-encoding for cached metadata
Signed and hash-pinned index files (debian Release/Packages.gz, rpm
repomd.xml, helm index.yaml, conda repodata.json, apk APKINDEX.tar.gz)
were cached after Go's transport transparently decompressed any
Content-Encoding: gzip response, so the proxy served bytes that differ
from what the upstream signed and broke client verification.
Request the identity encoding on the shared metadata fetch so Go no
longer auto-decompresses, and persist the upstream Content-Encoding in
a new metadata_cache column so both the cached and offline responses
replay the exact bytes and header. The uncached streaming path now
forwards Content-Encoding too.
Fixes #300
* fix(handler): scope verbatim metadata fetch to the ProxyCached path
The first cut forced Accept-Encoding: identity in the shared
fetchUpstreamMetadata, which an adversarial review showed both missed
the bug and regressed unrelated ecosystems:
- proxyMetadataStream (the default path, since cache_metadata is off)
never forced identity, so a client sending no Accept-Encoding still
triggered Go's transparent gzip decompression and served altered bytes
for apk/debian/rpm/conda indexes.
- Direct FetchOrCacheMetadata callers that parse or rewrite the body
(npm, pypi, cargo, composer, pub, nuget, swift, maven, helm) were
forced to identity too, losing wire compression and 502-ing against
upstreams that ignore identity and gzip anyway (helm rewriteIndex).
- Rows cached before the fix kept serving decompressed bytes via ETag
304 revalidation.
Scope the verbatim behavior to the ProxyCached code path, which serves
upstream bytes through unchanged (apk, debian, rpm, go, hex, conda,
cran, gem, conan, julia). That path now requests identity on both the
cached fetch and the uncached stream branch and replays Content-Encoding;
direct callers keep transparent compression, matching main. Migration
008 clears etag/fetched_at so legacy rows refetch once with identity.
Tests now exercise the stream path with no client Accept-Encoding and
pin that direct callers are not forced to identity.
2026-09-03 10:58:58 +02:00
|
|
|
meta.lastModified = entry.LastModified.Time
|
Fix metadata caching, 404 propagation, mirror progress, and registry stubs
- ProxyCached now stores upstream Last-Modified in the cache and uses it
(along with ETag) for conditional request handling, returning 304 when
client validators match. Adds Content-Length to cached responses.
- Handlers calling FetchOrCacheMetadata (pypi, composer, pub, nuget) now
check for ErrUpstreamNotFound and return 404 instead of 502, matching
the existing npm and cargo behavior.
- Mirror jobs report live progress via a periodic callback while running,
so API polls return real counts instead of zeroed progress.
- Registry mirroring removed from CLI flags, API acceptance, README, and
docs since every enumerator was a stub returning "not yet implemented".
- Added tests for the conditional metadata path (ETag/If-None-Match,
Last-Modified/If-Modified-Since, 304 responses, header omission).
2026-04-01 20:14:11 +01:00
|
|
|
}
|
fix(handler): preserve upstream content-encoding for cached metadata (#304)
* fix(handler): preserve upstream content-encoding for cached metadata
Signed and hash-pinned index files (debian Release/Packages.gz, rpm
repomd.xml, helm index.yaml, conda repodata.json, apk APKINDEX.tar.gz)
were cached after Go's transport transparently decompressed any
Content-Encoding: gzip response, so the proxy served bytes that differ
from what the upstream signed and broke client verification.
Request the identity encoding on the shared metadata fetch so Go no
longer auto-decompresses, and persist the upstream Content-Encoding in
a new metadata_cache column so both the cached and offline responses
replay the exact bytes and header. The uncached streaming path now
forwards Content-Encoding too.
Fixes #300
* fix(handler): scope verbatim metadata fetch to the ProxyCached path
The first cut forced Accept-Encoding: identity in the shared
fetchUpstreamMetadata, which an adversarial review showed both missed
the bug and regressed unrelated ecosystems:
- proxyMetadataStream (the default path, since cache_metadata is off)
never forced identity, so a client sending no Accept-Encoding still
triggered Go's transparent gzip decompression and served altered bytes
for apk/debian/rpm/conda indexes.
- Direct FetchOrCacheMetadata callers that parse or rewrite the body
(npm, pypi, cargo, composer, pub, nuget, swift, maven, helm) were
forced to identity too, losing wire compression and 502-ing against
upstreams that ignore identity and gzip anyway (helm rewriteIndex).
- Rows cached before the fix kept serving decompressed bytes via ETag
304 revalidation.
Scope the verbatim behavior to the ProxyCached code path, which serves
upstream bytes through unchanged (apk, debian, rpm, go, hex, conda,
cran, gem, conan, julia). That path now requests identity on both the
cached fetch and the uncached stream branch and replays Content-Encoding;
direct callers keep transparent compression, matching main. Migration
008 clears etag/fetched_at so legacy rows refetch once with identity.
Tests now exercise the stream path with no client Accept-Encoding and
pin that direct callers are not forced to identity.
2026-09-03 10:58:58 +02:00
|
|
|
return meta, nil
|
2026-03-18 10:59:29 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-19 21:06:02 +00:00
|
|
|
if resp.StatusCode == http.StatusNotFound {
|
2026-10-10 09:57:43 +02:00
|
|
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, upstreamNotFoundBodyLimit))
|
|
|
|
|
return nil, &upstreamNotFoundError{body: body}
|
2026-03-19 21:06:02 +00:00
|
|
|
}
|
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
fix(handler): preserve upstream content-encoding for cached metadata (#304)
* fix(handler): preserve upstream content-encoding for cached metadata
Signed and hash-pinned index files (debian Release/Packages.gz, rpm
repomd.xml, helm index.yaml, conda repodata.json, apk APKINDEX.tar.gz)
were cached after Go's transport transparently decompressed any
Content-Encoding: gzip response, so the proxy served bytes that differ
from what the upstream signed and broke client verification.
Request the identity encoding on the shared metadata fetch so Go no
longer auto-decompresses, and persist the upstream Content-Encoding in
a new metadata_cache column so both the cached and offline responses
replay the exact bytes and header. The uncached streaming path now
forwards Content-Encoding too.
Fixes #300
* fix(handler): scope verbatim metadata fetch to the ProxyCached path
The first cut forced Accept-Encoding: identity in the shared
fetchUpstreamMetadata, which an adversarial review showed both missed
the bug and regressed unrelated ecosystems:
- proxyMetadataStream (the default path, since cache_metadata is off)
never forced identity, so a client sending no Accept-Encoding still
triggered Go's transparent gzip decompression and served altered bytes
for apk/debian/rpm/conda indexes.
- Direct FetchOrCacheMetadata callers that parse or rewrite the body
(npm, pypi, cargo, composer, pub, nuget, swift, maven, helm) were
forced to identity too, losing wire compression and 502-ing against
upstreams that ignore identity and gzip anyway (helm rewriteIndex).
- Rows cached before the fix kept serving decompressed bytes via ETag
304 revalidation.
Scope the verbatim behavior to the ProxyCached code path, which serves
upstream bytes through unchanged (apk, debian, rpm, go, hex, conda,
cran, gem, conan, julia). That path now requests identity on both the
cached fetch and the uncached stream branch and replays Content-Encoding;
direct callers keep transparent compression, matching main. Migration
008 clears etag/fetched_at so legacy rows refetch once with identity.
Tests now exercise the stream path with no client Accept-Encoding and
pin that direct callers are not forced to identity.
2026-09-03 10:58:58 +02:00
|
|
|
return nil, fmt.Errorf("upstream returned %d", resp.StatusCode)
|
2026-03-19 21:06:02 +00:00
|
|
|
}
|
|
|
|
|
|
2026-06-02 07:59:00 +01:00
|
|
|
body, err := p.ReadMetadata(resp.Body)
|
2026-03-19 21:06:02 +00:00
|
|
|
if err != nil {
|
fix(handler): preserve upstream content-encoding for cached metadata (#304)
* fix(handler): preserve upstream content-encoding for cached metadata
Signed and hash-pinned index files (debian Release/Packages.gz, rpm
repomd.xml, helm index.yaml, conda repodata.json, apk APKINDEX.tar.gz)
were cached after Go's transport transparently decompressed any
Content-Encoding: gzip response, so the proxy served bytes that differ
from what the upstream signed and broke client verification.
Request the identity encoding on the shared metadata fetch so Go no
longer auto-decompresses, and persist the upstream Content-Encoding in
a new metadata_cache column so both the cached and offline responses
replay the exact bytes and header. The uncached streaming path now
forwards Content-Encoding too.
Fixes #300
* fix(handler): scope verbatim metadata fetch to the ProxyCached path
The first cut forced Accept-Encoding: identity in the shared
fetchUpstreamMetadata, which an adversarial review showed both missed
the bug and regressed unrelated ecosystems:
- proxyMetadataStream (the default path, since cache_metadata is off)
never forced identity, so a client sending no Accept-Encoding still
triggered Go's transparent gzip decompression and served altered bytes
for apk/debian/rpm/conda indexes.
- Direct FetchOrCacheMetadata callers that parse or rewrite the body
(npm, pypi, cargo, composer, pub, nuget, swift, maven, helm) were
forced to identity too, losing wire compression and 502-ing against
upstreams that ignore identity and gzip anyway (helm rewriteIndex).
- Rows cached before the fix kept serving decompressed bytes via ETag
304 revalidation.
Scope the verbatim behavior to the ProxyCached code path, which serves
upstream bytes through unchanged (apk, debian, rpm, go, hex, conda,
cran, gem, conan, julia). That path now requests identity on both the
cached fetch and the uncached stream branch and replays Content-Encoding;
direct callers keep transparent compression, matching main. Migration
008 clears etag/fetched_at so legacy rows refetch once with identity.
Tests now exercise the stream path with no client Accept-Encoding and
pin that direct callers are not forced to identity.
2026-09-03 10:58:58 +02:00
|
|
|
return nil, fmt.Errorf("reading response: %w", err)
|
2026-03-19 21:06:02 +00:00
|
|
|
}
|
|
|
|
|
|
fix(handler): preserve upstream content-encoding for cached metadata (#304)
* fix(handler): preserve upstream content-encoding for cached metadata
Signed and hash-pinned index files (debian Release/Packages.gz, rpm
repomd.xml, helm index.yaml, conda repodata.json, apk APKINDEX.tar.gz)
were cached after Go's transport transparently decompressed any
Content-Encoding: gzip response, so the proxy served bytes that differ
from what the upstream signed and broke client verification.
Request the identity encoding on the shared metadata fetch so Go no
longer auto-decompresses, and persist the upstream Content-Encoding in
a new metadata_cache column so both the cached and offline responses
replay the exact bytes and header. The uncached streaming path now
forwards Content-Encoding too.
Fixes #300
* fix(handler): scope verbatim metadata fetch to the ProxyCached path
The first cut forced Accept-Encoding: identity in the shared
fetchUpstreamMetadata, which an adversarial review showed both missed
the bug and regressed unrelated ecosystems:
- proxyMetadataStream (the default path, since cache_metadata is off)
never forced identity, so a client sending no Accept-Encoding still
triggered Go's transparent gzip decompression and served altered bytes
for apk/debian/rpm/conda indexes.
- Direct FetchOrCacheMetadata callers that parse or rewrite the body
(npm, pypi, cargo, composer, pub, nuget, swift, maven, helm) were
forced to identity too, losing wire compression and 502-ing against
upstreams that ignore identity and gzip anyway (helm rewriteIndex).
- Rows cached before the fix kept serving decompressed bytes via ETag
304 revalidation.
Scope the verbatim behavior to the ProxyCached code path, which serves
upstream bytes through unchanged (apk, debian, rpm, go, hex, conda,
cran, gem, conan, julia). That path now requests identity on both the
cached fetch and the uncached stream branch and replays Content-Encoding;
direct callers keep transparent compression, matching main. Migration
008 clears etag/fetched_at so legacy rows refetch once with identity.
Tests now exercise the stream path with no client Accept-Encoding and
pin that direct callers are not forced to identity.
2026-09-03 10:58:58 +02:00
|
|
|
meta := &upstreamMetadata{
|
|
|
|
|
body: body,
|
|
|
|
|
contentType: resp.Header.Get(headerContentType),
|
|
|
|
|
contentEncoding: resp.Header.Get(headerContentEncoding),
|
2026-09-03 16:59:12 +01:00
|
|
|
etag: resp.Header.Get(headerETag),
|
fix(handler): preserve upstream content-encoding for cached metadata (#304)
* fix(handler): preserve upstream content-encoding for cached metadata
Signed and hash-pinned index files (debian Release/Packages.gz, rpm
repomd.xml, helm index.yaml, conda repodata.json, apk APKINDEX.tar.gz)
were cached after Go's transport transparently decompressed any
Content-Encoding: gzip response, so the proxy served bytes that differ
from what the upstream signed and broke client verification.
Request the identity encoding on the shared metadata fetch so Go no
longer auto-decompresses, and persist the upstream Content-Encoding in
a new metadata_cache column so both the cached and offline responses
replay the exact bytes and header. The uncached streaming path now
forwards Content-Encoding too.
Fixes #300
* fix(handler): scope verbatim metadata fetch to the ProxyCached path
The first cut forced Accept-Encoding: identity in the shared
fetchUpstreamMetadata, which an adversarial review showed both missed
the bug and regressed unrelated ecosystems:
- proxyMetadataStream (the default path, since cache_metadata is off)
never forced identity, so a client sending no Accept-Encoding still
triggered Go's transparent gzip decompression and served altered bytes
for apk/debian/rpm/conda indexes.
- Direct FetchOrCacheMetadata callers that parse or rewrite the body
(npm, pypi, cargo, composer, pub, nuget, swift, maven, helm) were
forced to identity too, losing wire compression and 502-ing against
upstreams that ignore identity and gzip anyway (helm rewriteIndex).
- Rows cached before the fix kept serving decompressed bytes via ETag
304 revalidation.
Scope the verbatim behavior to the ProxyCached code path, which serves
upstream bytes through unchanged (apk, debian, rpm, go, hex, conda,
cran, gem, conan, julia). That path now requests identity on both the
cached fetch and the uncached stream branch and replays Content-Encoding;
direct callers keep transparent compression, matching main. Migration
008 clears etag/fetched_at so legacy rows refetch once with identity.
Tests now exercise the stream path with no client Accept-Encoding and
pin that direct callers are not forced to identity.
2026-09-03 10:58:58 +02:00
|
|
|
}
|
|
|
|
|
if meta.contentType == "" {
|
|
|
|
|
meta.contentType = contentTypeJSON
|
2026-03-19 21:06:02 +00:00
|
|
|
}
|
2026-09-03 16:59:12 +01:00
|
|
|
if lm := resp.Header.Get(headerLastModified); lm != "" {
|
fix(handler): preserve upstream content-encoding for cached metadata (#304)
* fix(handler): preserve upstream content-encoding for cached metadata
Signed and hash-pinned index files (debian Release/Packages.gz, rpm
repomd.xml, helm index.yaml, conda repodata.json, apk APKINDEX.tar.gz)
were cached after Go's transport transparently decompressed any
Content-Encoding: gzip response, so the proxy served bytes that differ
from what the upstream signed and broke client verification.
Request the identity encoding on the shared metadata fetch so Go no
longer auto-decompresses, and persist the upstream Content-Encoding in
a new metadata_cache column so both the cached and offline responses
replay the exact bytes and header. The uncached streaming path now
forwards Content-Encoding too.
Fixes #300
* fix(handler): scope verbatim metadata fetch to the ProxyCached path
The first cut forced Accept-Encoding: identity in the shared
fetchUpstreamMetadata, which an adversarial review showed both missed
the bug and regressed unrelated ecosystems:
- proxyMetadataStream (the default path, since cache_metadata is off)
never forced identity, so a client sending no Accept-Encoding still
triggered Go's transparent gzip decompression and served altered bytes
for apk/debian/rpm/conda indexes.
- Direct FetchOrCacheMetadata callers that parse or rewrite the body
(npm, pypi, cargo, composer, pub, nuget, swift, maven, helm) were
forced to identity too, losing wire compression and 502-ing against
upstreams that ignore identity and gzip anyway (helm rewriteIndex).
- Rows cached before the fix kept serving decompressed bytes via ETag
304 revalidation.
Scope the verbatim behavior to the ProxyCached code path, which serves
upstream bytes through unchanged (apk, debian, rpm, go, hex, conda,
cran, gem, conan, julia). That path now requests identity on both the
cached fetch and the uncached stream branch and replays Content-Encoding;
direct callers keep transparent compression, matching main. Migration
008 clears etag/fetched_at so legacy rows refetch once with identity.
Tests now exercise the stream path with no client Accept-Encoding and
pin that direct callers are not forced to identity.
2026-09-03 10:58:58 +02:00
|
|
|
meta.lastModified, _ = http.ParseTime(lm)
|
Fix metadata caching, 404 propagation, mirror progress, and registry stubs
- ProxyCached now stores upstream Last-Modified in the cache and uses it
(along with ETag) for conditional request handling, returning 304 when
client validators match. Adds Content-Length to cached responses.
- Handlers calling FetchOrCacheMetadata (pypi, composer, pub, nuget) now
check for ErrUpstreamNotFound and return 404 instead of 502, matching
the existing npm and cargo behavior.
- Mirror jobs report live progress via a periodic callback while running,
so API polls return real counts instead of zeroed progress.
- Registry mirroring removed from CLI flags, API acceptance, README, and
docs since every enumerator was a stub returning "not yet implemented".
- Added tests for the conditional metadata path (ETag/If-None-Match,
Last-Modified/If-Modified-Since, 304 responses, header omission).
2026-04-01 20:14:11 +01:00
|
|
|
}
|
fix(handler): preserve upstream content-encoding for cached metadata (#304)
* fix(handler): preserve upstream content-encoding for cached metadata
Signed and hash-pinned index files (debian Release/Packages.gz, rpm
repomd.xml, helm index.yaml, conda repodata.json, apk APKINDEX.tar.gz)
were cached after Go's transport transparently decompressed any
Content-Encoding: gzip response, so the proxy served bytes that differ
from what the upstream signed and broke client verification.
Request the identity encoding on the shared metadata fetch so Go no
longer auto-decompresses, and persist the upstream Content-Encoding in
a new metadata_cache column so both the cached and offline responses
replay the exact bytes and header. The uncached streaming path now
forwards Content-Encoding too.
Fixes #300
* fix(handler): scope verbatim metadata fetch to the ProxyCached path
The first cut forced Accept-Encoding: identity in the shared
fetchUpstreamMetadata, which an adversarial review showed both missed
the bug and regressed unrelated ecosystems:
- proxyMetadataStream (the default path, since cache_metadata is off)
never forced identity, so a client sending no Accept-Encoding still
triggered Go's transparent gzip decompression and served altered bytes
for apk/debian/rpm/conda indexes.
- Direct FetchOrCacheMetadata callers that parse or rewrite the body
(npm, pypi, cargo, composer, pub, nuget, swift, maven, helm) were
forced to identity too, losing wire compression and 502-ing against
upstreams that ignore identity and gzip anyway (helm rewriteIndex).
- Rows cached before the fix kept serving decompressed bytes via ETag
304 revalidation.
Scope the verbatim behavior to the ProxyCached code path, which serves
upstream bytes through unchanged (apk, debian, rpm, go, hex, conda,
cran, gem, conan, julia). That path now requests identity on both the
cached fetch and the uncached stream branch and replays Content-Encoding;
direct callers keep transparent compression, matching main. Migration
008 clears etag/fetched_at so legacy rows refetch once with identity.
Tests now exercise the stream path with no client Accept-Encoding and
pin that direct callers are not forced to identity.
2026-09-03 10:58:58 +02:00
|
|
|
return meta, nil
|
2026-03-18 10:59:29 +00:00
|
|
|
}
|
|
|
|
|
|
2026-10-09 08:10:41 +02:00
|
|
|
// cacheMetadataBlob stores metadata bytes in storage and updates the
|
|
|
|
|
// database. It returns the digest it recorded for the bytes, or "" when they
|
|
|
|
|
// were not cached.
|
|
|
|
|
func (p *Proxy) cacheMetadataBlob(ctx context.Context, ecosystem, cacheKey, storagePath string, meta *upstreamMetadata) string {
|
2026-03-19 21:06:02 +00:00
|
|
|
if p.DB == nil || p.Storage == nil {
|
2026-10-09 08:10:41 +02:00
|
|
|
return ""
|
2026-03-19 21:06:02 +00:00
|
|
|
}
|
|
|
|
|
|
2026-10-08 07:36:33 +02:00
|
|
|
size, hash, err := p.Storage.Store(ctx, storagePath, bytes.NewReader(meta.body))
|
2026-03-19 21:06:02 +00:00
|
|
|
if err != nil {
|
|
|
|
|
p.Logger.Warn("failed to cache metadata", "ecosystem", ecosystem, "key", cacheKey, "error", err)
|
2026-10-09 08:10:41 +02:00
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
var digest string
|
|
|
|
|
if hash != "" {
|
|
|
|
|
digest = "sha256:" + hash
|
2026-03-19 21:06:02 +00:00
|
|
|
}
|
|
|
|
|
|
fix(handler): let the ProxyCached path request a per-call Accept-Encoding (#324)
* fix(handler): fetch conda repodata gzip-compressed on both hops
#304 made the ProxyCached path request Accept-Encoding: identity so the
metadata cache stores upstream bytes verbatim. That is required for the
signed / hash-pinned index ecosystems, but conda's repodata.json is large
plain JSON: linux-64 repodata.json is ~441 MB uncompressed (over the
metadata_max_size cap, so it 502s today) versus ~34 MB gzip.
Replace the ProxyCached path's verbatim bool with an explicit
acceptEncoding string ('' = leave unset / transparent, 'identity', or
'gzip'), reusing #304's existing store-and-replay of Content-Encoding
unchanged. ProxyCached keeps its exported signature and continues to send
identity, so the nine other ecosystems and helm/maven are untouched; only
conda's repodata.json / current_repodata.json now request gzip. Setting
Accept-Encoding explicitly disables Go's transparent decompression, so the
compressed bytes and the Content-Encoding: gzip header are cached and
replayed exactly as identity bytes are. conda, mamba and pixi solicit and
decode gzip on .json URLs; repodata.json.bz2 stays identity.
Fixes #305
* fix(handler): pass metadata content-encoding with the body it describes
The adversarial review of the conda gzip route found a reachable
regression: writeMetadataCachedResponse took Content-Encoding from a
fresh cache-row read while cacheMetadataBlob skips the row write when
Storage.Store fails. Under identity that was benign (the body was plain
anyway), but on the new gzip route a disk-full or object-store outage
served raw gzip bytes as Content-Type: application/json with no
Content-Encoding and HTTP 200 -- conda, mamba and pixi fail to parse
them, with no HTTP signal and only a Warn log, on every request until a
cache write succeeds.
fetchOrCacheMetadata now returns the encoding of the body it hands back
(the upstream value on a fetch, the stored row's value on a TTL hit or
stale fallback) and proxyCachedWithEncoding passes it to
writeMetadataCachedResponse, so the header always describes the bytes
actually written. cachedMeta drops its now-unused content_encoding
field. helm and maven pass "" -- both fetch transparently, so their
stored encoding was always empty and behaviour is unchanged.
Also fixes a vacuous assertion in the new conda test: the upstream
request counter incremented behind the availability gate, so the
cached-replay block could never observe a refetch.
* fix(handler): pin the stale-fallback content-encoding and drop a dead guard
Follow-ups from the adversarial review of the #305 branch, limited to
code this branch introduced:
- proxyMetadataStream is only ever reached with an explicit
Accept-Encoding (ProxyCached passes identity, conda passes gzip or
identity), so the guard around the header set was unreachable; replace
it with the plain one-token substitution of the former literal, which
is the smallest change from main.
- The stale-fallback return of fetchOrCacheMetadata (encoding taken from
the cache row) was the one #305 return site no test pinned: replacing
it with an empty encoding survived the whole suite. Add a conda test
that expires the entry, fails the upstream, and asserts the stored
gzip blob is served with Content-Encoding: gzip.
Not changed, by scope: cacheMetadataBlob still discards the
UpsertMetadataCache error (pre-existing on main). If Storage.Store
succeeds and the row write fails, a later stale fallback or TTL hit can
serve the gzip blob with the row's stale encoding; that needs a DB write
failure plus a second event and is tracked separately.
* fix(handler): restore the pre-existing cachedMeta content-encoding field
The third adversarial review classified deleting cachedMeta.contentEncoding
and its lookupCachedMeta populate as elective: neither line was created by
this branch nor forced by the fix (writeMetadataCachedResponse now reads
the encoding from its parameter and ignores the row value). Under the rule
that pre-existing code this branch did not have to touch stays untouched,
restore both as they are on main. No behaviour change.
Residuals the review documented, unchanged by scope (both share one root
cause: the encoding lives in the cache row and the bytes in the blob, and
neither is written or read atomically):
- cacheMetadataBlob discards the UpsertMetadataCache error, so after a
successful gzip Store and a failed row write a later stale fallback or
TTL hit can serve the gzip blob with the row's stale encoding.
- During the one-time identity->gzip rollout, a request that read a
pre-branch identity row, lost the upstream race to a request that stored
the gzip blob, and then failed upstream serves the gzip bytes with no
Content-Encoding for that one response; later requests self-heal.
- helm and maven now pass an empty encoding; on main a spec-violating
upstream that answered a transparent gzip request with an encoding Go
does not decode (e.g. br) would have had that header replayed from the
row. Degenerate; documented rather than changed.
* fix(handler): keep conda's proxyCached and .bz2 route as on main
Threading acceptEncoding through CondaHandler.proxyCached changed the
form of two pieces of original code the fix did not need to touch: the
repodata.json.bz2 route (method value rewritten as a closure) and
proxyCached itself (new parameter, new call). Restore both exactly as on
main; ProxyCached still sends identity, so the .bz2 route is unchanged in
behaviour. handleRepodata's non-cooldown branch now derives the cache key
inline and calls proxyCachedWithEncoding with gzip directly, so the only
original conda.go line that changes is that one call.
* fix(handler): keep writeMetadataCachedResponse and its callers as on main
Adding a contentEncoding parameter to writeMetadataCachedResponse changed
a signature that predates #304 and dragged its two pre-#304 callers
(helm.go, maven.go) into the diff, even though #304 only ever added the
cm.contentEncoding block inside the function body.
Restore writeMetadataCachedResponse's doc and signature exactly as on
main and make it a delegate that passes an empty encoding to a new
unexported writeMetadataCachedResponseWithEncoding, which carries the
original body with #304's block reading the parameter instead of the
cache row. proxyCachedWithEncoding calls the sibling with the encoding
returned alongside the body. helm.go and maven.go drop out of the diff;
their behaviour is unchanged (both fetch transparently, so their stored
encoding was always empty). Same split pattern as ProxyCached ->
proxyCachedWithEncoding.
* fix(handler): move the conda gzip change to its own branch
The conda call site in handleRepodata predates #304 and #304 never
touched it, so under the rule that this PR only corrects code and
behaviour #304 introduced it does not belong here. Restore conda.go and
conda_test.go as on main; the conda change continues on a stacked branch
against its own issue.
Replace the conda-route tests with tests that exercise
proxyCachedWithEncoding directly, so this PR still pins its own plumbing:
gzip is requested and the compressed bytes plus Content-Encoding are
cached and replayed (cached and streaming paths), the header survives a
metadata cache write failure, and the stale fallback keeps the stored
encoding.
* fix(homebrew): fetch the JSON API gzip-compressed on both hops
Homebrew (#254) routes every API path through ProxyCached and so, since
#304, fetches formula.jws.json (~33 MB plain, ~5 MB gzip) uncompressed on
every refresh -- the case that motivated #305.
Request gzip for the JSON API via proxyCachedWithEncoding: brew fetches
every API download with curl --compressed and decodes Content-Encoding
itself, so the compressed bytes and header are cached and served as-is
and both hops stay compressed. The analytics endpoints are the one brew
consumer fetched without --compressed; they stay on identity.
* fix(handler): leave Accept-Encoding unset in proxyMetadataStream for an empty value
fetchUpstreamMetadata treats an empty acceptEncoding as 'do not set the
header'; proxyMetadataStream set it unconditionally, which would send an
empty Accept-Encoding line if a caller ever passed . Guard it the same
way so both paths agree. No caller passes today.
* fix(handler): keep the metadata row and blob from describing different bytes
Two ways the cache row could stop describing the stored blob once a
caller requests gzip, both raised by the review of #324:
- cacheMetadataBlob stored the blob and then discarded the
UpsertMetadataCache error. After a successful gzip store and a failed
row write, a later TTL hit or stale fallback served the gzip blob with
the previous row's encoding. On a row-write failure, log it and delete
the blob just written, so the next request refetches instead.
- fetchOrCacheMetadata read the row once up front and reused it for the
stale fallback. A request that read an identity row, lost the upstream
race to a request that stored the gzip blob, and then failed upstream
labelled the new blob with the old row. Re-read the row before falling
back so the encoding matches the blob as it is now.
Both only become harmful with an encoding change, which this branch
introduces; the pre-existing validator-from-row read is tracked
separately.
* Drop unused cachedMeta.contentEncoding and fix stale doc reference
The field was added by #304 and its only reader is replaced in this
branch by the encoding parameter passed alongside the body. The
proxyCachedWithEncoding comment named conda repodata, which was moved
out of this branch in 5991d95; Homebrew is the caller that ships here.
---------
Co-authored-by: Andrew Nesbitt <andrewnez@gmail.com>
2026-09-15 20:50:44 +02:00
|
|
|
err = p.DB.UpsertMetadataCache(&database.MetadataCacheEntry{
|
fix(handler): preserve upstream content-encoding for cached metadata (#304)
* fix(handler): preserve upstream content-encoding for cached metadata
Signed and hash-pinned index files (debian Release/Packages.gz, rpm
repomd.xml, helm index.yaml, conda repodata.json, apk APKINDEX.tar.gz)
were cached after Go's transport transparently decompressed any
Content-Encoding: gzip response, so the proxy served bytes that differ
from what the upstream signed and broke client verification.
Request the identity encoding on the shared metadata fetch so Go no
longer auto-decompresses, and persist the upstream Content-Encoding in
a new metadata_cache column so both the cached and offline responses
replay the exact bytes and header. The uncached streaming path now
forwards Content-Encoding too.
Fixes #300
* fix(handler): scope verbatim metadata fetch to the ProxyCached path
The first cut forced Accept-Encoding: identity in the shared
fetchUpstreamMetadata, which an adversarial review showed both missed
the bug and regressed unrelated ecosystems:
- proxyMetadataStream (the default path, since cache_metadata is off)
never forced identity, so a client sending no Accept-Encoding still
triggered Go's transparent gzip decompression and served altered bytes
for apk/debian/rpm/conda indexes.
- Direct FetchOrCacheMetadata callers that parse or rewrite the body
(npm, pypi, cargo, composer, pub, nuget, swift, maven, helm) were
forced to identity too, losing wire compression and 502-ing against
upstreams that ignore identity and gzip anyway (helm rewriteIndex).
- Rows cached before the fix kept serving decompressed bytes via ETag
304 revalidation.
Scope the verbatim behavior to the ProxyCached code path, which serves
upstream bytes through unchanged (apk, debian, rpm, go, hex, conda,
cran, gem, conan, julia). That path now requests identity on both the
cached fetch and the uncached stream branch and replays Content-Encoding;
direct callers keep transparent compression, matching main. Migration
008 clears etag/fetched_at so legacy rows refetch once with identity.
Tests now exercise the stream path with no client Accept-Encoding and
pin that direct callers are not forced to identity.
2026-09-03 10:58:58 +02:00
|
|
|
Ecosystem: ecosystem,
|
|
|
|
|
Name: cacheKey,
|
|
|
|
|
StoragePath: storagePath,
|
|
|
|
|
ETag: sql.NullString{String: meta.etag, Valid: meta.etag != ""},
|
|
|
|
|
ContentType: sql.NullString{String: meta.contentType, Valid: meta.contentType != ""},
|
|
|
|
|
ContentEncoding: sql.NullString{String: meta.contentEncoding, Valid: meta.contentEncoding != ""},
|
2026-10-08 07:36:33 +02:00
|
|
|
// The digest identifies the stored bytes, so a rewrite cached for them
|
|
|
|
|
// can be found without reading them back (see storedRewrite).
|
2026-10-09 08:10:41 +02:00
|
|
|
ContentDigest: sql.NullString{String: digest, Valid: digest != ""},
|
2026-10-08 07:36:33 +02:00
|
|
|
Size: sql.NullInt64{Int64: size, Valid: true},
|
|
|
|
|
LastModified: sql.NullTime{Time: meta.lastModified, Valid: !meta.lastModified.IsZero()},
|
|
|
|
|
FetchedAt: sql.NullTime{Time: time.Now(), Valid: true},
|
2026-03-19 21:06:02 +00:00
|
|
|
})
|
fix(handler): let the ProxyCached path request a per-call Accept-Encoding (#324)
* fix(handler): fetch conda repodata gzip-compressed on both hops
#304 made the ProxyCached path request Accept-Encoding: identity so the
metadata cache stores upstream bytes verbatim. That is required for the
signed / hash-pinned index ecosystems, but conda's repodata.json is large
plain JSON: linux-64 repodata.json is ~441 MB uncompressed (over the
metadata_max_size cap, so it 502s today) versus ~34 MB gzip.
Replace the ProxyCached path's verbatim bool with an explicit
acceptEncoding string ('' = leave unset / transparent, 'identity', or
'gzip'), reusing #304's existing store-and-replay of Content-Encoding
unchanged. ProxyCached keeps its exported signature and continues to send
identity, so the nine other ecosystems and helm/maven are untouched; only
conda's repodata.json / current_repodata.json now request gzip. Setting
Accept-Encoding explicitly disables Go's transparent decompression, so the
compressed bytes and the Content-Encoding: gzip header are cached and
replayed exactly as identity bytes are. conda, mamba and pixi solicit and
decode gzip on .json URLs; repodata.json.bz2 stays identity.
Fixes #305
* fix(handler): pass metadata content-encoding with the body it describes
The adversarial review of the conda gzip route found a reachable
regression: writeMetadataCachedResponse took Content-Encoding from a
fresh cache-row read while cacheMetadataBlob skips the row write when
Storage.Store fails. Under identity that was benign (the body was plain
anyway), but on the new gzip route a disk-full or object-store outage
served raw gzip bytes as Content-Type: application/json with no
Content-Encoding and HTTP 200 -- conda, mamba and pixi fail to parse
them, with no HTTP signal and only a Warn log, on every request until a
cache write succeeds.
fetchOrCacheMetadata now returns the encoding of the body it hands back
(the upstream value on a fetch, the stored row's value on a TTL hit or
stale fallback) and proxyCachedWithEncoding passes it to
writeMetadataCachedResponse, so the header always describes the bytes
actually written. cachedMeta drops its now-unused content_encoding
field. helm and maven pass "" -- both fetch transparently, so their
stored encoding was always empty and behaviour is unchanged.
Also fixes a vacuous assertion in the new conda test: the upstream
request counter incremented behind the availability gate, so the
cached-replay block could never observe a refetch.
* fix(handler): pin the stale-fallback content-encoding and drop a dead guard
Follow-ups from the adversarial review of the #305 branch, limited to
code this branch introduced:
- proxyMetadataStream is only ever reached with an explicit
Accept-Encoding (ProxyCached passes identity, conda passes gzip or
identity), so the guard around the header set was unreachable; replace
it with the plain one-token substitution of the former literal, which
is the smallest change from main.
- The stale-fallback return of fetchOrCacheMetadata (encoding taken from
the cache row) was the one #305 return site no test pinned: replacing
it with an empty encoding survived the whole suite. Add a conda test
that expires the entry, fails the upstream, and asserts the stored
gzip blob is served with Content-Encoding: gzip.
Not changed, by scope: cacheMetadataBlob still discards the
UpsertMetadataCache error (pre-existing on main). If Storage.Store
succeeds and the row write fails, a later stale fallback or TTL hit can
serve the gzip blob with the row's stale encoding; that needs a DB write
failure plus a second event and is tracked separately.
* fix(handler): restore the pre-existing cachedMeta content-encoding field
The third adversarial review classified deleting cachedMeta.contentEncoding
and its lookupCachedMeta populate as elective: neither line was created by
this branch nor forced by the fix (writeMetadataCachedResponse now reads
the encoding from its parameter and ignores the row value). Under the rule
that pre-existing code this branch did not have to touch stays untouched,
restore both as they are on main. No behaviour change.
Residuals the review documented, unchanged by scope (both share one root
cause: the encoding lives in the cache row and the bytes in the blob, and
neither is written or read atomically):
- cacheMetadataBlob discards the UpsertMetadataCache error, so after a
successful gzip Store and a failed row write a later stale fallback or
TTL hit can serve the gzip blob with the row's stale encoding.
- During the one-time identity->gzip rollout, a request that read a
pre-branch identity row, lost the upstream race to a request that stored
the gzip blob, and then failed upstream serves the gzip bytes with no
Content-Encoding for that one response; later requests self-heal.
- helm and maven now pass an empty encoding; on main a spec-violating
upstream that answered a transparent gzip request with an encoding Go
does not decode (e.g. br) would have had that header replayed from the
row. Degenerate; documented rather than changed.
* fix(handler): keep conda's proxyCached and .bz2 route as on main
Threading acceptEncoding through CondaHandler.proxyCached changed the
form of two pieces of original code the fix did not need to touch: the
repodata.json.bz2 route (method value rewritten as a closure) and
proxyCached itself (new parameter, new call). Restore both exactly as on
main; ProxyCached still sends identity, so the .bz2 route is unchanged in
behaviour. handleRepodata's non-cooldown branch now derives the cache key
inline and calls proxyCachedWithEncoding with gzip directly, so the only
original conda.go line that changes is that one call.
* fix(handler): keep writeMetadataCachedResponse and its callers as on main
Adding a contentEncoding parameter to writeMetadataCachedResponse changed
a signature that predates #304 and dragged its two pre-#304 callers
(helm.go, maven.go) into the diff, even though #304 only ever added the
cm.contentEncoding block inside the function body.
Restore writeMetadataCachedResponse's doc and signature exactly as on
main and make it a delegate that passes an empty encoding to a new
unexported writeMetadataCachedResponseWithEncoding, which carries the
original body with #304's block reading the parameter instead of the
cache row. proxyCachedWithEncoding calls the sibling with the encoding
returned alongside the body. helm.go and maven.go drop out of the diff;
their behaviour is unchanged (both fetch transparently, so their stored
encoding was always empty). Same split pattern as ProxyCached ->
proxyCachedWithEncoding.
* fix(handler): move the conda gzip change to its own branch
The conda call site in handleRepodata predates #304 and #304 never
touched it, so under the rule that this PR only corrects code and
behaviour #304 introduced it does not belong here. Restore conda.go and
conda_test.go as on main; the conda change continues on a stacked branch
against its own issue.
Replace the conda-route tests with tests that exercise
proxyCachedWithEncoding directly, so this PR still pins its own plumbing:
gzip is requested and the compressed bytes plus Content-Encoding are
cached and replayed (cached and streaming paths), the header survives a
metadata cache write failure, and the stale fallback keeps the stored
encoding.
* fix(homebrew): fetch the JSON API gzip-compressed on both hops
Homebrew (#254) routes every API path through ProxyCached and so, since
#304, fetches formula.jws.json (~33 MB plain, ~5 MB gzip) uncompressed on
every refresh -- the case that motivated #305.
Request gzip for the JSON API via proxyCachedWithEncoding: brew fetches
every API download with curl --compressed and decodes Content-Encoding
itself, so the compressed bytes and header are cached and served as-is
and both hops stay compressed. The analytics endpoints are the one brew
consumer fetched without --compressed; they stay on identity.
* fix(handler): leave Accept-Encoding unset in proxyMetadataStream for an empty value
fetchUpstreamMetadata treats an empty acceptEncoding as 'do not set the
header'; proxyMetadataStream set it unconditionally, which would send an
empty Accept-Encoding line if a caller ever passed . Guard it the same
way so both paths agree. No caller passes today.
* fix(handler): keep the metadata row and blob from describing different bytes
Two ways the cache row could stop describing the stored blob once a
caller requests gzip, both raised by the review of #324:
- cacheMetadataBlob stored the blob and then discarded the
UpsertMetadataCache error. After a successful gzip store and a failed
row write, a later TTL hit or stale fallback served the gzip blob with
the previous row's encoding. On a row-write failure, log it and delete
the blob just written, so the next request refetches instead.
- fetchOrCacheMetadata read the row once up front and reused it for the
stale fallback. A request that read an identity row, lost the upstream
race to a request that stored the gzip blob, and then failed upstream
labelled the new blob with the old row. Re-read the row before falling
back so the encoding matches the blob as it is now.
Both only become harmful with an encoding change, which this branch
introduces; the pre-existing validator-from-row read is tracked
separately.
* Drop unused cachedMeta.contentEncoding and fix stale doc reference
The field was added by #304 and its only reader is replaced in this
branch by the encoding parameter passed alongside the body. The
proxyCachedWithEncoding comment named conda repodata, which was moved
out of this branch in 5991d95; Homebrew is the caller that ships here.
---------
Co-authored-by: Andrew Nesbitt <andrewnez@gmail.com>
2026-09-15 20:50:44 +02:00
|
|
|
if err != nil {
|
|
|
|
|
// The blob is written but the row describing it is not, so a later
|
|
|
|
|
// TTL hit or stale fallback would serve these bytes with the previous
|
|
|
|
|
// row's encoding. Drop the blob so row and bytes can never disagree;
|
|
|
|
|
// the next request refetches instead.
|
|
|
|
|
p.Logger.Warn("failed to record cached metadata, discarding blob", "ecosystem", ecosystem, "key", cacheKey, "error", err)
|
|
|
|
|
if delErr := p.Storage.Delete(ctx, storagePath); delErr != nil {
|
|
|
|
|
p.Logger.Warn("failed to discard metadata blob", "ecosystem", ecosystem, "key", cacheKey, "error", delErr)
|
|
|
|
|
}
|
2026-10-09 08:10:41 +02:00
|
|
|
return ""
|
fix(handler): let the ProxyCached path request a per-call Accept-Encoding (#324)
* fix(handler): fetch conda repodata gzip-compressed on both hops
#304 made the ProxyCached path request Accept-Encoding: identity so the
metadata cache stores upstream bytes verbatim. That is required for the
signed / hash-pinned index ecosystems, but conda's repodata.json is large
plain JSON: linux-64 repodata.json is ~441 MB uncompressed (over the
metadata_max_size cap, so it 502s today) versus ~34 MB gzip.
Replace the ProxyCached path's verbatim bool with an explicit
acceptEncoding string ('' = leave unset / transparent, 'identity', or
'gzip'), reusing #304's existing store-and-replay of Content-Encoding
unchanged. ProxyCached keeps its exported signature and continues to send
identity, so the nine other ecosystems and helm/maven are untouched; only
conda's repodata.json / current_repodata.json now request gzip. Setting
Accept-Encoding explicitly disables Go's transparent decompression, so the
compressed bytes and the Content-Encoding: gzip header are cached and
replayed exactly as identity bytes are. conda, mamba and pixi solicit and
decode gzip on .json URLs; repodata.json.bz2 stays identity.
Fixes #305
* fix(handler): pass metadata content-encoding with the body it describes
The adversarial review of the conda gzip route found a reachable
regression: writeMetadataCachedResponse took Content-Encoding from a
fresh cache-row read while cacheMetadataBlob skips the row write when
Storage.Store fails. Under identity that was benign (the body was plain
anyway), but on the new gzip route a disk-full or object-store outage
served raw gzip bytes as Content-Type: application/json with no
Content-Encoding and HTTP 200 -- conda, mamba and pixi fail to parse
them, with no HTTP signal and only a Warn log, on every request until a
cache write succeeds.
fetchOrCacheMetadata now returns the encoding of the body it hands back
(the upstream value on a fetch, the stored row's value on a TTL hit or
stale fallback) and proxyCachedWithEncoding passes it to
writeMetadataCachedResponse, so the header always describes the bytes
actually written. cachedMeta drops its now-unused content_encoding
field. helm and maven pass "" -- both fetch transparently, so their
stored encoding was always empty and behaviour is unchanged.
Also fixes a vacuous assertion in the new conda test: the upstream
request counter incremented behind the availability gate, so the
cached-replay block could never observe a refetch.
* fix(handler): pin the stale-fallback content-encoding and drop a dead guard
Follow-ups from the adversarial review of the #305 branch, limited to
code this branch introduced:
- proxyMetadataStream is only ever reached with an explicit
Accept-Encoding (ProxyCached passes identity, conda passes gzip or
identity), so the guard around the header set was unreachable; replace
it with the plain one-token substitution of the former literal, which
is the smallest change from main.
- The stale-fallback return of fetchOrCacheMetadata (encoding taken from
the cache row) was the one #305 return site no test pinned: replacing
it with an empty encoding survived the whole suite. Add a conda test
that expires the entry, fails the upstream, and asserts the stored
gzip blob is served with Content-Encoding: gzip.
Not changed, by scope: cacheMetadataBlob still discards the
UpsertMetadataCache error (pre-existing on main). If Storage.Store
succeeds and the row write fails, a later stale fallback or TTL hit can
serve the gzip blob with the row's stale encoding; that needs a DB write
failure plus a second event and is tracked separately.
* fix(handler): restore the pre-existing cachedMeta content-encoding field
The third adversarial review classified deleting cachedMeta.contentEncoding
and its lookupCachedMeta populate as elective: neither line was created by
this branch nor forced by the fix (writeMetadataCachedResponse now reads
the encoding from its parameter and ignores the row value). Under the rule
that pre-existing code this branch did not have to touch stays untouched,
restore both as they are on main. No behaviour change.
Residuals the review documented, unchanged by scope (both share one root
cause: the encoding lives in the cache row and the bytes in the blob, and
neither is written or read atomically):
- cacheMetadataBlob discards the UpsertMetadataCache error, so after a
successful gzip Store and a failed row write a later stale fallback or
TTL hit can serve the gzip blob with the row's stale encoding.
- During the one-time identity->gzip rollout, a request that read a
pre-branch identity row, lost the upstream race to a request that stored
the gzip blob, and then failed upstream serves the gzip bytes with no
Content-Encoding for that one response; later requests self-heal.
- helm and maven now pass an empty encoding; on main a spec-violating
upstream that answered a transparent gzip request with an encoding Go
does not decode (e.g. br) would have had that header replayed from the
row. Degenerate; documented rather than changed.
* fix(handler): keep conda's proxyCached and .bz2 route as on main
Threading acceptEncoding through CondaHandler.proxyCached changed the
form of two pieces of original code the fix did not need to touch: the
repodata.json.bz2 route (method value rewritten as a closure) and
proxyCached itself (new parameter, new call). Restore both exactly as on
main; ProxyCached still sends identity, so the .bz2 route is unchanged in
behaviour. handleRepodata's non-cooldown branch now derives the cache key
inline and calls proxyCachedWithEncoding with gzip directly, so the only
original conda.go line that changes is that one call.
* fix(handler): keep writeMetadataCachedResponse and its callers as on main
Adding a contentEncoding parameter to writeMetadataCachedResponse changed
a signature that predates #304 and dragged its two pre-#304 callers
(helm.go, maven.go) into the diff, even though #304 only ever added the
cm.contentEncoding block inside the function body.
Restore writeMetadataCachedResponse's doc and signature exactly as on
main and make it a delegate that passes an empty encoding to a new
unexported writeMetadataCachedResponseWithEncoding, which carries the
original body with #304's block reading the parameter instead of the
cache row. proxyCachedWithEncoding calls the sibling with the encoding
returned alongside the body. helm.go and maven.go drop out of the diff;
their behaviour is unchanged (both fetch transparently, so their stored
encoding was always empty). Same split pattern as ProxyCached ->
proxyCachedWithEncoding.
* fix(handler): move the conda gzip change to its own branch
The conda call site in handleRepodata predates #304 and #304 never
touched it, so under the rule that this PR only corrects code and
behaviour #304 introduced it does not belong here. Restore conda.go and
conda_test.go as on main; the conda change continues on a stacked branch
against its own issue.
Replace the conda-route tests with tests that exercise
proxyCachedWithEncoding directly, so this PR still pins its own plumbing:
gzip is requested and the compressed bytes plus Content-Encoding are
cached and replayed (cached and streaming paths), the header survives a
metadata cache write failure, and the stale fallback keeps the stored
encoding.
* fix(homebrew): fetch the JSON API gzip-compressed on both hops
Homebrew (#254) routes every API path through ProxyCached and so, since
#304, fetches formula.jws.json (~33 MB plain, ~5 MB gzip) uncompressed on
every refresh -- the case that motivated #305.
Request gzip for the JSON API via proxyCachedWithEncoding: brew fetches
every API download with curl --compressed and decodes Content-Encoding
itself, so the compressed bytes and header are cached and served as-is
and both hops stay compressed. The analytics endpoints are the one brew
consumer fetched without --compressed; they stay on identity.
* fix(handler): leave Accept-Encoding unset in proxyMetadataStream for an empty value
fetchUpstreamMetadata treats an empty acceptEncoding as 'do not set the
header'; proxyMetadataStream set it unconditionally, which would send an
empty Accept-Encoding line if a caller ever passed . Guard it the same
way so both paths agree. No caller passes today.
* fix(handler): keep the metadata row and blob from describing different bytes
Two ways the cache row could stop describing the stored blob once a
caller requests gzip, both raised by the review of #324:
- cacheMetadataBlob stored the blob and then discarded the
UpsertMetadataCache error. After a successful gzip store and a failed
row write, a later TTL hit or stale fallback served the gzip blob with
the previous row's encoding. On a row-write failure, log it and delete
the blob just written, so the next request refetches instead.
- fetchOrCacheMetadata read the row once up front and reused it for the
stale fallback. A request that read an identity row, lost the upstream
race to a request that stored the gzip blob, and then failed upstream
labelled the new blob with the old row. Re-read the row before falling
back so the encoding matches the blob as it is now.
Both only become harmful with an encoding change, which this branch
introduces; the pre-existing validator-from-row read is tracked
separately.
* Drop unused cachedMeta.contentEncoding and fix stale doc reference
The field was added by #304 and its only reader is replaced in this
branch by the encoding parameter passed alongside the body. The
proxyCachedWithEncoding comment named conda repodata, which was moved
out of this branch in 5991d95; Homebrew is the caller that ships here.
---------
Co-authored-by: Andrew Nesbitt <andrewnez@gmail.com>
2026-09-15 20:50:44 +02:00
|
|
|
}
|
2026-10-09 08:10:41 +02:00
|
|
|
return digest
|
fix(handler): let the ProxyCached path request a per-call Accept-Encoding (#324)
* fix(handler): fetch conda repodata gzip-compressed on both hops
#304 made the ProxyCached path request Accept-Encoding: identity so the
metadata cache stores upstream bytes verbatim. That is required for the
signed / hash-pinned index ecosystems, but conda's repodata.json is large
plain JSON: linux-64 repodata.json is ~441 MB uncompressed (over the
metadata_max_size cap, so it 502s today) versus ~34 MB gzip.
Replace the ProxyCached path's verbatim bool with an explicit
acceptEncoding string ('' = leave unset / transparent, 'identity', or
'gzip'), reusing #304's existing store-and-replay of Content-Encoding
unchanged. ProxyCached keeps its exported signature and continues to send
identity, so the nine other ecosystems and helm/maven are untouched; only
conda's repodata.json / current_repodata.json now request gzip. Setting
Accept-Encoding explicitly disables Go's transparent decompression, so the
compressed bytes and the Content-Encoding: gzip header are cached and
replayed exactly as identity bytes are. conda, mamba and pixi solicit and
decode gzip on .json URLs; repodata.json.bz2 stays identity.
Fixes #305
* fix(handler): pass metadata content-encoding with the body it describes
The adversarial review of the conda gzip route found a reachable
regression: writeMetadataCachedResponse took Content-Encoding from a
fresh cache-row read while cacheMetadataBlob skips the row write when
Storage.Store fails. Under identity that was benign (the body was plain
anyway), but on the new gzip route a disk-full or object-store outage
served raw gzip bytes as Content-Type: application/json with no
Content-Encoding and HTTP 200 -- conda, mamba and pixi fail to parse
them, with no HTTP signal and only a Warn log, on every request until a
cache write succeeds.
fetchOrCacheMetadata now returns the encoding of the body it hands back
(the upstream value on a fetch, the stored row's value on a TTL hit or
stale fallback) and proxyCachedWithEncoding passes it to
writeMetadataCachedResponse, so the header always describes the bytes
actually written. cachedMeta drops its now-unused content_encoding
field. helm and maven pass "" -- both fetch transparently, so their
stored encoding was always empty and behaviour is unchanged.
Also fixes a vacuous assertion in the new conda test: the upstream
request counter incremented behind the availability gate, so the
cached-replay block could never observe a refetch.
* fix(handler): pin the stale-fallback content-encoding and drop a dead guard
Follow-ups from the adversarial review of the #305 branch, limited to
code this branch introduced:
- proxyMetadataStream is only ever reached with an explicit
Accept-Encoding (ProxyCached passes identity, conda passes gzip or
identity), so the guard around the header set was unreachable; replace
it with the plain one-token substitution of the former literal, which
is the smallest change from main.
- The stale-fallback return of fetchOrCacheMetadata (encoding taken from
the cache row) was the one #305 return site no test pinned: replacing
it with an empty encoding survived the whole suite. Add a conda test
that expires the entry, fails the upstream, and asserts the stored
gzip blob is served with Content-Encoding: gzip.
Not changed, by scope: cacheMetadataBlob still discards the
UpsertMetadataCache error (pre-existing on main). If Storage.Store
succeeds and the row write fails, a later stale fallback or TTL hit can
serve the gzip blob with the row's stale encoding; that needs a DB write
failure plus a second event and is tracked separately.
* fix(handler): restore the pre-existing cachedMeta content-encoding field
The third adversarial review classified deleting cachedMeta.contentEncoding
and its lookupCachedMeta populate as elective: neither line was created by
this branch nor forced by the fix (writeMetadataCachedResponse now reads
the encoding from its parameter and ignores the row value). Under the rule
that pre-existing code this branch did not have to touch stays untouched,
restore both as they are on main. No behaviour change.
Residuals the review documented, unchanged by scope (both share one root
cause: the encoding lives in the cache row and the bytes in the blob, and
neither is written or read atomically):
- cacheMetadataBlob discards the UpsertMetadataCache error, so after a
successful gzip Store and a failed row write a later stale fallback or
TTL hit can serve the gzip blob with the row's stale encoding.
- During the one-time identity->gzip rollout, a request that read a
pre-branch identity row, lost the upstream race to a request that stored
the gzip blob, and then failed upstream serves the gzip bytes with no
Content-Encoding for that one response; later requests self-heal.
- helm and maven now pass an empty encoding; on main a spec-violating
upstream that answered a transparent gzip request with an encoding Go
does not decode (e.g. br) would have had that header replayed from the
row. Degenerate; documented rather than changed.
* fix(handler): keep conda's proxyCached and .bz2 route as on main
Threading acceptEncoding through CondaHandler.proxyCached changed the
form of two pieces of original code the fix did not need to touch: the
repodata.json.bz2 route (method value rewritten as a closure) and
proxyCached itself (new parameter, new call). Restore both exactly as on
main; ProxyCached still sends identity, so the .bz2 route is unchanged in
behaviour. handleRepodata's non-cooldown branch now derives the cache key
inline and calls proxyCachedWithEncoding with gzip directly, so the only
original conda.go line that changes is that one call.
* fix(handler): keep writeMetadataCachedResponse and its callers as on main
Adding a contentEncoding parameter to writeMetadataCachedResponse changed
a signature that predates #304 and dragged its two pre-#304 callers
(helm.go, maven.go) into the diff, even though #304 only ever added the
cm.contentEncoding block inside the function body.
Restore writeMetadataCachedResponse's doc and signature exactly as on
main and make it a delegate that passes an empty encoding to a new
unexported writeMetadataCachedResponseWithEncoding, which carries the
original body with #304's block reading the parameter instead of the
cache row. proxyCachedWithEncoding calls the sibling with the encoding
returned alongside the body. helm.go and maven.go drop out of the diff;
their behaviour is unchanged (both fetch transparently, so their stored
encoding was always empty). Same split pattern as ProxyCached ->
proxyCachedWithEncoding.
* fix(handler): move the conda gzip change to its own branch
The conda call site in handleRepodata predates #304 and #304 never
touched it, so under the rule that this PR only corrects code and
behaviour #304 introduced it does not belong here. Restore conda.go and
conda_test.go as on main; the conda change continues on a stacked branch
against its own issue.
Replace the conda-route tests with tests that exercise
proxyCachedWithEncoding directly, so this PR still pins its own plumbing:
gzip is requested and the compressed bytes plus Content-Encoding are
cached and replayed (cached and streaming paths), the header survives a
metadata cache write failure, and the stale fallback keeps the stored
encoding.
* fix(homebrew): fetch the JSON API gzip-compressed on both hops
Homebrew (#254) routes every API path through ProxyCached and so, since
#304, fetches formula.jws.json (~33 MB plain, ~5 MB gzip) uncompressed on
every refresh -- the case that motivated #305.
Request gzip for the JSON API via proxyCachedWithEncoding: brew fetches
every API download with curl --compressed and decodes Content-Encoding
itself, so the compressed bytes and header are cached and served as-is
and both hops stay compressed. The analytics endpoints are the one brew
consumer fetched without --compressed; they stay on identity.
* fix(handler): leave Accept-Encoding unset in proxyMetadataStream for an empty value
fetchUpstreamMetadata treats an empty acceptEncoding as 'do not set the
header'; proxyMetadataStream set it unconditionally, which would send an
empty Accept-Encoding line if a caller ever passed . Guard it the same
way so both paths agree. No caller passes today.
* fix(handler): keep the metadata row and blob from describing different bytes
Two ways the cache row could stop describing the stored blob once a
caller requests gzip, both raised by the review of #324:
- cacheMetadataBlob stored the blob and then discarded the
UpsertMetadataCache error. After a successful gzip store and a failed
row write, a later TTL hit or stale fallback served the gzip blob with
the previous row's encoding. On a row-write failure, log it and delete
the blob just written, so the next request refetches instead.
- fetchOrCacheMetadata read the row once up front and reused it for the
stale fallback. A request that read an identity row, lost the upstream
race to a request that stored the gzip blob, and then failed upstream
labelled the new blob with the old row. Re-read the row before falling
back so the encoding matches the blob as it is now.
Both only become harmful with an encoding change, which this branch
introduces; the pre-existing validator-from-row read is tracked
separately.
* Drop unused cachedMeta.contentEncoding and fix stale doc reference
The field was added by #304 and its only reader is replaced in this
branch by the encoding parameter passed alongside the body. The
proxyCachedWithEncoding comment named conda repodata, which was moved
out of this branch in 5991d95; Homebrew is the caller that ships here.
---------
Co-authored-by: Andrew Nesbitt <andrewnez@gmail.com>
2026-09-15 20:50:44 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// currentMetadataEntry re-reads the metadata cache row and returns it, or
|
|
|
|
|
// fallback when the row cannot be read. Used before serving a stored blob so
|
|
|
|
|
// its encoding comes from the row as it is now rather than from a snapshot
|
|
|
|
|
// taken before the upstream fetch.
|
|
|
|
|
func (p *Proxy) currentMetadataEntry(ecosystem, cacheKey string, fallback *database.MetadataCacheEntry) *database.MetadataCacheEntry {
|
|
|
|
|
if fresh, err := p.DB.GetMetadataCache(ecosystem, cacheKey); err == nil && fresh != nil {
|
|
|
|
|
return fresh
|
|
|
|
|
}
|
|
|
|
|
return fallback
|
2026-03-19 21:06:02 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-06 19:30:59 +01:00
|
|
|
// cachedMeta holds cache validators and freshness state from a metadata cache entry.
|
|
|
|
|
type cachedMeta struct {
|
fix(handler): let the ProxyCached path request a per-call Accept-Encoding (#324)
* fix(handler): fetch conda repodata gzip-compressed on both hops
#304 made the ProxyCached path request Accept-Encoding: identity so the
metadata cache stores upstream bytes verbatim. That is required for the
signed / hash-pinned index ecosystems, but conda's repodata.json is large
plain JSON: linux-64 repodata.json is ~441 MB uncompressed (over the
metadata_max_size cap, so it 502s today) versus ~34 MB gzip.
Replace the ProxyCached path's verbatim bool with an explicit
acceptEncoding string ('' = leave unset / transparent, 'identity', or
'gzip'), reusing #304's existing store-and-replay of Content-Encoding
unchanged. ProxyCached keeps its exported signature and continues to send
identity, so the nine other ecosystems and helm/maven are untouched; only
conda's repodata.json / current_repodata.json now request gzip. Setting
Accept-Encoding explicitly disables Go's transparent decompression, so the
compressed bytes and the Content-Encoding: gzip header are cached and
replayed exactly as identity bytes are. conda, mamba and pixi solicit and
decode gzip on .json URLs; repodata.json.bz2 stays identity.
Fixes #305
* fix(handler): pass metadata content-encoding with the body it describes
The adversarial review of the conda gzip route found a reachable
regression: writeMetadataCachedResponse took Content-Encoding from a
fresh cache-row read while cacheMetadataBlob skips the row write when
Storage.Store fails. Under identity that was benign (the body was plain
anyway), but on the new gzip route a disk-full or object-store outage
served raw gzip bytes as Content-Type: application/json with no
Content-Encoding and HTTP 200 -- conda, mamba and pixi fail to parse
them, with no HTTP signal and only a Warn log, on every request until a
cache write succeeds.
fetchOrCacheMetadata now returns the encoding of the body it hands back
(the upstream value on a fetch, the stored row's value on a TTL hit or
stale fallback) and proxyCachedWithEncoding passes it to
writeMetadataCachedResponse, so the header always describes the bytes
actually written. cachedMeta drops its now-unused content_encoding
field. helm and maven pass "" -- both fetch transparently, so their
stored encoding was always empty and behaviour is unchanged.
Also fixes a vacuous assertion in the new conda test: the upstream
request counter incremented behind the availability gate, so the
cached-replay block could never observe a refetch.
* fix(handler): pin the stale-fallback content-encoding and drop a dead guard
Follow-ups from the adversarial review of the #305 branch, limited to
code this branch introduced:
- proxyMetadataStream is only ever reached with an explicit
Accept-Encoding (ProxyCached passes identity, conda passes gzip or
identity), so the guard around the header set was unreachable; replace
it with the plain one-token substitution of the former literal, which
is the smallest change from main.
- The stale-fallback return of fetchOrCacheMetadata (encoding taken from
the cache row) was the one #305 return site no test pinned: replacing
it with an empty encoding survived the whole suite. Add a conda test
that expires the entry, fails the upstream, and asserts the stored
gzip blob is served with Content-Encoding: gzip.
Not changed, by scope: cacheMetadataBlob still discards the
UpsertMetadataCache error (pre-existing on main). If Storage.Store
succeeds and the row write fails, a later stale fallback or TTL hit can
serve the gzip blob with the row's stale encoding; that needs a DB write
failure plus a second event and is tracked separately.
* fix(handler): restore the pre-existing cachedMeta content-encoding field
The third adversarial review classified deleting cachedMeta.contentEncoding
and its lookupCachedMeta populate as elective: neither line was created by
this branch nor forced by the fix (writeMetadataCachedResponse now reads
the encoding from its parameter and ignores the row value). Under the rule
that pre-existing code this branch did not have to touch stays untouched,
restore both as they are on main. No behaviour change.
Residuals the review documented, unchanged by scope (both share one root
cause: the encoding lives in the cache row and the bytes in the blob, and
neither is written or read atomically):
- cacheMetadataBlob discards the UpsertMetadataCache error, so after a
successful gzip Store and a failed row write a later stale fallback or
TTL hit can serve the gzip blob with the row's stale encoding.
- During the one-time identity->gzip rollout, a request that read a
pre-branch identity row, lost the upstream race to a request that stored
the gzip blob, and then failed upstream serves the gzip bytes with no
Content-Encoding for that one response; later requests self-heal.
- helm and maven now pass an empty encoding; on main a spec-violating
upstream that answered a transparent gzip request with an encoding Go
does not decode (e.g. br) would have had that header replayed from the
row. Degenerate; documented rather than changed.
* fix(handler): keep conda's proxyCached and .bz2 route as on main
Threading acceptEncoding through CondaHandler.proxyCached changed the
form of two pieces of original code the fix did not need to touch: the
repodata.json.bz2 route (method value rewritten as a closure) and
proxyCached itself (new parameter, new call). Restore both exactly as on
main; ProxyCached still sends identity, so the .bz2 route is unchanged in
behaviour. handleRepodata's non-cooldown branch now derives the cache key
inline and calls proxyCachedWithEncoding with gzip directly, so the only
original conda.go line that changes is that one call.
* fix(handler): keep writeMetadataCachedResponse and its callers as on main
Adding a contentEncoding parameter to writeMetadataCachedResponse changed
a signature that predates #304 and dragged its two pre-#304 callers
(helm.go, maven.go) into the diff, even though #304 only ever added the
cm.contentEncoding block inside the function body.
Restore writeMetadataCachedResponse's doc and signature exactly as on
main and make it a delegate that passes an empty encoding to a new
unexported writeMetadataCachedResponseWithEncoding, which carries the
original body with #304's block reading the parameter instead of the
cache row. proxyCachedWithEncoding calls the sibling with the encoding
returned alongside the body. helm.go and maven.go drop out of the diff;
their behaviour is unchanged (both fetch transparently, so their stored
encoding was always empty). Same split pattern as ProxyCached ->
proxyCachedWithEncoding.
* fix(handler): move the conda gzip change to its own branch
The conda call site in handleRepodata predates #304 and #304 never
touched it, so under the rule that this PR only corrects code and
behaviour #304 introduced it does not belong here. Restore conda.go and
conda_test.go as on main; the conda change continues on a stacked branch
against its own issue.
Replace the conda-route tests with tests that exercise
proxyCachedWithEncoding directly, so this PR still pins its own plumbing:
gzip is requested and the compressed bytes plus Content-Encoding are
cached and replayed (cached and streaming paths), the header survives a
metadata cache write failure, and the stale fallback keeps the stored
encoding.
* fix(homebrew): fetch the JSON API gzip-compressed on both hops
Homebrew (#254) routes every API path through ProxyCached and so, since
#304, fetches formula.jws.json (~33 MB plain, ~5 MB gzip) uncompressed on
every refresh -- the case that motivated #305.
Request gzip for the JSON API via proxyCachedWithEncoding: brew fetches
every API download with curl --compressed and decodes Content-Encoding
itself, so the compressed bytes and header are cached and served as-is
and both hops stay compressed. The analytics endpoints are the one brew
consumer fetched without --compressed; they stay on identity.
* fix(handler): leave Accept-Encoding unset in proxyMetadataStream for an empty value
fetchUpstreamMetadata treats an empty acceptEncoding as 'do not set the
header'; proxyMetadataStream set it unconditionally, which would send an
empty Accept-Encoding line if a caller ever passed . Guard it the same
way so both paths agree. No caller passes today.
* fix(handler): keep the metadata row and blob from describing different bytes
Two ways the cache row could stop describing the stored blob once a
caller requests gzip, both raised by the review of #324:
- cacheMetadataBlob stored the blob and then discarded the
UpsertMetadataCache error. After a successful gzip store and a failed
row write, a later TTL hit or stale fallback served the gzip blob with
the previous row's encoding. On a row-write failure, log it and delete
the blob just written, so the next request refetches instead.
- fetchOrCacheMetadata read the row once up front and reused it for the
stale fallback. A request that read an identity row, lost the upstream
race to a request that stored the gzip blob, and then failed upstream
labelled the new blob with the old row. Re-read the row before falling
back so the encoding matches the blob as it is now.
Both only become harmful with an encoding change, which this branch
introduces; the pre-existing validator-from-row read is tracked
separately.
* Drop unused cachedMeta.contentEncoding and fix stale doc reference
The field was added by #304 and its only reader is replaced in this
branch by the encoding parameter passed alongside the body. The
proxyCachedWithEncoding comment named conda repodata, which was moved
out of this branch in 5991d95; Homebrew is the caller that ships here.
---------
Co-authored-by: Andrew Nesbitt <andrewnez@gmail.com>
2026-09-15 20:50:44 +02:00
|
|
|
etag string
|
|
|
|
|
lastModified time.Time
|
|
|
|
|
stale bool
|
2026-04-06 19:30:59 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// lookupCachedMeta retrieves cache validators for a metadata entry.
|
|
|
|
|
func (p *Proxy) lookupCachedMeta(ecosystem, cacheKey string) cachedMeta {
|
|
|
|
|
if p.DB == nil {
|
|
|
|
|
return cachedMeta{}
|
|
|
|
|
}
|
|
|
|
|
entry, err := p.DB.GetMetadataCache(ecosystem, cacheKey)
|
|
|
|
|
if err != nil || entry == nil {
|
|
|
|
|
return cachedMeta{}
|
|
|
|
|
}
|
|
|
|
|
var cm cachedMeta
|
|
|
|
|
if entry.ETag.Valid {
|
|
|
|
|
cm.etag = entry.ETag.String
|
|
|
|
|
}
|
|
|
|
|
if entry.LastModified.Valid {
|
|
|
|
|
cm.lastModified = entry.LastModified.Time
|
|
|
|
|
}
|
|
|
|
|
// If FetchedAt is older than TTL, upstream must have failed and
|
|
|
|
|
// we served from stale cache (successful fetches update FetchedAt).
|
|
|
|
|
if p.MetadataTTL > 0 && entry.FetchedAt.Valid && time.Since(entry.FetchedAt.Time) > p.MetadataTTL {
|
|
|
|
|
cm.stale = true
|
|
|
|
|
}
|
|
|
|
|
return cm
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-19 21:06:02 +00:00
|
|
|
// ProxyCached fetches metadata from upstream (with optional caching for offline fallback)
|
|
|
|
|
// and writes it to the response. Optional acceptHeaders specify the Accept header to send.
|
2026-04-01 16:14:07 +01:00
|
|
|
// When metadata caching is disabled, the response is streamed directly to avoid buffering
|
|
|
|
|
// large metadata responses (e.g. npm packages with many versions) in memory.
|
2026-03-19 21:06:02 +00:00
|
|
|
func (p *Proxy) ProxyCached(w http.ResponseWriter, r *http.Request, upstreamURL, ecosystem, cacheKey string, acceptHeaders ...string) {
|
fix(handler): let the ProxyCached path request a per-call Accept-Encoding (#324)
* fix(handler): fetch conda repodata gzip-compressed on both hops
#304 made the ProxyCached path request Accept-Encoding: identity so the
metadata cache stores upstream bytes verbatim. That is required for the
signed / hash-pinned index ecosystems, but conda's repodata.json is large
plain JSON: linux-64 repodata.json is ~441 MB uncompressed (over the
metadata_max_size cap, so it 502s today) versus ~34 MB gzip.
Replace the ProxyCached path's verbatim bool with an explicit
acceptEncoding string ('' = leave unset / transparent, 'identity', or
'gzip'), reusing #304's existing store-and-replay of Content-Encoding
unchanged. ProxyCached keeps its exported signature and continues to send
identity, so the nine other ecosystems and helm/maven are untouched; only
conda's repodata.json / current_repodata.json now request gzip. Setting
Accept-Encoding explicitly disables Go's transparent decompression, so the
compressed bytes and the Content-Encoding: gzip header are cached and
replayed exactly as identity bytes are. conda, mamba and pixi solicit and
decode gzip on .json URLs; repodata.json.bz2 stays identity.
Fixes #305
* fix(handler): pass metadata content-encoding with the body it describes
The adversarial review of the conda gzip route found a reachable
regression: writeMetadataCachedResponse took Content-Encoding from a
fresh cache-row read while cacheMetadataBlob skips the row write when
Storage.Store fails. Under identity that was benign (the body was plain
anyway), but on the new gzip route a disk-full or object-store outage
served raw gzip bytes as Content-Type: application/json with no
Content-Encoding and HTTP 200 -- conda, mamba and pixi fail to parse
them, with no HTTP signal and only a Warn log, on every request until a
cache write succeeds.
fetchOrCacheMetadata now returns the encoding of the body it hands back
(the upstream value on a fetch, the stored row's value on a TTL hit or
stale fallback) and proxyCachedWithEncoding passes it to
writeMetadataCachedResponse, so the header always describes the bytes
actually written. cachedMeta drops its now-unused content_encoding
field. helm and maven pass "" -- both fetch transparently, so their
stored encoding was always empty and behaviour is unchanged.
Also fixes a vacuous assertion in the new conda test: the upstream
request counter incremented behind the availability gate, so the
cached-replay block could never observe a refetch.
* fix(handler): pin the stale-fallback content-encoding and drop a dead guard
Follow-ups from the adversarial review of the #305 branch, limited to
code this branch introduced:
- proxyMetadataStream is only ever reached with an explicit
Accept-Encoding (ProxyCached passes identity, conda passes gzip or
identity), so the guard around the header set was unreachable; replace
it with the plain one-token substitution of the former literal, which
is the smallest change from main.
- The stale-fallback return of fetchOrCacheMetadata (encoding taken from
the cache row) was the one #305 return site no test pinned: replacing
it with an empty encoding survived the whole suite. Add a conda test
that expires the entry, fails the upstream, and asserts the stored
gzip blob is served with Content-Encoding: gzip.
Not changed, by scope: cacheMetadataBlob still discards the
UpsertMetadataCache error (pre-existing on main). If Storage.Store
succeeds and the row write fails, a later stale fallback or TTL hit can
serve the gzip blob with the row's stale encoding; that needs a DB write
failure plus a second event and is tracked separately.
* fix(handler): restore the pre-existing cachedMeta content-encoding field
The third adversarial review classified deleting cachedMeta.contentEncoding
and its lookupCachedMeta populate as elective: neither line was created by
this branch nor forced by the fix (writeMetadataCachedResponse now reads
the encoding from its parameter and ignores the row value). Under the rule
that pre-existing code this branch did not have to touch stays untouched,
restore both as they are on main. No behaviour change.
Residuals the review documented, unchanged by scope (both share one root
cause: the encoding lives in the cache row and the bytes in the blob, and
neither is written or read atomically):
- cacheMetadataBlob discards the UpsertMetadataCache error, so after a
successful gzip Store and a failed row write a later stale fallback or
TTL hit can serve the gzip blob with the row's stale encoding.
- During the one-time identity->gzip rollout, a request that read a
pre-branch identity row, lost the upstream race to a request that stored
the gzip blob, and then failed upstream serves the gzip bytes with no
Content-Encoding for that one response; later requests self-heal.
- helm and maven now pass an empty encoding; on main a spec-violating
upstream that answered a transparent gzip request with an encoding Go
does not decode (e.g. br) would have had that header replayed from the
row. Degenerate; documented rather than changed.
* fix(handler): keep conda's proxyCached and .bz2 route as on main
Threading acceptEncoding through CondaHandler.proxyCached changed the
form of two pieces of original code the fix did not need to touch: the
repodata.json.bz2 route (method value rewritten as a closure) and
proxyCached itself (new parameter, new call). Restore both exactly as on
main; ProxyCached still sends identity, so the .bz2 route is unchanged in
behaviour. handleRepodata's non-cooldown branch now derives the cache key
inline and calls proxyCachedWithEncoding with gzip directly, so the only
original conda.go line that changes is that one call.
* fix(handler): keep writeMetadataCachedResponse and its callers as on main
Adding a contentEncoding parameter to writeMetadataCachedResponse changed
a signature that predates #304 and dragged its two pre-#304 callers
(helm.go, maven.go) into the diff, even though #304 only ever added the
cm.contentEncoding block inside the function body.
Restore writeMetadataCachedResponse's doc and signature exactly as on
main and make it a delegate that passes an empty encoding to a new
unexported writeMetadataCachedResponseWithEncoding, which carries the
original body with #304's block reading the parameter instead of the
cache row. proxyCachedWithEncoding calls the sibling with the encoding
returned alongside the body. helm.go and maven.go drop out of the diff;
their behaviour is unchanged (both fetch transparently, so their stored
encoding was always empty). Same split pattern as ProxyCached ->
proxyCachedWithEncoding.
* fix(handler): move the conda gzip change to its own branch
The conda call site in handleRepodata predates #304 and #304 never
touched it, so under the rule that this PR only corrects code and
behaviour #304 introduced it does not belong here. Restore conda.go and
conda_test.go as on main; the conda change continues on a stacked branch
against its own issue.
Replace the conda-route tests with tests that exercise
proxyCachedWithEncoding directly, so this PR still pins its own plumbing:
gzip is requested and the compressed bytes plus Content-Encoding are
cached and replayed (cached and streaming paths), the header survives a
metadata cache write failure, and the stale fallback keeps the stored
encoding.
* fix(homebrew): fetch the JSON API gzip-compressed on both hops
Homebrew (#254) routes every API path through ProxyCached and so, since
#304, fetches formula.jws.json (~33 MB plain, ~5 MB gzip) uncompressed on
every refresh -- the case that motivated #305.
Request gzip for the JSON API via proxyCachedWithEncoding: brew fetches
every API download with curl --compressed and decodes Content-Encoding
itself, so the compressed bytes and header are cached and served as-is
and both hops stay compressed. The analytics endpoints are the one brew
consumer fetched without --compressed; they stay on identity.
* fix(handler): leave Accept-Encoding unset in proxyMetadataStream for an empty value
fetchUpstreamMetadata treats an empty acceptEncoding as 'do not set the
header'; proxyMetadataStream set it unconditionally, which would send an
empty Accept-Encoding line if a caller ever passed . Guard it the same
way so both paths agree. No caller passes today.
* fix(handler): keep the metadata row and blob from describing different bytes
Two ways the cache row could stop describing the stored blob once a
caller requests gzip, both raised by the review of #324:
- cacheMetadataBlob stored the blob and then discarded the
UpsertMetadataCache error. After a successful gzip store and a failed
row write, a later TTL hit or stale fallback served the gzip blob with
the previous row's encoding. On a row-write failure, log it and delete
the blob just written, so the next request refetches instead.
- fetchOrCacheMetadata read the row once up front and reused it for the
stale fallback. A request that read an identity row, lost the upstream
race to a request that stored the gzip blob, and then failed upstream
labelled the new blob with the old row. Re-read the row before falling
back so the encoding matches the blob as it is now.
Both only become harmful with an encoding change, which this branch
introduces; the pre-existing validator-from-row read is tracked
separately.
* Drop unused cachedMeta.contentEncoding and fix stale doc reference
The field was added by #304 and its only reader is replaced in this
branch by the encoding parameter passed alongside the body. The
proxyCachedWithEncoding comment named conda repodata, which was moved
out of this branch in 5991d95; Homebrew is the caller that ships here.
---------
Co-authored-by: Andrew Nesbitt <andrewnez@gmail.com>
2026-09-15 20:50:44 +02:00
|
|
|
p.proxyCachedWithEncoding(w, r, upstreamURL, ecosystem, cacheKey, "identity", acceptHeaders...)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// proxyCachedWithEncoding is ProxyCached with an explicit upstream Accept-Encoding.
|
|
|
|
|
// "identity" preserves signed index bytes (the default); "gzip" keeps both hops
|
|
|
|
|
// compressed for large, non-hash-pinned metadata whose clients decode gzip
|
2026-09-25 21:12:00 +05:30
|
|
|
// (Homebrew API and Conda repodata). The stored bytes and Content-Encoding are
|
|
|
|
|
// replayed verbatim either way.
|
fix(handler): let the ProxyCached path request a per-call Accept-Encoding (#324)
* fix(handler): fetch conda repodata gzip-compressed on both hops
#304 made the ProxyCached path request Accept-Encoding: identity so the
metadata cache stores upstream bytes verbatim. That is required for the
signed / hash-pinned index ecosystems, but conda's repodata.json is large
plain JSON: linux-64 repodata.json is ~441 MB uncompressed (over the
metadata_max_size cap, so it 502s today) versus ~34 MB gzip.
Replace the ProxyCached path's verbatim bool with an explicit
acceptEncoding string ('' = leave unset / transparent, 'identity', or
'gzip'), reusing #304's existing store-and-replay of Content-Encoding
unchanged. ProxyCached keeps its exported signature and continues to send
identity, so the nine other ecosystems and helm/maven are untouched; only
conda's repodata.json / current_repodata.json now request gzip. Setting
Accept-Encoding explicitly disables Go's transparent decompression, so the
compressed bytes and the Content-Encoding: gzip header are cached and
replayed exactly as identity bytes are. conda, mamba and pixi solicit and
decode gzip on .json URLs; repodata.json.bz2 stays identity.
Fixes #305
* fix(handler): pass metadata content-encoding with the body it describes
The adversarial review of the conda gzip route found a reachable
regression: writeMetadataCachedResponse took Content-Encoding from a
fresh cache-row read while cacheMetadataBlob skips the row write when
Storage.Store fails. Under identity that was benign (the body was plain
anyway), but on the new gzip route a disk-full or object-store outage
served raw gzip bytes as Content-Type: application/json with no
Content-Encoding and HTTP 200 -- conda, mamba and pixi fail to parse
them, with no HTTP signal and only a Warn log, on every request until a
cache write succeeds.
fetchOrCacheMetadata now returns the encoding of the body it hands back
(the upstream value on a fetch, the stored row's value on a TTL hit or
stale fallback) and proxyCachedWithEncoding passes it to
writeMetadataCachedResponse, so the header always describes the bytes
actually written. cachedMeta drops its now-unused content_encoding
field. helm and maven pass "" -- both fetch transparently, so their
stored encoding was always empty and behaviour is unchanged.
Also fixes a vacuous assertion in the new conda test: the upstream
request counter incremented behind the availability gate, so the
cached-replay block could never observe a refetch.
* fix(handler): pin the stale-fallback content-encoding and drop a dead guard
Follow-ups from the adversarial review of the #305 branch, limited to
code this branch introduced:
- proxyMetadataStream is only ever reached with an explicit
Accept-Encoding (ProxyCached passes identity, conda passes gzip or
identity), so the guard around the header set was unreachable; replace
it with the plain one-token substitution of the former literal, which
is the smallest change from main.
- The stale-fallback return of fetchOrCacheMetadata (encoding taken from
the cache row) was the one #305 return site no test pinned: replacing
it with an empty encoding survived the whole suite. Add a conda test
that expires the entry, fails the upstream, and asserts the stored
gzip blob is served with Content-Encoding: gzip.
Not changed, by scope: cacheMetadataBlob still discards the
UpsertMetadataCache error (pre-existing on main). If Storage.Store
succeeds and the row write fails, a later stale fallback or TTL hit can
serve the gzip blob with the row's stale encoding; that needs a DB write
failure plus a second event and is tracked separately.
* fix(handler): restore the pre-existing cachedMeta content-encoding field
The third adversarial review classified deleting cachedMeta.contentEncoding
and its lookupCachedMeta populate as elective: neither line was created by
this branch nor forced by the fix (writeMetadataCachedResponse now reads
the encoding from its parameter and ignores the row value). Under the rule
that pre-existing code this branch did not have to touch stays untouched,
restore both as they are on main. No behaviour change.
Residuals the review documented, unchanged by scope (both share one root
cause: the encoding lives in the cache row and the bytes in the blob, and
neither is written or read atomically):
- cacheMetadataBlob discards the UpsertMetadataCache error, so after a
successful gzip Store and a failed row write a later stale fallback or
TTL hit can serve the gzip blob with the row's stale encoding.
- During the one-time identity->gzip rollout, a request that read a
pre-branch identity row, lost the upstream race to a request that stored
the gzip blob, and then failed upstream serves the gzip bytes with no
Content-Encoding for that one response; later requests self-heal.
- helm and maven now pass an empty encoding; on main a spec-violating
upstream that answered a transparent gzip request with an encoding Go
does not decode (e.g. br) would have had that header replayed from the
row. Degenerate; documented rather than changed.
* fix(handler): keep conda's proxyCached and .bz2 route as on main
Threading acceptEncoding through CondaHandler.proxyCached changed the
form of two pieces of original code the fix did not need to touch: the
repodata.json.bz2 route (method value rewritten as a closure) and
proxyCached itself (new parameter, new call). Restore both exactly as on
main; ProxyCached still sends identity, so the .bz2 route is unchanged in
behaviour. handleRepodata's non-cooldown branch now derives the cache key
inline and calls proxyCachedWithEncoding with gzip directly, so the only
original conda.go line that changes is that one call.
* fix(handler): keep writeMetadataCachedResponse and its callers as on main
Adding a contentEncoding parameter to writeMetadataCachedResponse changed
a signature that predates #304 and dragged its two pre-#304 callers
(helm.go, maven.go) into the diff, even though #304 only ever added the
cm.contentEncoding block inside the function body.
Restore writeMetadataCachedResponse's doc and signature exactly as on
main and make it a delegate that passes an empty encoding to a new
unexported writeMetadataCachedResponseWithEncoding, which carries the
original body with #304's block reading the parameter instead of the
cache row. proxyCachedWithEncoding calls the sibling with the encoding
returned alongside the body. helm.go and maven.go drop out of the diff;
their behaviour is unchanged (both fetch transparently, so their stored
encoding was always empty). Same split pattern as ProxyCached ->
proxyCachedWithEncoding.
* fix(handler): move the conda gzip change to its own branch
The conda call site in handleRepodata predates #304 and #304 never
touched it, so under the rule that this PR only corrects code and
behaviour #304 introduced it does not belong here. Restore conda.go and
conda_test.go as on main; the conda change continues on a stacked branch
against its own issue.
Replace the conda-route tests with tests that exercise
proxyCachedWithEncoding directly, so this PR still pins its own plumbing:
gzip is requested and the compressed bytes plus Content-Encoding are
cached and replayed (cached and streaming paths), the header survives a
metadata cache write failure, and the stale fallback keeps the stored
encoding.
* fix(homebrew): fetch the JSON API gzip-compressed on both hops
Homebrew (#254) routes every API path through ProxyCached and so, since
#304, fetches formula.jws.json (~33 MB plain, ~5 MB gzip) uncompressed on
every refresh -- the case that motivated #305.
Request gzip for the JSON API via proxyCachedWithEncoding: brew fetches
every API download with curl --compressed and decodes Content-Encoding
itself, so the compressed bytes and header are cached and served as-is
and both hops stay compressed. The analytics endpoints are the one brew
consumer fetched without --compressed; they stay on identity.
* fix(handler): leave Accept-Encoding unset in proxyMetadataStream for an empty value
fetchUpstreamMetadata treats an empty acceptEncoding as 'do not set the
header'; proxyMetadataStream set it unconditionally, which would send an
empty Accept-Encoding line if a caller ever passed . Guard it the same
way so both paths agree. No caller passes today.
* fix(handler): keep the metadata row and blob from describing different bytes
Two ways the cache row could stop describing the stored blob once a
caller requests gzip, both raised by the review of #324:
- cacheMetadataBlob stored the blob and then discarded the
UpsertMetadataCache error. After a successful gzip store and a failed
row write, a later TTL hit or stale fallback served the gzip blob with
the previous row's encoding. On a row-write failure, log it and delete
the blob just written, so the next request refetches instead.
- fetchOrCacheMetadata read the row once up front and reused it for the
stale fallback. A request that read an identity row, lost the upstream
race to a request that stored the gzip blob, and then failed upstream
labelled the new blob with the old row. Re-read the row before falling
back so the encoding matches the blob as it is now.
Both only become harmful with an encoding change, which this branch
introduces; the pre-existing validator-from-row read is tracked
separately.
* Drop unused cachedMeta.contentEncoding and fix stale doc reference
The field was added by #304 and its only reader is replaced in this
branch by the encoding parameter passed alongside the body. The
proxyCachedWithEncoding comment named conda repodata, which was moved
out of this branch in 5991d95; Homebrew is the caller that ships here.
---------
Co-authored-by: Andrew Nesbitt <andrewnez@gmail.com>
2026-09-15 20:50:44 +02:00
|
|
|
func (p *Proxy) proxyCachedWithEncoding(w http.ResponseWriter, r *http.Request, upstreamURL, ecosystem, cacheKey, acceptEncoding string, acceptHeaders ...string) {
|
2026-04-01 16:14:07 +01:00
|
|
|
if !p.CacheMetadata {
|
|
|
|
|
// Stream directly without buffering when caching is off.
|
fix(handler): let the ProxyCached path request a per-call Accept-Encoding (#324)
* fix(handler): fetch conda repodata gzip-compressed on both hops
#304 made the ProxyCached path request Accept-Encoding: identity so the
metadata cache stores upstream bytes verbatim. That is required for the
signed / hash-pinned index ecosystems, but conda's repodata.json is large
plain JSON: linux-64 repodata.json is ~441 MB uncompressed (over the
metadata_max_size cap, so it 502s today) versus ~34 MB gzip.
Replace the ProxyCached path's verbatim bool with an explicit
acceptEncoding string ('' = leave unset / transparent, 'identity', or
'gzip'), reusing #304's existing store-and-replay of Content-Encoding
unchanged. ProxyCached keeps its exported signature and continues to send
identity, so the nine other ecosystems and helm/maven are untouched; only
conda's repodata.json / current_repodata.json now request gzip. Setting
Accept-Encoding explicitly disables Go's transparent decompression, so the
compressed bytes and the Content-Encoding: gzip header are cached and
replayed exactly as identity bytes are. conda, mamba and pixi solicit and
decode gzip on .json URLs; repodata.json.bz2 stays identity.
Fixes #305
* fix(handler): pass metadata content-encoding with the body it describes
The adversarial review of the conda gzip route found a reachable
regression: writeMetadataCachedResponse took Content-Encoding from a
fresh cache-row read while cacheMetadataBlob skips the row write when
Storage.Store fails. Under identity that was benign (the body was plain
anyway), but on the new gzip route a disk-full or object-store outage
served raw gzip bytes as Content-Type: application/json with no
Content-Encoding and HTTP 200 -- conda, mamba and pixi fail to parse
them, with no HTTP signal and only a Warn log, on every request until a
cache write succeeds.
fetchOrCacheMetadata now returns the encoding of the body it hands back
(the upstream value on a fetch, the stored row's value on a TTL hit or
stale fallback) and proxyCachedWithEncoding passes it to
writeMetadataCachedResponse, so the header always describes the bytes
actually written. cachedMeta drops its now-unused content_encoding
field. helm and maven pass "" -- both fetch transparently, so their
stored encoding was always empty and behaviour is unchanged.
Also fixes a vacuous assertion in the new conda test: the upstream
request counter incremented behind the availability gate, so the
cached-replay block could never observe a refetch.
* fix(handler): pin the stale-fallback content-encoding and drop a dead guard
Follow-ups from the adversarial review of the #305 branch, limited to
code this branch introduced:
- proxyMetadataStream is only ever reached with an explicit
Accept-Encoding (ProxyCached passes identity, conda passes gzip or
identity), so the guard around the header set was unreachable; replace
it with the plain one-token substitution of the former literal, which
is the smallest change from main.
- The stale-fallback return of fetchOrCacheMetadata (encoding taken from
the cache row) was the one #305 return site no test pinned: replacing
it with an empty encoding survived the whole suite. Add a conda test
that expires the entry, fails the upstream, and asserts the stored
gzip blob is served with Content-Encoding: gzip.
Not changed, by scope: cacheMetadataBlob still discards the
UpsertMetadataCache error (pre-existing on main). If Storage.Store
succeeds and the row write fails, a later stale fallback or TTL hit can
serve the gzip blob with the row's stale encoding; that needs a DB write
failure plus a second event and is tracked separately.
* fix(handler): restore the pre-existing cachedMeta content-encoding field
The third adversarial review classified deleting cachedMeta.contentEncoding
and its lookupCachedMeta populate as elective: neither line was created by
this branch nor forced by the fix (writeMetadataCachedResponse now reads
the encoding from its parameter and ignores the row value). Under the rule
that pre-existing code this branch did not have to touch stays untouched,
restore both as they are on main. No behaviour change.
Residuals the review documented, unchanged by scope (both share one root
cause: the encoding lives in the cache row and the bytes in the blob, and
neither is written or read atomically):
- cacheMetadataBlob discards the UpsertMetadataCache error, so after a
successful gzip Store and a failed row write a later stale fallback or
TTL hit can serve the gzip blob with the row's stale encoding.
- During the one-time identity->gzip rollout, a request that read a
pre-branch identity row, lost the upstream race to a request that stored
the gzip blob, and then failed upstream serves the gzip bytes with no
Content-Encoding for that one response; later requests self-heal.
- helm and maven now pass an empty encoding; on main a spec-violating
upstream that answered a transparent gzip request with an encoding Go
does not decode (e.g. br) would have had that header replayed from the
row. Degenerate; documented rather than changed.
* fix(handler): keep conda's proxyCached and .bz2 route as on main
Threading acceptEncoding through CondaHandler.proxyCached changed the
form of two pieces of original code the fix did not need to touch: the
repodata.json.bz2 route (method value rewritten as a closure) and
proxyCached itself (new parameter, new call). Restore both exactly as on
main; ProxyCached still sends identity, so the .bz2 route is unchanged in
behaviour. handleRepodata's non-cooldown branch now derives the cache key
inline and calls proxyCachedWithEncoding with gzip directly, so the only
original conda.go line that changes is that one call.
* fix(handler): keep writeMetadataCachedResponse and its callers as on main
Adding a contentEncoding parameter to writeMetadataCachedResponse changed
a signature that predates #304 and dragged its two pre-#304 callers
(helm.go, maven.go) into the diff, even though #304 only ever added the
cm.contentEncoding block inside the function body.
Restore writeMetadataCachedResponse's doc and signature exactly as on
main and make it a delegate that passes an empty encoding to a new
unexported writeMetadataCachedResponseWithEncoding, which carries the
original body with #304's block reading the parameter instead of the
cache row. proxyCachedWithEncoding calls the sibling with the encoding
returned alongside the body. helm.go and maven.go drop out of the diff;
their behaviour is unchanged (both fetch transparently, so their stored
encoding was always empty). Same split pattern as ProxyCached ->
proxyCachedWithEncoding.
* fix(handler): move the conda gzip change to its own branch
The conda call site in handleRepodata predates #304 and #304 never
touched it, so under the rule that this PR only corrects code and
behaviour #304 introduced it does not belong here. Restore conda.go and
conda_test.go as on main; the conda change continues on a stacked branch
against its own issue.
Replace the conda-route tests with tests that exercise
proxyCachedWithEncoding directly, so this PR still pins its own plumbing:
gzip is requested and the compressed bytes plus Content-Encoding are
cached and replayed (cached and streaming paths), the header survives a
metadata cache write failure, and the stale fallback keeps the stored
encoding.
* fix(homebrew): fetch the JSON API gzip-compressed on both hops
Homebrew (#254) routes every API path through ProxyCached and so, since
#304, fetches formula.jws.json (~33 MB plain, ~5 MB gzip) uncompressed on
every refresh -- the case that motivated #305.
Request gzip for the JSON API via proxyCachedWithEncoding: brew fetches
every API download with curl --compressed and decodes Content-Encoding
itself, so the compressed bytes and header are cached and served as-is
and both hops stay compressed. The analytics endpoints are the one brew
consumer fetched without --compressed; they stay on identity.
* fix(handler): leave Accept-Encoding unset in proxyMetadataStream for an empty value
fetchUpstreamMetadata treats an empty acceptEncoding as 'do not set the
header'; proxyMetadataStream set it unconditionally, which would send an
empty Accept-Encoding line if a caller ever passed . Guard it the same
way so both paths agree. No caller passes today.
* fix(handler): keep the metadata row and blob from describing different bytes
Two ways the cache row could stop describing the stored blob once a
caller requests gzip, both raised by the review of #324:
- cacheMetadataBlob stored the blob and then discarded the
UpsertMetadataCache error. After a successful gzip store and a failed
row write, a later TTL hit or stale fallback served the gzip blob with
the previous row's encoding. On a row-write failure, log it and delete
the blob just written, so the next request refetches instead.
- fetchOrCacheMetadata read the row once up front and reused it for the
stale fallback. A request that read an identity row, lost the upstream
race to a request that stored the gzip blob, and then failed upstream
labelled the new blob with the old row. Re-read the row before falling
back so the encoding matches the blob as it is now.
Both only become harmful with an encoding change, which this branch
introduces; the pre-existing validator-from-row read is tracked
separately.
* Drop unused cachedMeta.contentEncoding and fix stale doc reference
The field was added by #304 and its only reader is replaced in this
branch by the encoding parameter passed alongside the body. The
proxyCachedWithEncoding comment named conda repodata, which was moved
out of this branch in 5991d95; Homebrew is the caller that ships here.
---------
Co-authored-by: Andrew Nesbitt <andrewnez@gmail.com>
2026-09-15 20:50:44 +02:00
|
|
|
p.proxyMetadataStream(w, r, upstreamURL, acceptEncoding, acceptHeaders...)
|
2026-04-01 16:14:07 +01:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-16 12:57:44 +05:30
|
|
|
body, contentType, contentEncoding, err := p.fetchOrCacheMetadata(r.Context(), ecosystem, cacheKey, upstreamURL, acceptEncoding, nil, acceptHeaders...)
|
2026-03-19 21:06:02 +00:00
|
|
|
if err != nil {
|
|
|
|
|
if errors.Is(err, ErrUpstreamNotFound) {
|
|
|
|
|
http.Error(w, "not found", http.StatusNotFound)
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-10-14 12:51:43 +01:00
|
|
|
if writeUpstreamHeld(w, err) {
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-03-19 21:06:02 +00:00
|
|
|
p.Logger.Error("metadata fetch failed", "error", err)
|
|
|
|
|
http.Error(w, "failed to fetch from upstream", http.StatusBadGateway)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
fix(handler): let the ProxyCached path request a per-call Accept-Encoding (#324)
* fix(handler): fetch conda repodata gzip-compressed on both hops
#304 made the ProxyCached path request Accept-Encoding: identity so the
metadata cache stores upstream bytes verbatim. That is required for the
signed / hash-pinned index ecosystems, but conda's repodata.json is large
plain JSON: linux-64 repodata.json is ~441 MB uncompressed (over the
metadata_max_size cap, so it 502s today) versus ~34 MB gzip.
Replace the ProxyCached path's verbatim bool with an explicit
acceptEncoding string ('' = leave unset / transparent, 'identity', or
'gzip'), reusing #304's existing store-and-replay of Content-Encoding
unchanged. ProxyCached keeps its exported signature and continues to send
identity, so the nine other ecosystems and helm/maven are untouched; only
conda's repodata.json / current_repodata.json now request gzip. Setting
Accept-Encoding explicitly disables Go's transparent decompression, so the
compressed bytes and the Content-Encoding: gzip header are cached and
replayed exactly as identity bytes are. conda, mamba and pixi solicit and
decode gzip on .json URLs; repodata.json.bz2 stays identity.
Fixes #305
* fix(handler): pass metadata content-encoding with the body it describes
The adversarial review of the conda gzip route found a reachable
regression: writeMetadataCachedResponse took Content-Encoding from a
fresh cache-row read while cacheMetadataBlob skips the row write when
Storage.Store fails. Under identity that was benign (the body was plain
anyway), but on the new gzip route a disk-full or object-store outage
served raw gzip bytes as Content-Type: application/json with no
Content-Encoding and HTTP 200 -- conda, mamba and pixi fail to parse
them, with no HTTP signal and only a Warn log, on every request until a
cache write succeeds.
fetchOrCacheMetadata now returns the encoding of the body it hands back
(the upstream value on a fetch, the stored row's value on a TTL hit or
stale fallback) and proxyCachedWithEncoding passes it to
writeMetadataCachedResponse, so the header always describes the bytes
actually written. cachedMeta drops its now-unused content_encoding
field. helm and maven pass "" -- both fetch transparently, so their
stored encoding was always empty and behaviour is unchanged.
Also fixes a vacuous assertion in the new conda test: the upstream
request counter incremented behind the availability gate, so the
cached-replay block could never observe a refetch.
* fix(handler): pin the stale-fallback content-encoding and drop a dead guard
Follow-ups from the adversarial review of the #305 branch, limited to
code this branch introduced:
- proxyMetadataStream is only ever reached with an explicit
Accept-Encoding (ProxyCached passes identity, conda passes gzip or
identity), so the guard around the header set was unreachable; replace
it with the plain one-token substitution of the former literal, which
is the smallest change from main.
- The stale-fallback return of fetchOrCacheMetadata (encoding taken from
the cache row) was the one #305 return site no test pinned: replacing
it with an empty encoding survived the whole suite. Add a conda test
that expires the entry, fails the upstream, and asserts the stored
gzip blob is served with Content-Encoding: gzip.
Not changed, by scope: cacheMetadataBlob still discards the
UpsertMetadataCache error (pre-existing on main). If Storage.Store
succeeds and the row write fails, a later stale fallback or TTL hit can
serve the gzip blob with the row's stale encoding; that needs a DB write
failure plus a second event and is tracked separately.
* fix(handler): restore the pre-existing cachedMeta content-encoding field
The third adversarial review classified deleting cachedMeta.contentEncoding
and its lookupCachedMeta populate as elective: neither line was created by
this branch nor forced by the fix (writeMetadataCachedResponse now reads
the encoding from its parameter and ignores the row value). Under the rule
that pre-existing code this branch did not have to touch stays untouched,
restore both as they are on main. No behaviour change.
Residuals the review documented, unchanged by scope (both share one root
cause: the encoding lives in the cache row and the bytes in the blob, and
neither is written or read atomically):
- cacheMetadataBlob discards the UpsertMetadataCache error, so after a
successful gzip Store and a failed row write a later stale fallback or
TTL hit can serve the gzip blob with the row's stale encoding.
- During the one-time identity->gzip rollout, a request that read a
pre-branch identity row, lost the upstream race to a request that stored
the gzip blob, and then failed upstream serves the gzip bytes with no
Content-Encoding for that one response; later requests self-heal.
- helm and maven now pass an empty encoding; on main a spec-violating
upstream that answered a transparent gzip request with an encoding Go
does not decode (e.g. br) would have had that header replayed from the
row. Degenerate; documented rather than changed.
* fix(handler): keep conda's proxyCached and .bz2 route as on main
Threading acceptEncoding through CondaHandler.proxyCached changed the
form of two pieces of original code the fix did not need to touch: the
repodata.json.bz2 route (method value rewritten as a closure) and
proxyCached itself (new parameter, new call). Restore both exactly as on
main; ProxyCached still sends identity, so the .bz2 route is unchanged in
behaviour. handleRepodata's non-cooldown branch now derives the cache key
inline and calls proxyCachedWithEncoding with gzip directly, so the only
original conda.go line that changes is that one call.
* fix(handler): keep writeMetadataCachedResponse and its callers as on main
Adding a contentEncoding parameter to writeMetadataCachedResponse changed
a signature that predates #304 and dragged its two pre-#304 callers
(helm.go, maven.go) into the diff, even though #304 only ever added the
cm.contentEncoding block inside the function body.
Restore writeMetadataCachedResponse's doc and signature exactly as on
main and make it a delegate that passes an empty encoding to a new
unexported writeMetadataCachedResponseWithEncoding, which carries the
original body with #304's block reading the parameter instead of the
cache row. proxyCachedWithEncoding calls the sibling with the encoding
returned alongside the body. helm.go and maven.go drop out of the diff;
their behaviour is unchanged (both fetch transparently, so their stored
encoding was always empty). Same split pattern as ProxyCached ->
proxyCachedWithEncoding.
* fix(handler): move the conda gzip change to its own branch
The conda call site in handleRepodata predates #304 and #304 never
touched it, so under the rule that this PR only corrects code and
behaviour #304 introduced it does not belong here. Restore conda.go and
conda_test.go as on main; the conda change continues on a stacked branch
against its own issue.
Replace the conda-route tests with tests that exercise
proxyCachedWithEncoding directly, so this PR still pins its own plumbing:
gzip is requested and the compressed bytes plus Content-Encoding are
cached and replayed (cached and streaming paths), the header survives a
metadata cache write failure, and the stale fallback keeps the stored
encoding.
* fix(homebrew): fetch the JSON API gzip-compressed on both hops
Homebrew (#254) routes every API path through ProxyCached and so, since
#304, fetches formula.jws.json (~33 MB plain, ~5 MB gzip) uncompressed on
every refresh -- the case that motivated #305.
Request gzip for the JSON API via proxyCachedWithEncoding: brew fetches
every API download with curl --compressed and decodes Content-Encoding
itself, so the compressed bytes and header are cached and served as-is
and both hops stay compressed. The analytics endpoints are the one brew
consumer fetched without --compressed; they stay on identity.
* fix(handler): leave Accept-Encoding unset in proxyMetadataStream for an empty value
fetchUpstreamMetadata treats an empty acceptEncoding as 'do not set the
header'; proxyMetadataStream set it unconditionally, which would send an
empty Accept-Encoding line if a caller ever passed . Guard it the same
way so both paths agree. No caller passes today.
* fix(handler): keep the metadata row and blob from describing different bytes
Two ways the cache row could stop describing the stored blob once a
caller requests gzip, both raised by the review of #324:
- cacheMetadataBlob stored the blob and then discarded the
UpsertMetadataCache error. After a successful gzip store and a failed
row write, a later TTL hit or stale fallback served the gzip blob with
the previous row's encoding. On a row-write failure, log it and delete
the blob just written, so the next request refetches instead.
- fetchOrCacheMetadata read the row once up front and reused it for the
stale fallback. A request that read an identity row, lost the upstream
race to a request that stored the gzip blob, and then failed upstream
labelled the new blob with the old row. Re-read the row before falling
back so the encoding matches the blob as it is now.
Both only become harmful with an encoding change, which this branch
introduces; the pre-existing validator-from-row read is tracked
separately.
* Drop unused cachedMeta.contentEncoding and fix stale doc reference
The field was added by #304 and its only reader is replaced in this
branch by the encoding parameter passed alongside the body. The
proxyCachedWithEncoding comment named conda repodata, which was moved
out of this branch in 5991d95; Homebrew is the caller that ships here.
---------
Co-authored-by: Andrew Nesbitt <andrewnez@gmail.com>
2026-09-15 20:50:44 +02:00
|
|
|
p.writeMetadataCachedResponseWithEncoding(w, r, ecosystem, cacheKey, body, contentType, contentEncoding)
|
2026-05-22 18:05:20 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// writeMetadataCachedResponse writes a cached metadata response and handles
|
|
|
|
|
// conditional request headers using metadata cache validators.
|
|
|
|
|
func (p *Proxy) writeMetadataCachedResponse(w http.ResponseWriter, r *http.Request, ecosystem, cacheKey string, body []byte, contentType string) {
|
fix(handler): let the ProxyCached path request a per-call Accept-Encoding (#324)
* fix(handler): fetch conda repodata gzip-compressed on both hops
#304 made the ProxyCached path request Accept-Encoding: identity so the
metadata cache stores upstream bytes verbatim. That is required for the
signed / hash-pinned index ecosystems, but conda's repodata.json is large
plain JSON: linux-64 repodata.json is ~441 MB uncompressed (over the
metadata_max_size cap, so it 502s today) versus ~34 MB gzip.
Replace the ProxyCached path's verbatim bool with an explicit
acceptEncoding string ('' = leave unset / transparent, 'identity', or
'gzip'), reusing #304's existing store-and-replay of Content-Encoding
unchanged. ProxyCached keeps its exported signature and continues to send
identity, so the nine other ecosystems and helm/maven are untouched; only
conda's repodata.json / current_repodata.json now request gzip. Setting
Accept-Encoding explicitly disables Go's transparent decompression, so the
compressed bytes and the Content-Encoding: gzip header are cached and
replayed exactly as identity bytes are. conda, mamba and pixi solicit and
decode gzip on .json URLs; repodata.json.bz2 stays identity.
Fixes #305
* fix(handler): pass metadata content-encoding with the body it describes
The adversarial review of the conda gzip route found a reachable
regression: writeMetadataCachedResponse took Content-Encoding from a
fresh cache-row read while cacheMetadataBlob skips the row write when
Storage.Store fails. Under identity that was benign (the body was plain
anyway), but on the new gzip route a disk-full or object-store outage
served raw gzip bytes as Content-Type: application/json with no
Content-Encoding and HTTP 200 -- conda, mamba and pixi fail to parse
them, with no HTTP signal and only a Warn log, on every request until a
cache write succeeds.
fetchOrCacheMetadata now returns the encoding of the body it hands back
(the upstream value on a fetch, the stored row's value on a TTL hit or
stale fallback) and proxyCachedWithEncoding passes it to
writeMetadataCachedResponse, so the header always describes the bytes
actually written. cachedMeta drops its now-unused content_encoding
field. helm and maven pass "" -- both fetch transparently, so their
stored encoding was always empty and behaviour is unchanged.
Also fixes a vacuous assertion in the new conda test: the upstream
request counter incremented behind the availability gate, so the
cached-replay block could never observe a refetch.
* fix(handler): pin the stale-fallback content-encoding and drop a dead guard
Follow-ups from the adversarial review of the #305 branch, limited to
code this branch introduced:
- proxyMetadataStream is only ever reached with an explicit
Accept-Encoding (ProxyCached passes identity, conda passes gzip or
identity), so the guard around the header set was unreachable; replace
it with the plain one-token substitution of the former literal, which
is the smallest change from main.
- The stale-fallback return of fetchOrCacheMetadata (encoding taken from
the cache row) was the one #305 return site no test pinned: replacing
it with an empty encoding survived the whole suite. Add a conda test
that expires the entry, fails the upstream, and asserts the stored
gzip blob is served with Content-Encoding: gzip.
Not changed, by scope: cacheMetadataBlob still discards the
UpsertMetadataCache error (pre-existing on main). If Storage.Store
succeeds and the row write fails, a later stale fallback or TTL hit can
serve the gzip blob with the row's stale encoding; that needs a DB write
failure plus a second event and is tracked separately.
* fix(handler): restore the pre-existing cachedMeta content-encoding field
The third adversarial review classified deleting cachedMeta.contentEncoding
and its lookupCachedMeta populate as elective: neither line was created by
this branch nor forced by the fix (writeMetadataCachedResponse now reads
the encoding from its parameter and ignores the row value). Under the rule
that pre-existing code this branch did not have to touch stays untouched,
restore both as they are on main. No behaviour change.
Residuals the review documented, unchanged by scope (both share one root
cause: the encoding lives in the cache row and the bytes in the blob, and
neither is written or read atomically):
- cacheMetadataBlob discards the UpsertMetadataCache error, so after a
successful gzip Store and a failed row write a later stale fallback or
TTL hit can serve the gzip blob with the row's stale encoding.
- During the one-time identity->gzip rollout, a request that read a
pre-branch identity row, lost the upstream race to a request that stored
the gzip blob, and then failed upstream serves the gzip bytes with no
Content-Encoding for that one response; later requests self-heal.
- helm and maven now pass an empty encoding; on main a spec-violating
upstream that answered a transparent gzip request with an encoding Go
does not decode (e.g. br) would have had that header replayed from the
row. Degenerate; documented rather than changed.
* fix(handler): keep conda's proxyCached and .bz2 route as on main
Threading acceptEncoding through CondaHandler.proxyCached changed the
form of two pieces of original code the fix did not need to touch: the
repodata.json.bz2 route (method value rewritten as a closure) and
proxyCached itself (new parameter, new call). Restore both exactly as on
main; ProxyCached still sends identity, so the .bz2 route is unchanged in
behaviour. handleRepodata's non-cooldown branch now derives the cache key
inline and calls proxyCachedWithEncoding with gzip directly, so the only
original conda.go line that changes is that one call.
* fix(handler): keep writeMetadataCachedResponse and its callers as on main
Adding a contentEncoding parameter to writeMetadataCachedResponse changed
a signature that predates #304 and dragged its two pre-#304 callers
(helm.go, maven.go) into the diff, even though #304 only ever added the
cm.contentEncoding block inside the function body.
Restore writeMetadataCachedResponse's doc and signature exactly as on
main and make it a delegate that passes an empty encoding to a new
unexported writeMetadataCachedResponseWithEncoding, which carries the
original body with #304's block reading the parameter instead of the
cache row. proxyCachedWithEncoding calls the sibling with the encoding
returned alongside the body. helm.go and maven.go drop out of the diff;
their behaviour is unchanged (both fetch transparently, so their stored
encoding was always empty). Same split pattern as ProxyCached ->
proxyCachedWithEncoding.
* fix(handler): move the conda gzip change to its own branch
The conda call site in handleRepodata predates #304 and #304 never
touched it, so under the rule that this PR only corrects code and
behaviour #304 introduced it does not belong here. Restore conda.go and
conda_test.go as on main; the conda change continues on a stacked branch
against its own issue.
Replace the conda-route tests with tests that exercise
proxyCachedWithEncoding directly, so this PR still pins its own plumbing:
gzip is requested and the compressed bytes plus Content-Encoding are
cached and replayed (cached and streaming paths), the header survives a
metadata cache write failure, and the stale fallback keeps the stored
encoding.
* fix(homebrew): fetch the JSON API gzip-compressed on both hops
Homebrew (#254) routes every API path through ProxyCached and so, since
#304, fetches formula.jws.json (~33 MB plain, ~5 MB gzip) uncompressed on
every refresh -- the case that motivated #305.
Request gzip for the JSON API via proxyCachedWithEncoding: brew fetches
every API download with curl --compressed and decodes Content-Encoding
itself, so the compressed bytes and header are cached and served as-is
and both hops stay compressed. The analytics endpoints are the one brew
consumer fetched without --compressed; they stay on identity.
* fix(handler): leave Accept-Encoding unset in proxyMetadataStream for an empty value
fetchUpstreamMetadata treats an empty acceptEncoding as 'do not set the
header'; proxyMetadataStream set it unconditionally, which would send an
empty Accept-Encoding line if a caller ever passed . Guard it the same
way so both paths agree. No caller passes today.
* fix(handler): keep the metadata row and blob from describing different bytes
Two ways the cache row could stop describing the stored blob once a
caller requests gzip, both raised by the review of #324:
- cacheMetadataBlob stored the blob and then discarded the
UpsertMetadataCache error. After a successful gzip store and a failed
row write, a later TTL hit or stale fallback served the gzip blob with
the previous row's encoding. On a row-write failure, log it and delete
the blob just written, so the next request refetches instead.
- fetchOrCacheMetadata read the row once up front and reused it for the
stale fallback. A request that read an identity row, lost the upstream
race to a request that stored the gzip blob, and then failed upstream
labelled the new blob with the old row. Re-read the row before falling
back so the encoding matches the blob as it is now.
Both only become harmful with an encoding change, which this branch
introduces; the pre-existing validator-from-row read is tracked
separately.
* Drop unused cachedMeta.contentEncoding and fix stale doc reference
The field was added by #304 and its only reader is replaced in this
branch by the encoding parameter passed alongside the body. The
proxyCachedWithEncoding comment named conda repodata, which was moved
out of this branch in 5991d95; Homebrew is the caller that ships here.
---------
Co-authored-by: Andrew Nesbitt <andrewnez@gmail.com>
2026-09-15 20:50:44 +02:00
|
|
|
p.writeMetadataCachedResponseWithEncoding(w, r, ecosystem, cacheKey, body, contentType, "")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// writeMetadataCachedResponseWithEncoding is writeMetadataCachedResponse with
|
|
|
|
|
// an explicit Content-Encoding. contentEncoding must describe the body being
|
|
|
|
|
// written; it is passed in rather than re-read from the cache row, which is
|
|
|
|
|
// missing or stale when the metadata cache write failed and would otherwise
|
|
|
|
|
// mislabel the bytes.
|
|
|
|
|
func (p *Proxy) writeMetadataCachedResponseWithEncoding(w http.ResponseWriter, r *http.Request, ecosystem, cacheKey string, body []byte, contentType, contentEncoding string) {
|
2026-04-06 19:30:59 +01:00
|
|
|
cm := p.lookupCachedMeta(ecosystem, cacheKey)
|
Fix metadata caching, 404 propagation, mirror progress, and registry stubs
- ProxyCached now stores upstream Last-Modified in the cache and uses it
(along with ETag) for conditional request handling, returning 304 when
client validators match. Adds Content-Length to cached responses.
- Handlers calling FetchOrCacheMetadata (pypi, composer, pub, nuget) now
check for ErrUpstreamNotFound and return 404 instead of 502, matching
the existing npm and cargo behavior.
- Mirror jobs report live progress via a periodic callback while running,
so API polls return real counts instead of zeroed progress.
- Registry mirroring removed from CLI flags, API acceptance, README, and
docs since every enumerator was a stub returning "not yet implemented".
- Added tests for the conditional metadata path (ETag/If-None-Match,
Last-Modified/If-Modified-Since, 304 responses, header omission).
2026-04-01 20:14:11 +01:00
|
|
|
|
2026-04-06 19:30:59 +01:00
|
|
|
if cm.etag != "" {
|
2026-09-03 16:59:12 +01:00
|
|
|
w.Header().Set(headerETag, cm.etag)
|
|
|
|
|
}
|
|
|
|
|
if !cm.lastModified.IsZero() {
|
|
|
|
|
w.Header().Set(headerLastModified, cm.lastModified.UTC().Format(http.TimeFormat))
|
|
|
|
|
}
|
|
|
|
|
if ifNoneMatchHits(r.Header.Get("If-None-Match"), cm.etag) {
|
|
|
|
|
w.WriteHeader(http.StatusNotModified)
|
|
|
|
|
return
|
Fix metadata caching, 404 propagation, mirror progress, and registry stubs
- ProxyCached now stores upstream Last-Modified in the cache and uses it
(along with ETag) for conditional request handling, returning 304 when
client validators match. Adds Content-Length to cached responses.
- Handlers calling FetchOrCacheMetadata (pypi, composer, pub, nuget) now
check for ErrUpstreamNotFound and return 404 instead of 502, matching
the existing npm and cargo behavior.
- Mirror jobs report live progress via a periodic callback while running,
so API polls return real counts instead of zeroed progress.
- Registry mirroring removed from CLI flags, API acceptance, README, and
docs since every enumerator was a stub returning "not yet implemented".
- Added tests for the conditional metadata path (ETag/If-None-Match,
Last-Modified/If-Modified-Since, 304 responses, header omission).
2026-04-01 20:14:11 +01:00
|
|
|
}
|
2026-04-06 19:30:59 +01:00
|
|
|
if !cm.lastModified.IsZero() {
|
Fix metadata caching, 404 propagation, mirror progress, and registry stubs
- ProxyCached now stores upstream Last-Modified in the cache and uses it
(along with ETag) for conditional request handling, returning 304 when
client validators match. Adds Content-Length to cached responses.
- Handlers calling FetchOrCacheMetadata (pypi, composer, pub, nuget) now
check for ErrUpstreamNotFound and return 404 instead of 502, matching
the existing npm and cargo behavior.
- Mirror jobs report live progress via a periodic callback while running,
so API polls return real counts instead of zeroed progress.
- Registry mirroring removed from CLI flags, API acceptance, README, and
docs since every enumerator was a stub returning "not yet implemented".
- Added tests for the conditional metadata path (ETag/If-None-Match,
Last-Modified/If-Modified-Since, 304 responses, header omission).
2026-04-01 20:14:11 +01:00
|
|
|
if ims := r.Header.Get("If-Modified-Since"); ims != "" {
|
2026-04-06 19:30:59 +01:00
|
|
|
if t, err := http.ParseTime(ims); err == nil && !cm.lastModified.After(t) {
|
Fix metadata caching, 404 propagation, mirror progress, and registry stubs
- ProxyCached now stores upstream Last-Modified in the cache and uses it
(along with ETag) for conditional request handling, returning 304 when
client validators match. Adds Content-Length to cached responses.
- Handlers calling FetchOrCacheMetadata (pypi, composer, pub, nuget) now
check for ErrUpstreamNotFound and return 404 instead of 502, matching
the existing npm and cargo behavior.
- Mirror jobs report live progress via a periodic callback while running,
so API polls return real counts instead of zeroed progress.
- Registry mirroring removed from CLI flags, API acceptance, README, and
docs since every enumerator was a stub returning "not yet implemented".
- Added tests for the conditional metadata path (ETag/If-None-Match,
Last-Modified/If-Modified-Since, 304 responses, header omission).
2026-04-01 20:14:11 +01:00
|
|
|
w.WriteHeader(http.StatusNotModified)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-02 13:22:02 +01:00
|
|
|
w.Header().Set(headerContentType, contentType)
|
|
|
|
|
w.Header().Set(headerContentLength, strconv.Itoa(len(body)))
|
fix(handler): let the ProxyCached path request a per-call Accept-Encoding (#324)
* fix(handler): fetch conda repodata gzip-compressed on both hops
#304 made the ProxyCached path request Accept-Encoding: identity so the
metadata cache stores upstream bytes verbatim. That is required for the
signed / hash-pinned index ecosystems, but conda's repodata.json is large
plain JSON: linux-64 repodata.json is ~441 MB uncompressed (over the
metadata_max_size cap, so it 502s today) versus ~34 MB gzip.
Replace the ProxyCached path's verbatim bool with an explicit
acceptEncoding string ('' = leave unset / transparent, 'identity', or
'gzip'), reusing #304's existing store-and-replay of Content-Encoding
unchanged. ProxyCached keeps its exported signature and continues to send
identity, so the nine other ecosystems and helm/maven are untouched; only
conda's repodata.json / current_repodata.json now request gzip. Setting
Accept-Encoding explicitly disables Go's transparent decompression, so the
compressed bytes and the Content-Encoding: gzip header are cached and
replayed exactly as identity bytes are. conda, mamba and pixi solicit and
decode gzip on .json URLs; repodata.json.bz2 stays identity.
Fixes #305
* fix(handler): pass metadata content-encoding with the body it describes
The adversarial review of the conda gzip route found a reachable
regression: writeMetadataCachedResponse took Content-Encoding from a
fresh cache-row read while cacheMetadataBlob skips the row write when
Storage.Store fails. Under identity that was benign (the body was plain
anyway), but on the new gzip route a disk-full or object-store outage
served raw gzip bytes as Content-Type: application/json with no
Content-Encoding and HTTP 200 -- conda, mamba and pixi fail to parse
them, with no HTTP signal and only a Warn log, on every request until a
cache write succeeds.
fetchOrCacheMetadata now returns the encoding of the body it hands back
(the upstream value on a fetch, the stored row's value on a TTL hit or
stale fallback) and proxyCachedWithEncoding passes it to
writeMetadataCachedResponse, so the header always describes the bytes
actually written. cachedMeta drops its now-unused content_encoding
field. helm and maven pass "" -- both fetch transparently, so their
stored encoding was always empty and behaviour is unchanged.
Also fixes a vacuous assertion in the new conda test: the upstream
request counter incremented behind the availability gate, so the
cached-replay block could never observe a refetch.
* fix(handler): pin the stale-fallback content-encoding and drop a dead guard
Follow-ups from the adversarial review of the #305 branch, limited to
code this branch introduced:
- proxyMetadataStream is only ever reached with an explicit
Accept-Encoding (ProxyCached passes identity, conda passes gzip or
identity), so the guard around the header set was unreachable; replace
it with the plain one-token substitution of the former literal, which
is the smallest change from main.
- The stale-fallback return of fetchOrCacheMetadata (encoding taken from
the cache row) was the one #305 return site no test pinned: replacing
it with an empty encoding survived the whole suite. Add a conda test
that expires the entry, fails the upstream, and asserts the stored
gzip blob is served with Content-Encoding: gzip.
Not changed, by scope: cacheMetadataBlob still discards the
UpsertMetadataCache error (pre-existing on main). If Storage.Store
succeeds and the row write fails, a later stale fallback or TTL hit can
serve the gzip blob with the row's stale encoding; that needs a DB write
failure plus a second event and is tracked separately.
* fix(handler): restore the pre-existing cachedMeta content-encoding field
The third adversarial review classified deleting cachedMeta.contentEncoding
and its lookupCachedMeta populate as elective: neither line was created by
this branch nor forced by the fix (writeMetadataCachedResponse now reads
the encoding from its parameter and ignores the row value). Under the rule
that pre-existing code this branch did not have to touch stays untouched,
restore both as they are on main. No behaviour change.
Residuals the review documented, unchanged by scope (both share one root
cause: the encoding lives in the cache row and the bytes in the blob, and
neither is written or read atomically):
- cacheMetadataBlob discards the UpsertMetadataCache error, so after a
successful gzip Store and a failed row write a later stale fallback or
TTL hit can serve the gzip blob with the row's stale encoding.
- During the one-time identity->gzip rollout, a request that read a
pre-branch identity row, lost the upstream race to a request that stored
the gzip blob, and then failed upstream serves the gzip bytes with no
Content-Encoding for that one response; later requests self-heal.
- helm and maven now pass an empty encoding; on main a spec-violating
upstream that answered a transparent gzip request with an encoding Go
does not decode (e.g. br) would have had that header replayed from the
row. Degenerate; documented rather than changed.
* fix(handler): keep conda's proxyCached and .bz2 route as on main
Threading acceptEncoding through CondaHandler.proxyCached changed the
form of two pieces of original code the fix did not need to touch: the
repodata.json.bz2 route (method value rewritten as a closure) and
proxyCached itself (new parameter, new call). Restore both exactly as on
main; ProxyCached still sends identity, so the .bz2 route is unchanged in
behaviour. handleRepodata's non-cooldown branch now derives the cache key
inline and calls proxyCachedWithEncoding with gzip directly, so the only
original conda.go line that changes is that one call.
* fix(handler): keep writeMetadataCachedResponse and its callers as on main
Adding a contentEncoding parameter to writeMetadataCachedResponse changed
a signature that predates #304 and dragged its two pre-#304 callers
(helm.go, maven.go) into the diff, even though #304 only ever added the
cm.contentEncoding block inside the function body.
Restore writeMetadataCachedResponse's doc and signature exactly as on
main and make it a delegate that passes an empty encoding to a new
unexported writeMetadataCachedResponseWithEncoding, which carries the
original body with #304's block reading the parameter instead of the
cache row. proxyCachedWithEncoding calls the sibling with the encoding
returned alongside the body. helm.go and maven.go drop out of the diff;
their behaviour is unchanged (both fetch transparently, so their stored
encoding was always empty). Same split pattern as ProxyCached ->
proxyCachedWithEncoding.
* fix(handler): move the conda gzip change to its own branch
The conda call site in handleRepodata predates #304 and #304 never
touched it, so under the rule that this PR only corrects code and
behaviour #304 introduced it does not belong here. Restore conda.go and
conda_test.go as on main; the conda change continues on a stacked branch
against its own issue.
Replace the conda-route tests with tests that exercise
proxyCachedWithEncoding directly, so this PR still pins its own plumbing:
gzip is requested and the compressed bytes plus Content-Encoding are
cached and replayed (cached and streaming paths), the header survives a
metadata cache write failure, and the stale fallback keeps the stored
encoding.
* fix(homebrew): fetch the JSON API gzip-compressed on both hops
Homebrew (#254) routes every API path through ProxyCached and so, since
#304, fetches formula.jws.json (~33 MB plain, ~5 MB gzip) uncompressed on
every refresh -- the case that motivated #305.
Request gzip for the JSON API via proxyCachedWithEncoding: brew fetches
every API download with curl --compressed and decodes Content-Encoding
itself, so the compressed bytes and header are cached and served as-is
and both hops stay compressed. The analytics endpoints are the one brew
consumer fetched without --compressed; they stay on identity.
* fix(handler): leave Accept-Encoding unset in proxyMetadataStream for an empty value
fetchUpstreamMetadata treats an empty acceptEncoding as 'do not set the
header'; proxyMetadataStream set it unconditionally, which would send an
empty Accept-Encoding line if a caller ever passed . Guard it the same
way so both paths agree. No caller passes today.
* fix(handler): keep the metadata row and blob from describing different bytes
Two ways the cache row could stop describing the stored blob once a
caller requests gzip, both raised by the review of #324:
- cacheMetadataBlob stored the blob and then discarded the
UpsertMetadataCache error. After a successful gzip store and a failed
row write, a later TTL hit or stale fallback served the gzip blob with
the previous row's encoding. On a row-write failure, log it and delete
the blob just written, so the next request refetches instead.
- fetchOrCacheMetadata read the row once up front and reused it for the
stale fallback. A request that read an identity row, lost the upstream
race to a request that stored the gzip blob, and then failed upstream
labelled the new blob with the old row. Re-read the row before falling
back so the encoding matches the blob as it is now.
Both only become harmful with an encoding change, which this branch
introduces; the pre-existing validator-from-row read is tracked
separately.
* Drop unused cachedMeta.contentEncoding and fix stale doc reference
The field was added by #304 and its only reader is replaced in this
branch by the encoding parameter passed alongside the body. The
proxyCachedWithEncoding comment named conda repodata, which was moved
out of this branch in 5991d95; Homebrew is the caller that ships here.
---------
Co-authored-by: Andrew Nesbitt <andrewnez@gmail.com>
2026-09-15 20:50:44 +02:00
|
|
|
if contentEncoding != "" {
|
|
|
|
|
w.Header().Set(headerContentEncoding, contentEncoding)
|
fix(handler): preserve upstream content-encoding for cached metadata (#304)
* fix(handler): preserve upstream content-encoding for cached metadata
Signed and hash-pinned index files (debian Release/Packages.gz, rpm
repomd.xml, helm index.yaml, conda repodata.json, apk APKINDEX.tar.gz)
were cached after Go's transport transparently decompressed any
Content-Encoding: gzip response, so the proxy served bytes that differ
from what the upstream signed and broke client verification.
Request the identity encoding on the shared metadata fetch so Go no
longer auto-decompresses, and persist the upstream Content-Encoding in
a new metadata_cache column so both the cached and offline responses
replay the exact bytes and header. The uncached streaming path now
forwards Content-Encoding too.
Fixes #300
* fix(handler): scope verbatim metadata fetch to the ProxyCached path
The first cut forced Accept-Encoding: identity in the shared
fetchUpstreamMetadata, which an adversarial review showed both missed
the bug and regressed unrelated ecosystems:
- proxyMetadataStream (the default path, since cache_metadata is off)
never forced identity, so a client sending no Accept-Encoding still
triggered Go's transparent gzip decompression and served altered bytes
for apk/debian/rpm/conda indexes.
- Direct FetchOrCacheMetadata callers that parse or rewrite the body
(npm, pypi, cargo, composer, pub, nuget, swift, maven, helm) were
forced to identity too, losing wire compression and 502-ing against
upstreams that ignore identity and gzip anyway (helm rewriteIndex).
- Rows cached before the fix kept serving decompressed bytes via ETag
304 revalidation.
Scope the verbatim behavior to the ProxyCached code path, which serves
upstream bytes through unchanged (apk, debian, rpm, go, hex, conda,
cran, gem, conan, julia). That path now requests identity on both the
cached fetch and the uncached stream branch and replays Content-Encoding;
direct callers keep transparent compression, matching main. Migration
008 clears etag/fetched_at so legacy rows refetch once with identity.
Tests now exercise the stream path with no client Accept-Encoding and
pin that direct callers are not forced to identity.
2026-09-03 10:58:58 +02:00
|
|
|
}
|
2026-04-06 19:30:59 +01:00
|
|
|
if cm.stale {
|
|
|
|
|
w.Header().Set("Warning", `110 - "Response is Stale"`)
|
Fix metadata caching, 404 propagation, mirror progress, and registry stubs
- ProxyCached now stores upstream Last-Modified in the cache and uses it
(along with ETag) for conditional request handling, returning 304 when
client validators match. Adds Content-Length to cached responses.
- Handlers calling FetchOrCacheMetadata (pypi, composer, pub, nuget) now
check for ErrUpstreamNotFound and return 404 instead of 502, matching
the existing npm and cargo behavior.
- Mirror jobs report live progress via a periodic callback while running,
so API polls return real counts instead of zeroed progress.
- Registry mirroring removed from CLI flags, API acceptance, README, and
docs since every enumerator was a stub returning "not yet implemented".
- Added tests for the conditional metadata path (ETag/If-None-Match,
Last-Modified/If-Modified-Since, 304 responses, header omission).
2026-04-01 20:14:11 +01:00
|
|
|
}
|
2026-03-19 21:06:02 +00:00
|
|
|
w.WriteHeader(http.StatusOK)
|
2026-09-03 16:59:12 +01:00
|
|
|
if r.Method != http.MethodHead {
|
|
|
|
|
_, _ = w.Write(body)
|
|
|
|
|
}
|
2026-01-20 21:52:44 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-01 16:14:07 +01:00
|
|
|
// proxyMetadataStream forwards an upstream metadata response by streaming it to the client
|
|
|
|
|
// without buffering the full body in memory.
|
fix(handler): let the ProxyCached path request a per-call Accept-Encoding (#324)
* fix(handler): fetch conda repodata gzip-compressed on both hops
#304 made the ProxyCached path request Accept-Encoding: identity so the
metadata cache stores upstream bytes verbatim. That is required for the
signed / hash-pinned index ecosystems, but conda's repodata.json is large
plain JSON: linux-64 repodata.json is ~441 MB uncompressed (over the
metadata_max_size cap, so it 502s today) versus ~34 MB gzip.
Replace the ProxyCached path's verbatim bool with an explicit
acceptEncoding string ('' = leave unset / transparent, 'identity', or
'gzip'), reusing #304's existing store-and-replay of Content-Encoding
unchanged. ProxyCached keeps its exported signature and continues to send
identity, so the nine other ecosystems and helm/maven are untouched; only
conda's repodata.json / current_repodata.json now request gzip. Setting
Accept-Encoding explicitly disables Go's transparent decompression, so the
compressed bytes and the Content-Encoding: gzip header are cached and
replayed exactly as identity bytes are. conda, mamba and pixi solicit and
decode gzip on .json URLs; repodata.json.bz2 stays identity.
Fixes #305
* fix(handler): pass metadata content-encoding with the body it describes
The adversarial review of the conda gzip route found a reachable
regression: writeMetadataCachedResponse took Content-Encoding from a
fresh cache-row read while cacheMetadataBlob skips the row write when
Storage.Store fails. Under identity that was benign (the body was plain
anyway), but on the new gzip route a disk-full or object-store outage
served raw gzip bytes as Content-Type: application/json with no
Content-Encoding and HTTP 200 -- conda, mamba and pixi fail to parse
them, with no HTTP signal and only a Warn log, on every request until a
cache write succeeds.
fetchOrCacheMetadata now returns the encoding of the body it hands back
(the upstream value on a fetch, the stored row's value on a TTL hit or
stale fallback) and proxyCachedWithEncoding passes it to
writeMetadataCachedResponse, so the header always describes the bytes
actually written. cachedMeta drops its now-unused content_encoding
field. helm and maven pass "" -- both fetch transparently, so their
stored encoding was always empty and behaviour is unchanged.
Also fixes a vacuous assertion in the new conda test: the upstream
request counter incremented behind the availability gate, so the
cached-replay block could never observe a refetch.
* fix(handler): pin the stale-fallback content-encoding and drop a dead guard
Follow-ups from the adversarial review of the #305 branch, limited to
code this branch introduced:
- proxyMetadataStream is only ever reached with an explicit
Accept-Encoding (ProxyCached passes identity, conda passes gzip or
identity), so the guard around the header set was unreachable; replace
it with the plain one-token substitution of the former literal, which
is the smallest change from main.
- The stale-fallback return of fetchOrCacheMetadata (encoding taken from
the cache row) was the one #305 return site no test pinned: replacing
it with an empty encoding survived the whole suite. Add a conda test
that expires the entry, fails the upstream, and asserts the stored
gzip blob is served with Content-Encoding: gzip.
Not changed, by scope: cacheMetadataBlob still discards the
UpsertMetadataCache error (pre-existing on main). If Storage.Store
succeeds and the row write fails, a later stale fallback or TTL hit can
serve the gzip blob with the row's stale encoding; that needs a DB write
failure plus a second event and is tracked separately.
* fix(handler): restore the pre-existing cachedMeta content-encoding field
The third adversarial review classified deleting cachedMeta.contentEncoding
and its lookupCachedMeta populate as elective: neither line was created by
this branch nor forced by the fix (writeMetadataCachedResponse now reads
the encoding from its parameter and ignores the row value). Under the rule
that pre-existing code this branch did not have to touch stays untouched,
restore both as they are on main. No behaviour change.
Residuals the review documented, unchanged by scope (both share one root
cause: the encoding lives in the cache row and the bytes in the blob, and
neither is written or read atomically):
- cacheMetadataBlob discards the UpsertMetadataCache error, so after a
successful gzip Store and a failed row write a later stale fallback or
TTL hit can serve the gzip blob with the row's stale encoding.
- During the one-time identity->gzip rollout, a request that read a
pre-branch identity row, lost the upstream race to a request that stored
the gzip blob, and then failed upstream serves the gzip bytes with no
Content-Encoding for that one response; later requests self-heal.
- helm and maven now pass an empty encoding; on main a spec-violating
upstream that answered a transparent gzip request with an encoding Go
does not decode (e.g. br) would have had that header replayed from the
row. Degenerate; documented rather than changed.
* fix(handler): keep conda's proxyCached and .bz2 route as on main
Threading acceptEncoding through CondaHandler.proxyCached changed the
form of two pieces of original code the fix did not need to touch: the
repodata.json.bz2 route (method value rewritten as a closure) and
proxyCached itself (new parameter, new call). Restore both exactly as on
main; ProxyCached still sends identity, so the .bz2 route is unchanged in
behaviour. handleRepodata's non-cooldown branch now derives the cache key
inline and calls proxyCachedWithEncoding with gzip directly, so the only
original conda.go line that changes is that one call.
* fix(handler): keep writeMetadataCachedResponse and its callers as on main
Adding a contentEncoding parameter to writeMetadataCachedResponse changed
a signature that predates #304 and dragged its two pre-#304 callers
(helm.go, maven.go) into the diff, even though #304 only ever added the
cm.contentEncoding block inside the function body.
Restore writeMetadataCachedResponse's doc and signature exactly as on
main and make it a delegate that passes an empty encoding to a new
unexported writeMetadataCachedResponseWithEncoding, which carries the
original body with #304's block reading the parameter instead of the
cache row. proxyCachedWithEncoding calls the sibling with the encoding
returned alongside the body. helm.go and maven.go drop out of the diff;
their behaviour is unchanged (both fetch transparently, so their stored
encoding was always empty). Same split pattern as ProxyCached ->
proxyCachedWithEncoding.
* fix(handler): move the conda gzip change to its own branch
The conda call site in handleRepodata predates #304 and #304 never
touched it, so under the rule that this PR only corrects code and
behaviour #304 introduced it does not belong here. Restore conda.go and
conda_test.go as on main; the conda change continues on a stacked branch
against its own issue.
Replace the conda-route tests with tests that exercise
proxyCachedWithEncoding directly, so this PR still pins its own plumbing:
gzip is requested and the compressed bytes plus Content-Encoding are
cached and replayed (cached and streaming paths), the header survives a
metadata cache write failure, and the stale fallback keeps the stored
encoding.
* fix(homebrew): fetch the JSON API gzip-compressed on both hops
Homebrew (#254) routes every API path through ProxyCached and so, since
#304, fetches formula.jws.json (~33 MB plain, ~5 MB gzip) uncompressed on
every refresh -- the case that motivated #305.
Request gzip for the JSON API via proxyCachedWithEncoding: brew fetches
every API download with curl --compressed and decodes Content-Encoding
itself, so the compressed bytes and header are cached and served as-is
and both hops stay compressed. The analytics endpoints are the one brew
consumer fetched without --compressed; they stay on identity.
* fix(handler): leave Accept-Encoding unset in proxyMetadataStream for an empty value
fetchUpstreamMetadata treats an empty acceptEncoding as 'do not set the
header'; proxyMetadataStream set it unconditionally, which would send an
empty Accept-Encoding line if a caller ever passed . Guard it the same
way so both paths agree. No caller passes today.
* fix(handler): keep the metadata row and blob from describing different bytes
Two ways the cache row could stop describing the stored blob once a
caller requests gzip, both raised by the review of #324:
- cacheMetadataBlob stored the blob and then discarded the
UpsertMetadataCache error. After a successful gzip store and a failed
row write, a later TTL hit or stale fallback served the gzip blob with
the previous row's encoding. On a row-write failure, log it and delete
the blob just written, so the next request refetches instead.
- fetchOrCacheMetadata read the row once up front and reused it for the
stale fallback. A request that read an identity row, lost the upstream
race to a request that stored the gzip blob, and then failed upstream
labelled the new blob with the old row. Re-read the row before falling
back so the encoding matches the blob as it is now.
Both only become harmful with an encoding change, which this branch
introduces; the pre-existing validator-from-row read is tracked
separately.
* Drop unused cachedMeta.contentEncoding and fix stale doc reference
The field was added by #304 and its only reader is replaced in this
branch by the encoding parameter passed alongside the body. The
proxyCachedWithEncoding comment named conda repodata, which was moved
out of this branch in 5991d95; Homebrew is the caller that ships here.
---------
Co-authored-by: Andrew Nesbitt <andrewnez@gmail.com>
2026-09-15 20:50:44 +02:00
|
|
|
func (p *Proxy) proxyMetadataStream(w http.ResponseWriter, r *http.Request, upstreamURL, acceptEncoding string, acceptHeaders ...string) {
|
2026-09-03 16:59:12 +01:00
|
|
|
req, err := http.NewRequestWithContext(r.Context(), r.Method, upstreamURL, nil)
|
2026-04-01 16:14:07 +01:00
|
|
|
if err != nil {
|
|
|
|
|
http.Error(w, "failed to create request", http.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
accept := contentTypeJSON
|
|
|
|
|
if len(acceptHeaders) > 0 && acceptHeaders[0] != "" {
|
|
|
|
|
accept = acceptHeaders[0]
|
|
|
|
|
}
|
|
|
|
|
req.Header.Set("Accept", accept)
|
fix(handler): let the ProxyCached path request a per-call Accept-Encoding (#324)
* fix(handler): fetch conda repodata gzip-compressed on both hops
#304 made the ProxyCached path request Accept-Encoding: identity so the
metadata cache stores upstream bytes verbatim. That is required for the
signed / hash-pinned index ecosystems, but conda's repodata.json is large
plain JSON: linux-64 repodata.json is ~441 MB uncompressed (over the
metadata_max_size cap, so it 502s today) versus ~34 MB gzip.
Replace the ProxyCached path's verbatim bool with an explicit
acceptEncoding string ('' = leave unset / transparent, 'identity', or
'gzip'), reusing #304's existing store-and-replay of Content-Encoding
unchanged. ProxyCached keeps its exported signature and continues to send
identity, so the nine other ecosystems and helm/maven are untouched; only
conda's repodata.json / current_repodata.json now request gzip. Setting
Accept-Encoding explicitly disables Go's transparent decompression, so the
compressed bytes and the Content-Encoding: gzip header are cached and
replayed exactly as identity bytes are. conda, mamba and pixi solicit and
decode gzip on .json URLs; repodata.json.bz2 stays identity.
Fixes #305
* fix(handler): pass metadata content-encoding with the body it describes
The adversarial review of the conda gzip route found a reachable
regression: writeMetadataCachedResponse took Content-Encoding from a
fresh cache-row read while cacheMetadataBlob skips the row write when
Storage.Store fails. Under identity that was benign (the body was plain
anyway), but on the new gzip route a disk-full or object-store outage
served raw gzip bytes as Content-Type: application/json with no
Content-Encoding and HTTP 200 -- conda, mamba and pixi fail to parse
them, with no HTTP signal and only a Warn log, on every request until a
cache write succeeds.
fetchOrCacheMetadata now returns the encoding of the body it hands back
(the upstream value on a fetch, the stored row's value on a TTL hit or
stale fallback) and proxyCachedWithEncoding passes it to
writeMetadataCachedResponse, so the header always describes the bytes
actually written. cachedMeta drops its now-unused content_encoding
field. helm and maven pass "" -- both fetch transparently, so their
stored encoding was always empty and behaviour is unchanged.
Also fixes a vacuous assertion in the new conda test: the upstream
request counter incremented behind the availability gate, so the
cached-replay block could never observe a refetch.
* fix(handler): pin the stale-fallback content-encoding and drop a dead guard
Follow-ups from the adversarial review of the #305 branch, limited to
code this branch introduced:
- proxyMetadataStream is only ever reached with an explicit
Accept-Encoding (ProxyCached passes identity, conda passes gzip or
identity), so the guard around the header set was unreachable; replace
it with the plain one-token substitution of the former literal, which
is the smallest change from main.
- The stale-fallback return of fetchOrCacheMetadata (encoding taken from
the cache row) was the one #305 return site no test pinned: replacing
it with an empty encoding survived the whole suite. Add a conda test
that expires the entry, fails the upstream, and asserts the stored
gzip blob is served with Content-Encoding: gzip.
Not changed, by scope: cacheMetadataBlob still discards the
UpsertMetadataCache error (pre-existing on main). If Storage.Store
succeeds and the row write fails, a later stale fallback or TTL hit can
serve the gzip blob with the row's stale encoding; that needs a DB write
failure plus a second event and is tracked separately.
* fix(handler): restore the pre-existing cachedMeta content-encoding field
The third adversarial review classified deleting cachedMeta.contentEncoding
and its lookupCachedMeta populate as elective: neither line was created by
this branch nor forced by the fix (writeMetadataCachedResponse now reads
the encoding from its parameter and ignores the row value). Under the rule
that pre-existing code this branch did not have to touch stays untouched,
restore both as they are on main. No behaviour change.
Residuals the review documented, unchanged by scope (both share one root
cause: the encoding lives in the cache row and the bytes in the blob, and
neither is written or read atomically):
- cacheMetadataBlob discards the UpsertMetadataCache error, so after a
successful gzip Store and a failed row write a later stale fallback or
TTL hit can serve the gzip blob with the row's stale encoding.
- During the one-time identity->gzip rollout, a request that read a
pre-branch identity row, lost the upstream race to a request that stored
the gzip blob, and then failed upstream serves the gzip bytes with no
Content-Encoding for that one response; later requests self-heal.
- helm and maven now pass an empty encoding; on main a spec-violating
upstream that answered a transparent gzip request with an encoding Go
does not decode (e.g. br) would have had that header replayed from the
row. Degenerate; documented rather than changed.
* fix(handler): keep conda's proxyCached and .bz2 route as on main
Threading acceptEncoding through CondaHandler.proxyCached changed the
form of two pieces of original code the fix did not need to touch: the
repodata.json.bz2 route (method value rewritten as a closure) and
proxyCached itself (new parameter, new call). Restore both exactly as on
main; ProxyCached still sends identity, so the .bz2 route is unchanged in
behaviour. handleRepodata's non-cooldown branch now derives the cache key
inline and calls proxyCachedWithEncoding with gzip directly, so the only
original conda.go line that changes is that one call.
* fix(handler): keep writeMetadataCachedResponse and its callers as on main
Adding a contentEncoding parameter to writeMetadataCachedResponse changed
a signature that predates #304 and dragged its two pre-#304 callers
(helm.go, maven.go) into the diff, even though #304 only ever added the
cm.contentEncoding block inside the function body.
Restore writeMetadataCachedResponse's doc and signature exactly as on
main and make it a delegate that passes an empty encoding to a new
unexported writeMetadataCachedResponseWithEncoding, which carries the
original body with #304's block reading the parameter instead of the
cache row. proxyCachedWithEncoding calls the sibling with the encoding
returned alongside the body. helm.go and maven.go drop out of the diff;
their behaviour is unchanged (both fetch transparently, so their stored
encoding was always empty). Same split pattern as ProxyCached ->
proxyCachedWithEncoding.
* fix(handler): move the conda gzip change to its own branch
The conda call site in handleRepodata predates #304 and #304 never
touched it, so under the rule that this PR only corrects code and
behaviour #304 introduced it does not belong here. Restore conda.go and
conda_test.go as on main; the conda change continues on a stacked branch
against its own issue.
Replace the conda-route tests with tests that exercise
proxyCachedWithEncoding directly, so this PR still pins its own plumbing:
gzip is requested and the compressed bytes plus Content-Encoding are
cached and replayed (cached and streaming paths), the header survives a
metadata cache write failure, and the stale fallback keeps the stored
encoding.
* fix(homebrew): fetch the JSON API gzip-compressed on both hops
Homebrew (#254) routes every API path through ProxyCached and so, since
#304, fetches formula.jws.json (~33 MB plain, ~5 MB gzip) uncompressed on
every refresh -- the case that motivated #305.
Request gzip for the JSON API via proxyCachedWithEncoding: brew fetches
every API download with curl --compressed and decodes Content-Encoding
itself, so the compressed bytes and header are cached and served as-is
and both hops stay compressed. The analytics endpoints are the one brew
consumer fetched without --compressed; they stay on identity.
* fix(handler): leave Accept-Encoding unset in proxyMetadataStream for an empty value
fetchUpstreamMetadata treats an empty acceptEncoding as 'do not set the
header'; proxyMetadataStream set it unconditionally, which would send an
empty Accept-Encoding line if a caller ever passed . Guard it the same
way so both paths agree. No caller passes today.
* fix(handler): keep the metadata row and blob from describing different bytes
Two ways the cache row could stop describing the stored blob once a
caller requests gzip, both raised by the review of #324:
- cacheMetadataBlob stored the blob and then discarded the
UpsertMetadataCache error. After a successful gzip store and a failed
row write, a later TTL hit or stale fallback served the gzip blob with
the previous row's encoding. On a row-write failure, log it and delete
the blob just written, so the next request refetches instead.
- fetchOrCacheMetadata read the row once up front and reused it for the
stale fallback. A request that read an identity row, lost the upstream
race to a request that stored the gzip blob, and then failed upstream
labelled the new blob with the old row. Re-read the row before falling
back so the encoding matches the blob as it is now.
Both only become harmful with an encoding change, which this branch
introduces; the pre-existing validator-from-row read is tracked
separately.
* Drop unused cachedMeta.contentEncoding and fix stale doc reference
The field was added by #304 and its only reader is replaced in this
branch by the encoding parameter passed alongside the body. The
proxyCachedWithEncoding comment named conda repodata, which was moved
out of this branch in 5991d95; Homebrew is the caller that ships here.
---------
Co-authored-by: Andrew Nesbitt <andrewnez@gmail.com>
2026-09-15 20:50:44 +02:00
|
|
|
// Set Accept-Encoding explicitly (identity, or gzip for compressible
|
|
|
|
|
// verbatim metadata) so Go does not transparently decompress and strip the
|
|
|
|
|
// Content-Encoding of the bytes we forward, regardless of what the client
|
|
|
|
|
// negotiated. An empty value leaves the header unset, as in
|
|
|
|
|
// fetchUpstreamMetadata.
|
|
|
|
|
if acceptEncoding != "" {
|
|
|
|
|
req.Header.Set(headerAcceptEncoding, acceptEncoding)
|
|
|
|
|
}
|
2026-04-01 16:14:07 +01:00
|
|
|
|
fix(handler): preserve upstream content-encoding for cached metadata (#304)
* fix(handler): preserve upstream content-encoding for cached metadata
Signed and hash-pinned index files (debian Release/Packages.gz, rpm
repomd.xml, helm index.yaml, conda repodata.json, apk APKINDEX.tar.gz)
were cached after Go's transport transparently decompressed any
Content-Encoding: gzip response, so the proxy served bytes that differ
from what the upstream signed and broke client verification.
Request the identity encoding on the shared metadata fetch so Go no
longer auto-decompresses, and persist the upstream Content-Encoding in
a new metadata_cache column so both the cached and offline responses
replay the exact bytes and header. The uncached streaming path now
forwards Content-Encoding too.
Fixes #300
* fix(handler): scope verbatim metadata fetch to the ProxyCached path
The first cut forced Accept-Encoding: identity in the shared
fetchUpstreamMetadata, which an adversarial review showed both missed
the bug and regressed unrelated ecosystems:
- proxyMetadataStream (the default path, since cache_metadata is off)
never forced identity, so a client sending no Accept-Encoding still
triggered Go's transparent gzip decompression and served altered bytes
for apk/debian/rpm/conda indexes.
- Direct FetchOrCacheMetadata callers that parse or rewrite the body
(npm, pypi, cargo, composer, pub, nuget, swift, maven, helm) were
forced to identity too, losing wire compression and 502-ing against
upstreams that ignore identity and gzip anyway (helm rewriteIndex).
- Rows cached before the fix kept serving decompressed bytes via ETag
304 revalidation.
Scope the verbatim behavior to the ProxyCached code path, which serves
upstream bytes through unchanged (apk, debian, rpm, go, hex, conda,
cran, gem, conan, julia). That path now requests identity on both the
cached fetch and the uncached stream branch and replays Content-Encoding;
direct callers keep transparent compression, matching main. Migration
008 clears etag/fetched_at so legacy rows refetch once with identity.
Tests now exercise the stream path with no client Accept-Encoding and
pin that direct callers are not forced to identity.
2026-09-03 10:58:58 +02:00
|
|
|
for _, header := range []string{"If-Modified-Since", "If-None-Match"} {
|
2026-04-01 16:14:07 +01:00
|
|
|
if v := r.Header.Get(header); v != "" {
|
|
|
|
|
req.Header.Set(header, v)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
resp, err := p.HTTPClient.Do(req)
|
|
|
|
|
if err != nil {
|
2026-10-14 12:51:43 +01:00
|
|
|
if writeUpstreamHeld(w, err) {
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-04-01 16:14:07 +01:00
|
|
|
http.Error(w, "failed to fetch from upstream", http.StatusBadGateway)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
|
|
2026-09-26 15:44:28 +05:30
|
|
|
p.relayResponse(w, r, resp, func(dst, src http.Header) {
|
|
|
|
|
for _, header := range []string{headerContentType, headerContentLength, headerContentEncoding, headerLastModified, headerETag} {
|
|
|
|
|
if v := src.Get(header); v != "" {
|
|
|
|
|
dst.Set(header, v)
|
|
|
|
|
}
|
2026-04-01 16:14:07 +01:00
|
|
|
}
|
2026-09-26 15:44:28 +05:30
|
|
|
})
|
2026-04-01 16:14:07 +01:00
|
|
|
}
|
|
|
|
|
|
2026-01-20 21:52:44 +00:00
|
|
|
// GetOrFetchArtifactFromURL retrieves an artifact from cache or fetches from a specific URL.
|
|
|
|
|
// This is useful for registries where download URLs are determined from metadata.
|
|
|
|
|
func (p *Proxy) GetOrFetchArtifactFromURL(ctx context.Context, ecosystem, name, version, filename, downloadURL string) (*CacheResult, error) {
|
2026-10-14 15:36:26 +02:00
|
|
|
return p.getOrFetchArtifactFromURL(ctx, ecosystem, name, version, filename, downloadURL, nil, "", "")
|
2026-04-01 15:22:39 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// GetOrFetchArtifactFromURLWithHeaders retrieves an artifact from cache or fetches from a URL
|
2026-08-13 07:35:07 +01:00
|
|
|
// with additional request-specific HTTP headers.
|
2026-04-01 15:22:39 +01:00
|
|
|
func (p *Proxy) GetOrFetchArtifactFromURLWithHeaders(ctx context.Context, ecosystem, name, version, filename, downloadURL string, headers http.Header) (*CacheResult, error) {
|
2026-10-14 15:36:26 +02:00
|
|
|
return p.getOrFetchArtifactFromURL(ctx, ecosystem, name, version, filename, downloadURL, headers, "", "")
|
2026-09-02 13:06:34 +01:00
|
|
|
}
|
|
|
|
|
|
2026-09-03 16:59:12 +01:00
|
|
|
// GetOrFetchArtifactFromURLWithDigest retrieves an artifact and verifies its
|
|
|
|
|
// SHA-256 digest before adding a newly fetched response to the cache.
|
|
|
|
|
// Non-sha256 digests are proxied without verification.
|
|
|
|
|
func (p *Proxy) GetOrFetchArtifactFromURLWithDigest(ctx context.Context, ecosystem, name, version, filename, downloadURL, digest string) (*CacheResult, error) {
|
|
|
|
|
upstreamHash, _ := strings.CutPrefix(digest, "sha256:")
|
|
|
|
|
if upstreamHash == digest {
|
|
|
|
|
upstreamHash = ""
|
|
|
|
|
}
|
2026-10-14 15:36:26 +02:00
|
|
|
return p.getOrFetchArtifactFromURL(ctx, ecosystem, name, version, filename, downloadURL, nil, upstreamHash, "")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// GetOrFetchArtifactFromURLWithIntegrity retrieves an artifact and verifies a
|
2026-10-14 15:43:39 +02:00
|
|
|
// newly fetched response against declared, the integrity the registry's
|
|
|
|
|
// metadata declares, before serving or caching it: a Subresource Integrity
|
|
|
|
|
// string, or a single algorithm-prefixed hex checksum such as "sha1-<hex>".
|
|
|
|
|
// declared is recorded with the cached version, so cache hits are verified
|
|
|
|
|
// against it as well. An empty declared fetches without verification.
|
2026-10-14 15:36:26 +02:00
|
|
|
func (p *Proxy) GetOrFetchArtifactFromURLWithIntegrity(ctx context.Context, ecosystem, name, version, filename, downloadURL, declared string) (*CacheResult, error) {
|
|
|
|
|
return p.getOrFetchArtifactFromURL(ctx, ecosystem, name, version, filename, downloadURL, nil, "", declared)
|
2026-09-03 16:59:12 +01:00
|
|
|
}
|
|
|
|
|
|
2026-10-14 15:36:26 +02:00
|
|
|
func (p *Proxy) getOrFetchArtifactFromURL(ctx context.Context, ecosystem, name, version, filename, downloadURL string, headers http.Header, upstreamHash, declared string) (*CacheResult, error) {
|
2026-09-02 13:06:34 +01:00
|
|
|
pkgPURL, versionPURL, err := packagePURLStrings(ecosystem, name, version)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
return p.getOrFetchArtifactFromURLWithCachePURLs(
|
2026-10-14 15:36:26 +02:00
|
|
|
ctx, ecosystem, name, version, filename, pkgPURL, versionPURL, downloadURL, headers, upstreamHash, declared,
|
2026-09-02 13:06:34 +01:00
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-14 15:36:26 +02:00
|
|
|
func (p *Proxy) getOrFetchArtifactFromURLWithCachePURLs(ctx context.Context, ecosystem, name, version, filename, pkgPURL, versionPURL, downloadURL string, headers http.Header, upstreamHash, declared string) (*CacheResult, error) {
|
2026-09-20 14:42:30 +05:30
|
|
|
// Some protocols use qualified cache keys. Check their package identity too.
|
|
|
|
|
if p.versionDenied(ecosystem, name, version) {
|
|
|
|
|
return nil, fmt.Errorf("%w: %s", ErrVersionDenied, canonicalVersionPURL(ecosystem, name, version))
|
|
|
|
|
}
|
Add storage.cache_artifacts to serve artifacts without storing them (#387)
* Add storage.passthrough to serve artifacts without storing them
With storage.passthrough enabled the proxy streams every artifact from
upstream to the client and never writes it to storage or the cache
database. Metadata filtering, cooldown and the denylist work as before,
so the proxy can sit behind another cache (e.g. an Artifactory remote)
purely as a policy layer without holding a second copy of every package.
Artifacts whose digest is known up front (OCI, Swift, Helm) are verified
while streaming. Their responses are sent chunked and the connection is
aborted on a mismatch, so a client never receives a tampered artifact as
a complete response.
Passthrough is rejected together with scanning, direct_serve and
mirror_api, and the mirror command refuses to run with it, since all of
them depend on stored artifacts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Address review: abort truncated streams, guard every cache read
- serveArtifact aborts the response on any body read error, and when fewer
bytes than the declared size were written, not only on a digest mismatch.
A failed or short upstream body in passthrough mode no longer reaches the
client as a complete 200.
- Streamed upstream read failures are logged and counted as
stream_failed upstream errors before the response is aborted.
- checkCache reports a miss when artifact caching is off, so every cache
read path, including Swift archive HEAD requests, ignores entries stored
before the mode was enabled. Denylisted versions are still rejected.
- Rename storage.passthrough to storage.cache_artifacts (default true,
PROXY_STORAGE_CACHE_ARTIFACTS), which names what actually changes.
Tests cover an OCI blob shorter than its Content-Length and an npm tarball
whose chunked response ends without the final chunk, both served through
the HTTP handlers, plus a Swift HEAD request against a cached archive.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Rename Proxy.Passthrough to StreamArtifacts
Match the storage.cache_artifacts option: the field says what changes
(artifacts are streamed instead of stored) and its zero value keeps the
usual caching behaviour for every Proxy built without the server config,
such as the mirror command and tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 15:28:20 +07:00
|
|
|
if p.StreamArtifacts {
|
2026-10-14 15:36:26 +02:00
|
|
|
return p.streamFromUpstream(ctx, ecosystem, name, version, filename, versionPURL, downloadURL, upstreamHash, declared,
|
Add storage.cache_artifacts to serve artifacts without storing them (#387)
* Add storage.passthrough to serve artifacts without storing them
With storage.passthrough enabled the proxy streams every artifact from
upstream to the client and never writes it to storage or the cache
database. Metadata filtering, cooldown and the denylist work as before,
so the proxy can sit behind another cache (e.g. an Artifactory remote)
purely as a policy layer without holding a second copy of every package.
Artifacts whose digest is known up front (OCI, Swift, Helm) are verified
while streaming. Their responses are sent chunked and the connection is
aborted on a mismatch, so a client never receives a tampered artifact as
a complete response.
Passthrough is rejected together with scanning, direct_serve and
mirror_api, and the mirror command refuses to run with it, since all of
them depend on stored artifacts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Address review: abort truncated streams, guard every cache read
- serveArtifact aborts the response on any body read error, and when fewer
bytes than the declared size were written, not only on a digest mismatch.
A failed or short upstream body in passthrough mode no longer reaches the
client as a complete 200.
- Streamed upstream read failures are logged and counted as
stream_failed upstream errors before the response is aborted.
- checkCache reports a miss when artifact caching is off, so every cache
read path, including Swift archive HEAD requests, ignores entries stored
before the mode was enabled. Denylisted versions are still rejected.
- Rename storage.passthrough to storage.cache_artifacts (default true,
PROXY_STORAGE_CACHE_ARTIFACTS), which names what actually changes.
Tests cover an OCI blob shorter than its Content-Length and an npm tarball
whose chunked response ends without the final chunk, both served through
the HTTP handlers, plus a Swift HEAD request against a cached archive.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Rename Proxy.Passthrough to StreamArtifacts
Match the storage.cache_artifacts option: the field says what changes
(artifacts are streamed instead of stored) and its zero value keeps the
usual caching behaviour for every Proxy built without the server config,
such as the mirror command and tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 15:28:20 +07:00
|
|
|
func(fetchCtx context.Context) (*fetch.Artifact, error) {
|
|
|
|
|
return p.Fetcher.FetchWithHeaders(fetchCtx, downloadURL, headers)
|
|
|
|
|
})
|
|
|
|
|
}
|
2026-09-02 13:06:34 +01:00
|
|
|
if cached, err := p.getCachedArtifactWithUpstreamHash(ctx, pkgPURL, versionPURL, filename, upstreamHash); err != nil {
|
2026-01-21 22:47:23 +00:00
|
|
|
return nil, err
|
|
|
|
|
} else if cached != nil {
|
|
|
|
|
return cached, nil
|
2026-01-20 21:52:44 +00:00
|
|
|
}
|
2026-08-16 18:01:59 +01:00
|
|
|
metrics.RecordCacheMiss(ecosystem)
|
2026-10-14 15:36:26 +02:00
|
|
|
return p.coalescedFetchFromURL(ctx, ecosystem, name, version, filename, pkgPURL, versionPURL, downloadURL, headers, upstreamHash, declared)
|
2026-09-16 00:20:27 -07:00
|
|
|
}
|
2026-01-20 21:52:44 +00:00
|
|
|
|
2026-09-16 00:20:27 -07:00
|
|
|
// coalescedFetchFromURL fetches an artifact the cache could not serve, sharing
|
|
|
|
|
// the fetch with concurrent callers. The caller running it discards a stale
|
|
|
|
|
// entry under the key, where it cannot delete a fetch that just replaced it.
|
2026-10-14 15:36:26 +02:00
|
|
|
func (p *Proxy) coalescedFetchFromURL(ctx context.Context, ecosystem, name, version, filename, pkgPURL, versionPURL, downloadURL string, headers http.Header, upstreamHash, declared string) (*CacheResult, error) {
|
|
|
|
|
key := artifactCoalesceKey(versionPURL, filename, downloadURL, upstreamHash, declared)
|
Fix duplicate fetches and 502s on concurrent cache misses (#329)
* Return the stored artifact from storeArtifact, not a reader
storeArtifact returned a CacheResult holding an open file handle. A
handle has one read position, so it can only ever serve a single caller,
which is what blocks sharing one fetch between concurrent requests.
Return the artifact and its storage path instead, and let each caller
open its own reader through openStoredArtifact. Threading that type
through fetchAndCache, fetchAndCacheFromURL and their error paths is
mechanical; behaviour is unchanged.
* Coalesce concurrent cache misses
A cache miss went from checkCache straight to an upstream fetch with
nothing tracking in-flight work, so N concurrent requests for one
uncached artifact produced N upstream fetches and N stores to the same
key. That is the CI shape: parallel jobs installing overlapping
dependencies against a cold cache. The duplicate stores also fail
requests, racing fileblob's per-key ".attrs" sidecar into a partial read
served as a 502. Over 12 runs of 8 simultaneous requests for one
uncached tarball, against bb2205a: before, 8 fetches per run and 12 of
96 responses were 502; after, 1 fetch per run and none failed.
Route both miss paths through a shared in-flight map keyed on the
artifact, including the download URL and upstream-declared hash so
callers expecting different bytes never share a fetch.
singleflight does not fit: Do gives waiters no way to leave, while
DoChan lets the caller running the fetch abandon it, breaking
storeArtifact's scan-on-disconnect contract. Deciding roles under a
mutex gives both behaviours. The fetch runs on the first caller's
context and is seen through; waiters leave when their own clients do.
This removes the sidecar trigger on this path. The race is in fileblob
and three writers bypass this path entirely, so it is fixed separately.
Fewer failures now reach the circuit breaker, so it trips later.
Sixteen concurrent callers against real file:// storage fail 10 of 10
runs on main and pass 10 of 10 here. Other tests pin key discrimination,
failure propagation, resolver-path coalescing, per-caller readers,
waiter cancellation, key release and panic safety. allocs/op is
unchanged. mockStorage gains a mutex so concurrent tests can use it.
* Normalize digest case in the coalescing key
artifactHashMatches compares digests with strings.EqualFold, but the
coalescing key used the hash verbatim. The same digest in two casings
produced two keys, so two callers for one artifact each ran their own
upstream fetch and store, which is what the coalescing is meant to
prevent.
* Make the panic coalescing test deterministic
The test timed the second caller's arrival with a sleep, so which caller
became the leader was left to the scheduler. When it lost that race the
second caller ran the fetch itself, and its panic was not recovered, so
the test binary died instead of the test failing.
Whether a caller has reached the wait is not observable from outside:
it runs a cache lookup against the database first, so releasing the
leader on a timer races that query. Drive coalesceFetch directly and
hold the shared entry instead, which removes the timing entirely. The
panicking fetcher is no longer needed.
* Recheck the cache before running a shared fetch
A caller checks the cache before it reaches coalesceFetch, so a fetch
that commits in that gap is invisible to it. Arriving after the sharing
entry is gone, it became a new leader and fetched, stored and scanned an
artifact the cache already held.
The leader now rechecks the committed record first. It serves that
record only if its bytes still open, because a record can outlive them,
and refetching is the recovery the cache lookup already makes for that
case. Waiters are unaffected: the record fills the same shared value a
fetch would, and each caller opens its own reader from it.
The recheck is the leader's alone. A waiter has a fetch in flight to
wait on, and rechecking would race it for no gain.
* Lock the mock fetcher's bookkeeping
Coalescing tests call the handler from many goroutines. The key keeps
the fetch itself serialized, but the mock should not lean on that: it
now locks the fields it records, so any concurrency the handler applies
is safe under the race detector.
* Wait for the leader's fetch instead of sleeping
The canceled-waiter test slept 200ms and assumed the leader had taken
the key by then. On a slow scheduler the canceled call could become the
leader and the test would no longer cover waiter cancellation. The
fetcher now signals when its first fetch begins, which happens only once
the key is held.
2026-09-15 11:59:03 -07:00
|
|
|
recheck := func() (artifacts.Artifact, string, bool) {
|
|
|
|
|
return p.cachedArtifactRecord(pkgPURL, versionPURL, filename, upstreamHash)
|
|
|
|
|
}
|
|
|
|
|
return p.coalesceFetch(ctx, key, recheck, func(fetchCtx context.Context) (artifacts.Artifact, string, error) {
|
Give each fetch its own storage path
Every fetch of an artifact wrote one path, so fetches with different URLs
or digests, which do not share a coalescing key, could overwrite each
other's bytes, serve the wrong ones, or delete them on a digest mismatch
while the other was about to open them.
storeArtifact now writes {ecosystem}/{name}/{version}/{fetch id}/{filename},
with a random id per fetch. Most ecosystems learn the digest only after
the fetch, and storage has no rename, so the id is the one rule that fits
all of them. Existing records keep their paths and stay readable. On a
file:// bucket, Delete now removes an emptied fetch directory, which
fileblob leaves behind, and no other directory, since another fetch may
be creating its own inside it.
A path a record stops pointing at is queued in pending_deletes rather
than deleted, since a request that read the record may still open it. A
loop deletes queued paths after max(1h, direct_serve_ttl), whether or not
max_size is set. UpsertArtifact and clears apply only if the record still
holds the path the caller read, so racing commits each queue the path
they replaced and a clear never orphans a newer commit. Eviction still
deletes inline, and counts space as freed only when the record still
pointed at what it deleted.
2026-09-23 14:40:13 -07:00
|
|
|
p.discardStaleArtifact(pkgPURL, versionPURL, filename, upstreamHash)
|
2026-10-14 15:36:26 +02:00
|
|
|
return p.fetchAndCacheFromURL(fetchCtx, ecosystem, name, version, filename, pkgPURL, versionPURL, downloadURL, headers, upstreamHash, declared)
|
Fix duplicate fetches and 502s on concurrent cache misses (#329)
* Return the stored artifact from storeArtifact, not a reader
storeArtifact returned a CacheResult holding an open file handle. A
handle has one read position, so it can only ever serve a single caller,
which is what blocks sharing one fetch between concurrent requests.
Return the artifact and its storage path instead, and let each caller
open its own reader through openStoredArtifact. Threading that type
through fetchAndCache, fetchAndCacheFromURL and their error paths is
mechanical; behaviour is unchanged.
* Coalesce concurrent cache misses
A cache miss went from checkCache straight to an upstream fetch with
nothing tracking in-flight work, so N concurrent requests for one
uncached artifact produced N upstream fetches and N stores to the same
key. That is the CI shape: parallel jobs installing overlapping
dependencies against a cold cache. The duplicate stores also fail
requests, racing fileblob's per-key ".attrs" sidecar into a partial read
served as a 502. Over 12 runs of 8 simultaneous requests for one
uncached tarball, against bb2205a: before, 8 fetches per run and 12 of
96 responses were 502; after, 1 fetch per run and none failed.
Route both miss paths through a shared in-flight map keyed on the
artifact, including the download URL and upstream-declared hash so
callers expecting different bytes never share a fetch.
singleflight does not fit: Do gives waiters no way to leave, while
DoChan lets the caller running the fetch abandon it, breaking
storeArtifact's scan-on-disconnect contract. Deciding roles under a
mutex gives both behaviours. The fetch runs on the first caller's
context and is seen through; waiters leave when their own clients do.
This removes the sidecar trigger on this path. The race is in fileblob
and three writers bypass this path entirely, so it is fixed separately.
Fewer failures now reach the circuit breaker, so it trips later.
Sixteen concurrent callers against real file:// storage fail 10 of 10
runs on main and pass 10 of 10 here. Other tests pin key discrimination,
failure propagation, resolver-path coalescing, per-caller readers,
waiter cancellation, key release and panic safety. allocs/op is
unchanged. mockStorage gains a mutex so concurrent tests can use it.
* Normalize digest case in the coalescing key
artifactHashMatches compares digests with strings.EqualFold, but the
coalescing key used the hash verbatim. The same digest in two casings
produced two keys, so two callers for one artifact each ran their own
upstream fetch and store, which is what the coalescing is meant to
prevent.
* Make the panic coalescing test deterministic
The test timed the second caller's arrival with a sleep, so which caller
became the leader was left to the scheduler. When it lost that race the
second caller ran the fetch itself, and its panic was not recovered, so
the test binary died instead of the test failing.
Whether a caller has reached the wait is not observable from outside:
it runs a cache lookup against the database first, so releasing the
leader on a timer races that query. Drive coalesceFetch directly and
hold the shared entry instead, which removes the timing entirely. The
panicking fetcher is no longer needed.
* Recheck the cache before running a shared fetch
A caller checks the cache before it reaches coalesceFetch, so a fetch
that commits in that gap is invisible to it. Arriving after the sharing
entry is gone, it became a new leader and fetched, stored and scanned an
artifact the cache already held.
The leader now rechecks the committed record first. It serves that
record only if its bytes still open, because a record can outlive them,
and refetching is the recovery the cache lookup already makes for that
case. Waiters are unaffected: the record fills the same shared value a
fetch would, and each caller opens its own reader from it.
The recheck is the leader's alone. A waiter has a fetch in flight to
wait on, and rechecking would race it for no gain.
* Lock the mock fetcher's bookkeeping
Coalescing tests call the handler from many goroutines. The key keeps
the fetch itself serialized, but the mock should not lean on that: it
now locks the fields it records, so any concurrency the handler applies
is safe under the race detector.
* Wait for the leader's fetch instead of sleeping
The canceled-waiter test slept 200ms and assumed the leader had taken
the key by then. On a slow scheduler the canceled call could become the
leader and the test would no longer cover waiter cancellation. The
fetcher now signals when its first fetch begins, which happens only once
the key is held.
2026-09-15 11:59:03 -07:00
|
|
|
})
|
2026-01-20 21:52:44 +00:00
|
|
|
}
|
|
|
|
|
|
2026-09-02 13:06:34 +01:00
|
|
|
// getCachedArtifactWithUpstreamHash returns a cached artifact whose recorded
|
|
|
|
|
// content hash matches the checksum the upstream currently declares for it.
|
|
|
|
|
// This detects an upstream re-publishing under the same version, which the
|
|
|
|
|
// stream integrity check in checkCache cannot: that check only verifies the
|
2026-09-16 00:20:27 -07:00
|
|
|
// stored blob against the hash recorded when it was cached. A stale entry is
|
|
|
|
|
// a miss and is left in place: the fetch that replaces it discards it under
|
|
|
|
|
// the coalescing key.
|
2026-09-02 13:06:34 +01:00
|
|
|
func (p *Proxy) getCachedArtifactWithUpstreamHash(ctx context.Context, pkgPURL, versionPURL, filename, upstreamHash string) (*CacheResult, error) {
|
|
|
|
|
cached, err := p.checkCache(ctx, pkgPURL, versionPURL, filename)
|
|
|
|
|
if err != nil || cached == nil {
|
|
|
|
|
return cached, err
|
|
|
|
|
}
|
2026-09-04 10:58:22 +01:00
|
|
|
if artifactHashMatches(cached.Artifact.Digest.Encoded(), upstreamHash) {
|
2026-09-02 13:06:34 +01:00
|
|
|
return cached, nil
|
|
|
|
|
}
|
|
|
|
|
if cached.Reader != nil {
|
|
|
|
|
_ = cached.Reader.Close()
|
|
|
|
|
}
|
|
|
|
|
return nil, nil
|
|
|
|
|
}
|
|
|
|
|
|
Give each fetch its own storage path
Every fetch of an artifact wrote one path, so fetches with different URLs
or digests, which do not share a coalescing key, could overwrite each
other's bytes, serve the wrong ones, or delete them on a digest mismatch
while the other was about to open them.
storeArtifact now writes {ecosystem}/{name}/{version}/{fetch id}/{filename},
with a random id per fetch. Most ecosystems learn the digest only after
the fetch, and storage has no rename, so the id is the one rule that fits
all of them. Existing records keep their paths and stay readable. On a
file:// bucket, Delete now removes an emptied fetch directory, which
fileblob leaves behind, and no other directory, since another fetch may
be creating its own inside it.
A path a record stops pointing at is queued in pending_deletes rather
than deleted, since a request that read the record may still open it. A
loop deletes queued paths after max(1h, direct_serve_ttl), whether or not
max_size is set. UpsertArtifact and clears apply only if the record still
holds the path the caller read, so racing commits each queue the path
they replaced and a clear never orphans a newer commit. Eviction still
deletes inline, and counts space as freed only when the record still
pointed at what it deleted.
2026-09-23 14:40:13 -07:00
|
|
|
// discardStaleArtifact clears the cached entry when its digest disagrees with
|
|
|
|
|
// upstreamHash, queueing its object for deletion. It runs under the coalescing
|
|
|
|
|
// key, after the recheck, so an entry a previous fetch refreshed is kept.
|
|
|
|
|
func (p *Proxy) discardStaleArtifact(pkgPURL, versionPURL, filename, upstreamHash string) {
|
2026-09-16 00:20:27 -07:00
|
|
|
record, err := p.DB.GetCachedArtifact(pkgPURL, versionPURL, filename)
|
|
|
|
|
if err != nil {
|
|
|
|
|
p.Logger.Warn("failed to read cache record before refetch",
|
|
|
|
|
"purl", versionPURL, "filename", filename, "error", err)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if record == nil || artifactHashMatches(record.Artifact.Digest.Encoded(), upstreamHash) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
p.Logger.Warn("cached artifact hash disagrees with upstream metadata, discarding",
|
|
|
|
|
"purl", versionPURL, "filename", filename, "cached", record.Artifact.Digest.Encoded(), "upstream", upstreamHash)
|
Give each fetch its own storage path
Every fetch of an artifact wrote one path, so fetches with different URLs
or digests, which do not share a coalescing key, could overwrite each
other's bytes, serve the wrong ones, or delete them on a digest mismatch
while the other was about to open them.
storeArtifact now writes {ecosystem}/{name}/{version}/{fetch id}/{filename},
with a random id per fetch. Most ecosystems learn the digest only after
the fetch, and storage has no rename, so the id is the one rule that fits
all of them. Existing records keep their paths and stay readable. On a
file:// bucket, Delete now removes an emptied fetch directory, which
fileblob leaves behind, and no other directory, since another fetch may
be creating its own inside it.
A path a record stops pointing at is queued in pending_deletes rather
than deleted, since a request that read the record may still open it. A
loop deletes queued paths after max(1h, direct_serve_ttl), whether or not
max_size is set. UpsertArtifact and clears apply only if the record still
holds the path the caller read, so racing commits each queue the path
they replaced and a clear never orphans a newer commit. Eviction still
deletes inline, and counts space as freed only when the record still
pointed at what it deleted.
2026-09-23 14:40:13 -07:00
|
|
|
if err := p.DB.DiscardArtifact(versionPURL, filename, record.StoragePath); err != nil {
|
|
|
|
|
p.Logger.Warn("failed to clear artifact cache record", "purl", versionPURL, "filename", filename, "error", err)
|
|
|
|
|
}
|
2026-09-16 00:20:27 -07:00
|
|
|
}
|
|
|
|
|
|
2026-10-14 15:36:26 +02:00
|
|
|
func (p *Proxy) fetchAndCacheFromURL(ctx context.Context, ecosystem, name, version, filename, pkgPURL, versionPURL, downloadURL string, headers http.Header, upstreamHash, declared string) (artifacts.Artifact, string, error) {
|
2026-10-15 13:35:12 +01:00
|
|
|
if err := p.RateLimits.Held(downloadURL); err != nil {
|
|
|
|
|
return artifacts.Artifact{}, "", err
|
|
|
|
|
}
|
2026-01-20 21:52:44 +00:00
|
|
|
p.Logger.Info("fetching from upstream",
|
|
|
|
|
"ecosystem", ecosystem, "name", name, "version", version, "url", downloadURL)
|
|
|
|
|
|
2026-08-29 11:19:54 +01:00
|
|
|
fetchStart := time.Now()
|
2026-04-01 15:22:39 +01:00
|
|
|
artifact, err := p.Fetcher.FetchWithHeaders(ctx, downloadURL, headers)
|
2026-08-29 11:19:54 +01:00
|
|
|
metrics.RecordUpstreamFetch(ecosystem, time.Since(fetchStart))
|
2026-01-20 21:52:44 +00:00
|
|
|
if err != nil {
|
Record the detail behind each failure counter
- Failure recorders take a FailureDetail: PURL, filename, URL, storage path, error
- Keep the last 20 failures per counter series in memory, reset on restart
- Strip userinfo, query and fragment from URLs, including those inside error text
- Pass the detail from upstream, storage, integrity, health probe and scan call sites
- Package names and URLs stay out of Prometheus labels
2026-10-12 09:41:42 +02:00
|
|
|
metrics.RecordUpstreamError(ecosystem, "fetch_failed", metrics.FailureDetail{
|
|
|
|
|
PURL: versionPURL, Filename: filename, URL: downloadURL, Err: err})
|
2026-07-26 20:07:46 +02:00
|
|
|
if errors.Is(err, fetch.ErrNotFound) {
|
Fix duplicate fetches and 502s on concurrent cache misses (#329)
* Return the stored artifact from storeArtifact, not a reader
storeArtifact returned a CacheResult holding an open file handle. A
handle has one read position, so it can only ever serve a single caller,
which is what blocks sharing one fetch between concurrent requests.
Return the artifact and its storage path instead, and let each caller
open its own reader through openStoredArtifact. Threading that type
through fetchAndCache, fetchAndCacheFromURL and their error paths is
mechanical; behaviour is unchanged.
* Coalesce concurrent cache misses
A cache miss went from checkCache straight to an upstream fetch with
nothing tracking in-flight work, so N concurrent requests for one
uncached artifact produced N upstream fetches and N stores to the same
key. That is the CI shape: parallel jobs installing overlapping
dependencies against a cold cache. The duplicate stores also fail
requests, racing fileblob's per-key ".attrs" sidecar into a partial read
served as a 502. Over 12 runs of 8 simultaneous requests for one
uncached tarball, against bb2205a: before, 8 fetches per run and 12 of
96 responses were 502; after, 1 fetch per run and none failed.
Route both miss paths through a shared in-flight map keyed on the
artifact, including the download URL and upstream-declared hash so
callers expecting different bytes never share a fetch.
singleflight does not fit: Do gives waiters no way to leave, while
DoChan lets the caller running the fetch abandon it, breaking
storeArtifact's scan-on-disconnect contract. Deciding roles under a
mutex gives both behaviours. The fetch runs on the first caller's
context and is seen through; waiters leave when their own clients do.
This removes the sidecar trigger on this path. The race is in fileblob
and three writers bypass this path entirely, so it is fixed separately.
Fewer failures now reach the circuit breaker, so it trips later.
Sixteen concurrent callers against real file:// storage fail 10 of 10
runs on main and pass 10 of 10 here. Other tests pin key discrimination,
failure propagation, resolver-path coalescing, per-caller readers,
waiter cancellation, key release and panic safety. allocs/op is
unchanged. mockStorage gains a mutex so concurrent tests can use it.
* Normalize digest case in the coalescing key
artifactHashMatches compares digests with strings.EqualFold, but the
coalescing key used the hash verbatim. The same digest in two casings
produced two keys, so two callers for one artifact each ran their own
upstream fetch and store, which is what the coalescing is meant to
prevent.
* Make the panic coalescing test deterministic
The test timed the second caller's arrival with a sleep, so which caller
became the leader was left to the scheduler. When it lost that race the
second caller ran the fetch itself, and its panic was not recovered, so
the test binary died instead of the test failing.
Whether a caller has reached the wait is not observable from outside:
it runs a cache lookup against the database first, so releasing the
leader on a timer races that query. Drive coalesceFetch directly and
hold the shared entry instead, which removes the timing entirely. The
panicking fetcher is no longer needed.
* Recheck the cache before running a shared fetch
A caller checks the cache before it reaches coalesceFetch, so a fetch
that commits in that gap is invisible to it. Arriving after the sharing
entry is gone, it became a new leader and fetched, stored and scanned an
artifact the cache already held.
The leader now rechecks the committed record first. It serves that
record only if its bytes still open, because a record can outlive them,
and refetching is the recovery the cache lookup already makes for that
case. Waiters are unaffected: the record fills the same shared value a
fetch would, and each caller opens its own reader from it.
The recheck is the leader's alone. A waiter has a fetch in flight to
wait on, and rechecking would race it for no gain.
* Lock the mock fetcher's bookkeeping
Coalescing tests call the handler from many goroutines. The key keeps
the fetch itself serialized, but the mock should not lean on that: it
now locks the fields it records, so any concurrency the handler applies
is safe under the race detector.
* Wait for the leader's fetch instead of sleeping
The canceled-waiter test slept 200ms and assumed the leader had taken
the key by then. On a slow scheduler the canceled call could become the
leader and the test would no longer cover waiter cancellation. The
fetcher now signals when its first fetch begins, which happens only once
the key is held.
2026-09-15 11:59:03 -07:00
|
|
|
return artifacts.Artifact{}, "", ErrUpstreamNotFound
|
2026-07-26 20:07:46 +02:00
|
|
|
}
|
Fix duplicate fetches and 502s on concurrent cache misses (#329)
* Return the stored artifact from storeArtifact, not a reader
storeArtifact returned a CacheResult holding an open file handle. A
handle has one read position, so it can only ever serve a single caller,
which is what blocks sharing one fetch between concurrent requests.
Return the artifact and its storage path instead, and let each caller
open its own reader through openStoredArtifact. Threading that type
through fetchAndCache, fetchAndCacheFromURL and their error paths is
mechanical; behaviour is unchanged.
* Coalesce concurrent cache misses
A cache miss went from checkCache straight to an upstream fetch with
nothing tracking in-flight work, so N concurrent requests for one
uncached artifact produced N upstream fetches and N stores to the same
key. That is the CI shape: parallel jobs installing overlapping
dependencies against a cold cache. The duplicate stores also fail
requests, racing fileblob's per-key ".attrs" sidecar into a partial read
served as a 502. Over 12 runs of 8 simultaneous requests for one
uncached tarball, against bb2205a: before, 8 fetches per run and 12 of
96 responses were 502; after, 1 fetch per run and none failed.
Route both miss paths through a shared in-flight map keyed on the
artifact, including the download URL and upstream-declared hash so
callers expecting different bytes never share a fetch.
singleflight does not fit: Do gives waiters no way to leave, while
DoChan lets the caller running the fetch abandon it, breaking
storeArtifact's scan-on-disconnect contract. Deciding roles under a
mutex gives both behaviours. The fetch runs on the first caller's
context and is seen through; waiters leave when their own clients do.
This removes the sidecar trigger on this path. The race is in fileblob
and three writers bypass this path entirely, so it is fixed separately.
Fewer failures now reach the circuit breaker, so it trips later.
Sixteen concurrent callers against real file:// storage fail 10 of 10
runs on main and pass 10 of 10 here. Other tests pin key discrimination,
failure propagation, resolver-path coalescing, per-caller readers,
waiter cancellation, key release and panic safety. allocs/op is
unchanged. mockStorage gains a mutex so concurrent tests can use it.
* Normalize digest case in the coalescing key
artifactHashMatches compares digests with strings.EqualFold, but the
coalescing key used the hash verbatim. The same digest in two casings
produced two keys, so two callers for one artifact each ran their own
upstream fetch and store, which is what the coalescing is meant to
prevent.
* Make the panic coalescing test deterministic
The test timed the second caller's arrival with a sleep, so which caller
became the leader was left to the scheduler. When it lost that race the
second caller ran the fetch itself, and its panic was not recovered, so
the test binary died instead of the test failing.
Whether a caller has reached the wait is not observable from outside:
it runs a cache lookup against the database first, so releasing the
leader on a timer races that query. Drive coalesceFetch directly and
hold the shared entry instead, which removes the timing entirely. The
panicking fetcher is no longer needed.
* Recheck the cache before running a shared fetch
A caller checks the cache before it reaches coalesceFetch, so a fetch
that commits in that gap is invisible to it. Arriving after the sharing
entry is gone, it became a new leader and fetched, stored and scanned an
artifact the cache already held.
The leader now rechecks the committed record first. It serves that
record only if its bytes still open, because a record can outlive them,
and refetching is the recovery the cache lookup already makes for that
case. Waiters are unaffected: the record fills the same shared value a
fetch would, and each caller opens its own reader from it.
The recheck is the leader's alone. A waiter has a fetch in flight to
wait on, and rechecking would race it for no gain.
* Lock the mock fetcher's bookkeeping
Coalescing tests call the handler from many goroutines. The key keeps
the fetch itself serialized, but the mock should not lean on that: it
now locks the fields it records, so any concurrency the handler applies
is safe under the race detector.
* Wait for the leader's fetch instead of sleeping
The canceled-waiter test slept 200ms and assumed the leader had taken
the key by then. On a slow scheduler the canceled call could become the
leader and the test would no longer cover waiter cancellation. The
fetcher now signals when its first fetch begins, which happens only once
the key is held.
2026-09-15 11:59:03 -07:00
|
|
|
return artifacts.Artifact{}, "", fmt.Errorf("fetching from upstream: %w", err)
|
2026-01-20 21:52:44 +00:00
|
|
|
}
|
|
|
|
|
|
2026-10-14 15:36:26 +02:00
|
|
|
return p.storeArtifact(ctx, ecosystem, name, version, filename, pkgPURL, versionPURL, downloadURL, upstreamHash, declared, artifact)
|
2026-01-20 21:52:44 +00:00
|
|
|
}
|
2026-09-02 13:06:34 +01:00
|
|
|
|
Add storage.cache_artifacts to serve artifacts without storing them (#387)
* Add storage.passthrough to serve artifacts without storing them
With storage.passthrough enabled the proxy streams every artifact from
upstream to the client and never writes it to storage or the cache
database. Metadata filtering, cooldown and the denylist work as before,
so the proxy can sit behind another cache (e.g. an Artifactory remote)
purely as a policy layer without holding a second copy of every package.
Artifacts whose digest is known up front (OCI, Swift, Helm) are verified
while streaming. Their responses are sent chunked and the connection is
aborted on a mismatch, so a client never receives a tampered artifact as
a complete response.
Passthrough is rejected together with scanning, direct_serve and
mirror_api, and the mirror command refuses to run with it, since all of
them depend on stored artifacts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Address review: abort truncated streams, guard every cache read
- serveArtifact aborts the response on any body read error, and when fewer
bytes than the declared size were written, not only on a digest mismatch.
A failed or short upstream body in passthrough mode no longer reaches the
client as a complete 200.
- Streamed upstream read failures are logged and counted as
stream_failed upstream errors before the response is aborted.
- checkCache reports a miss when artifact caching is off, so every cache
read path, including Swift archive HEAD requests, ignores entries stored
before the mode was enabled. Denylisted versions are still rejected.
- Rename storage.passthrough to storage.cache_artifacts (default true,
PROXY_STORAGE_CACHE_ARTIFACTS), which names what actually changes.
Tests cover an OCI blob shorter than its Content-Length and an npm tarball
whose chunked response ends without the final chunk, both served through
the HTTP handlers, plus a Swift HEAD request against a cached archive.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Rename Proxy.Passthrough to StreamArtifacts
Match the storage.cache_artifacts option: the field says what changes
(artifacts are streamed instead of stored) and its zero value keeps the
usual caching behaviour for every Proxy built without the server config,
such as the mirror command and tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 15:28:20 +07:00
|
|
|
// streamFromUpstream fetches an artifact when StreamArtifacts is set and hands its
|
|
|
|
|
// body to the caller without storing it.
|
|
|
|
|
//
|
2026-10-14 15:36:26 +02:00
|
|
|
// With upstreamHash or the declared integrity set the body is verified as it
|
|
|
|
|
// streams. The size is left
|
Add storage.cache_artifacts to serve artifacts without storing them (#387)
* Add storage.passthrough to serve artifacts without storing them
With storage.passthrough enabled the proxy streams every artifact from
upstream to the client and never writes it to storage or the cache
database. Metadata filtering, cooldown and the denylist work as before,
so the proxy can sit behind another cache (e.g. an Artifactory remote)
purely as a policy layer without holding a second copy of every package.
Artifacts whose digest is known up front (OCI, Swift, Helm) are verified
while streaming. Their responses are sent chunked and the connection is
aborted on a mismatch, so a client never receives a tampered artifact as
a complete response.
Passthrough is rejected together with scanning, direct_serve and
mirror_api, and the mirror command refuses to run with it, since all of
them depend on stored artifacts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Address review: abort truncated streams, guard every cache read
- serveArtifact aborts the response on any body read error, and when fewer
bytes than the declared size were written, not only on a digest mismatch.
A failed or short upstream body in passthrough mode no longer reaches the
client as a complete 200.
- Streamed upstream read failures are logged and counted as
stream_failed upstream errors before the response is aborted.
- checkCache reports a miss when artifact caching is off, so every cache
read path, including Swift archive HEAD requests, ignores entries stored
before the mode was enabled. Denylisted versions are still rejected.
- Rename storage.passthrough to storage.cache_artifacts (default true,
PROXY_STORAGE_CACHE_ARTIFACTS), which names what actually changes.
Tests cover an OCI blob shorter than its Content-Length and an npm tarball
whose chunked response ends without the final chunk, both served through
the HTTP handlers, plus a Swift HEAD request against a cached archive.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Rename Proxy.Passthrough to StreamArtifacts
Match the storage.cache_artifacts option: the field says what changes
(artifacts are streamed instead of stored) and its zero value keeps the
usual caching behaviour for every Proxy built without the server config,
such as the mirror command and tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 15:28:20 +07:00
|
|
|
// unknown so the response goes out chunked: a mismatch only shows at EOF,
|
|
|
|
|
// after the bytes have been sent, and an unterminated chunked response is the
|
|
|
|
|
// only way left to make the client reject them (see serveArtifact).
|
2026-10-14 15:36:26 +02:00
|
|
|
func (p *Proxy) streamFromUpstream(ctx context.Context, ecosystem, name, version, filename, versionPURL, upstreamURL, upstreamHash, declared string, fetchArtifact func(context.Context) (*fetch.Artifact, error)) (*CacheResult, error) {
|
2026-10-15 13:35:12 +01:00
|
|
|
if err := p.RateLimits.Held(upstreamURL); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
Add storage.cache_artifacts to serve artifacts without storing them (#387)
* Add storage.passthrough to serve artifacts without storing them
With storage.passthrough enabled the proxy streams every artifact from
upstream to the client and never writes it to storage or the cache
database. Metadata filtering, cooldown and the denylist work as before,
so the proxy can sit behind another cache (e.g. an Artifactory remote)
purely as a policy layer without holding a second copy of every package.
Artifacts whose digest is known up front (OCI, Swift, Helm) are verified
while streaming. Their responses are sent chunked and the connection is
aborted on a mismatch, so a client never receives a tampered artifact as
a complete response.
Passthrough is rejected together with scanning, direct_serve and
mirror_api, and the mirror command refuses to run with it, since all of
them depend on stored artifacts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Address review: abort truncated streams, guard every cache read
- serveArtifact aborts the response on any body read error, and when fewer
bytes than the declared size were written, not only on a digest mismatch.
A failed or short upstream body in passthrough mode no longer reaches the
client as a complete 200.
- Streamed upstream read failures are logged and counted as
stream_failed upstream errors before the response is aborted.
- checkCache reports a miss when artifact caching is off, so every cache
read path, including Swift archive HEAD requests, ignores entries stored
before the mode was enabled. Denylisted versions are still rejected.
- Rename storage.passthrough to storage.cache_artifacts (default true,
PROXY_STORAGE_CACHE_ARTIFACTS), which names what actually changes.
Tests cover an OCI blob shorter than its Content-Length and an npm tarball
whose chunked response ends without the final chunk, both served through
the HTTP handlers, plus a Swift HEAD request against a cached archive.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Rename Proxy.Passthrough to StreamArtifacts
Match the storage.cache_artifacts option: the field says what changes
(artifacts are streamed instead of stored) and its zero value keeps the
usual caching behaviour for every Proxy built without the server config,
such as the mirror command and tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 15:28:20 +07:00
|
|
|
p.Logger.Info("streaming from upstream",
|
|
|
|
|
"ecosystem", ecosystem, "name", name, "version", version, "url", upstreamURL)
|
|
|
|
|
|
|
|
|
|
fetchStart := time.Now()
|
|
|
|
|
artifact, err := fetchArtifact(ctx)
|
|
|
|
|
metrics.RecordUpstreamFetch(ecosystem, time.Since(fetchStart))
|
|
|
|
|
if err != nil {
|
Record the detail behind each failure counter
- Failure recorders take a FailureDetail: PURL, filename, URL, storage path, error
- Keep the last 20 failures per counter series in memory, reset on restart
- Strip userinfo, query and fragment from URLs, including those inside error text
- Pass the detail from upstream, storage, integrity, health probe and scan call sites
- Package names and URLs stay out of Prometheus labels
2026-10-12 09:41:42 +02:00
|
|
|
metrics.RecordUpstreamError(ecosystem, "fetch_failed", metrics.FailureDetail{
|
|
|
|
|
PURL: versionPURL, Filename: filename, URL: upstreamURL, Err: err})
|
Add storage.cache_artifacts to serve artifacts without storing them (#387)
* Add storage.passthrough to serve artifacts without storing them
With storage.passthrough enabled the proxy streams every artifact from
upstream to the client and never writes it to storage or the cache
database. Metadata filtering, cooldown and the denylist work as before,
so the proxy can sit behind another cache (e.g. an Artifactory remote)
purely as a policy layer without holding a second copy of every package.
Artifacts whose digest is known up front (OCI, Swift, Helm) are verified
while streaming. Their responses are sent chunked and the connection is
aborted on a mismatch, so a client never receives a tampered artifact as
a complete response.
Passthrough is rejected together with scanning, direct_serve and
mirror_api, and the mirror command refuses to run with it, since all of
them depend on stored artifacts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Address review: abort truncated streams, guard every cache read
- serveArtifact aborts the response on any body read error, and when fewer
bytes than the declared size were written, not only on a digest mismatch.
A failed or short upstream body in passthrough mode no longer reaches the
client as a complete 200.
- Streamed upstream read failures are logged and counted as
stream_failed upstream errors before the response is aborted.
- checkCache reports a miss when artifact caching is off, so every cache
read path, including Swift archive HEAD requests, ignores entries stored
before the mode was enabled. Denylisted versions are still rejected.
- Rename storage.passthrough to storage.cache_artifacts (default true,
PROXY_STORAGE_CACHE_ARTIFACTS), which names what actually changes.
Tests cover an OCI blob shorter than its Content-Length and an npm tarball
whose chunked response ends without the final chunk, both served through
the HTTP handlers, plus a Swift HEAD request against a cached archive.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Rename Proxy.Passthrough to StreamArtifacts
Match the storage.cache_artifacts option: the field says what changes
(artifacts are streamed instead of stored) and its zero value keeps the
usual caching behaviour for every Proxy built without the server config,
such as the mirror command and tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 15:28:20 +07:00
|
|
|
if errors.Is(err, fetch.ErrNotFound) {
|
|
|
|
|
return nil, ErrUpstreamNotFound
|
|
|
|
|
}
|
|
|
|
|
return nil, fmt.Errorf("fetching from upstream: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
body := &streamErrorLogger{
|
|
|
|
|
ReadCloser: artifact.Body,
|
|
|
|
|
onError: func(read int64, err error) {
|
|
|
|
|
p.Logger.Warn("streaming artifact from upstream failed",
|
|
|
|
|
"purl", versionPURL, "filename", filename, "url", upstreamURL, "bytes", read, "error", err)
|
Record the detail behind each failure counter
- Failure recorders take a FailureDetail: PURL, filename, URL, storage path, error
- Keep the last 20 failures per counter series in memory, reset on restart
- Strip userinfo, query and fragment from URLs, including those inside error text
- Pass the detail from upstream, storage, integrity, health probe and scan call sites
- Package names and URLs stay out of Prometheus labels
2026-10-12 09:41:42 +02:00
|
|
|
metrics.RecordUpstreamError(ecosystem, "stream_failed", metrics.FailureDetail{
|
|
|
|
|
PURL: versionPURL, Filename: filename, URL: upstreamURL, Err: err})
|
Add storage.cache_artifacts to serve artifacts without storing them (#387)
* Add storage.passthrough to serve artifacts without storing them
With storage.passthrough enabled the proxy streams every artifact from
upstream to the client and never writes it to storage or the cache
database. Metadata filtering, cooldown and the denylist work as before,
so the proxy can sit behind another cache (e.g. an Artifactory remote)
purely as a policy layer without holding a second copy of every package.
Artifacts whose digest is known up front (OCI, Swift, Helm) are verified
while streaming. Their responses are sent chunked and the connection is
aborted on a mismatch, so a client never receives a tampered artifact as
a complete response.
Passthrough is rejected together with scanning, direct_serve and
mirror_api, and the mirror command refuses to run with it, since all of
them depend on stored artifacts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Address review: abort truncated streams, guard every cache read
- serveArtifact aborts the response on any body read error, and when fewer
bytes than the declared size were written, not only on a digest mismatch.
A failed or short upstream body in passthrough mode no longer reaches the
client as a complete 200.
- Streamed upstream read failures are logged and counted as
stream_failed upstream errors before the response is aborted.
- checkCache reports a miss when artifact caching is off, so every cache
read path, including Swift archive HEAD requests, ignores entries stored
before the mode was enabled. Denylisted versions are still rejected.
- Rename storage.passthrough to storage.cache_artifacts (default true,
PROXY_STORAGE_CACHE_ARTIFACTS), which names what actually changes.
Tests cover an OCI blob shorter than its Content-Length and an npm tarball
whose chunked response ends without the final chunk, both served through
the HTTP handlers, plus a Swift HEAD request against a cached archive.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Rename Proxy.Passthrough to StreamArtifacts
Match the storage.cache_artifacts option: the field says what changes
(artifacts are streamed instead of stored) and its zero value keeps the
usual caching behaviour for every Proxy built without the server config,
such as the mirror command and tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 15:28:20 +07:00
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
result := &CacheResult{
|
|
|
|
|
Reader: body,
|
|
|
|
|
Artifact: artifacts.Artifact{
|
|
|
|
|
PURL: versionPURL,
|
|
|
|
|
Size: artifact.Size,
|
|
|
|
|
Filename: filename,
|
|
|
|
|
MediaType: artifact.ContentType,
|
|
|
|
|
},
|
|
|
|
|
}
|
2026-10-14 15:36:26 +02:00
|
|
|
if upstreamHash == "" && declared == "" {
|
Add storage.cache_artifacts to serve artifacts without storing them (#387)
* Add storage.passthrough to serve artifacts without storing them
With storage.passthrough enabled the proxy streams every artifact from
upstream to the client and never writes it to storage or the cache
database. Metadata filtering, cooldown and the denylist work as before,
so the proxy can sit behind another cache (e.g. an Artifactory remote)
purely as a policy layer without holding a second copy of every package.
Artifacts whose digest is known up front (OCI, Swift, Helm) are verified
while streaming. Their responses are sent chunked and the connection is
aborted on a mismatch, so a client never receives a tampered artifact as
a complete response.
Passthrough is rejected together with scanning, direct_serve and
mirror_api, and the mirror command refuses to run with it, since all of
them depend on stored artifacts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Address review: abort truncated streams, guard every cache read
- serveArtifact aborts the response on any body read error, and when fewer
bytes than the declared size were written, not only on a digest mismatch.
A failed or short upstream body in passthrough mode no longer reaches the
client as a complete 200.
- Streamed upstream read failures are logged and counted as
stream_failed upstream errors before the response is aborted.
- checkCache reports a miss when artifact caching is off, so every cache
read path, including Swift archive HEAD requests, ignores entries stored
before the mode was enabled. Denylisted versions are still rejected.
- Rename storage.passthrough to storage.cache_artifacts (default true,
PROXY_STORAGE_CACHE_ARTIFACTS), which names what actually changes.
Tests cover an OCI blob shorter than its Content-Length and an npm tarball
whose chunked response ends without the final chunk, both served through
the HTTP handlers, plus a Swift HEAD request against a cached archive.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Rename Proxy.Passthrough to StreamArtifacts
Match the storage.cache_artifacts option: the field says what changes
(artifacts are streamed instead of stored) and its zero value keeps the
usual caching behaviour for every Proxy built without the server config,
such as the mirror command and tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 15:28:20 +07:00
|
|
|
return result, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
hash := strings.ToLower(upstreamHash)
|
2026-10-14 15:36:26 +02:00
|
|
|
checks, err := newIntegrityChecks(hash, declared)
|
Add storage.cache_artifacts to serve artifacts without storing them (#387)
* Add storage.passthrough to serve artifacts without storing them
With storage.passthrough enabled the proxy streams every artifact from
upstream to the client and never writes it to storage or the cache
database. Metadata filtering, cooldown and the denylist work as before,
so the proxy can sit behind another cache (e.g. an Artifactory remote)
purely as a policy layer without holding a second copy of every package.
Artifacts whose digest is known up front (OCI, Swift, Helm) are verified
while streaming. Their responses are sent chunked and the connection is
aborted on a mismatch, so a client never receives a tampered artifact as
a complete response.
Passthrough is rejected together with scanning, direct_serve and
mirror_api, and the mirror command refuses to run with it, since all of
them depend on stored artifacts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Address review: abort truncated streams, guard every cache read
- serveArtifact aborts the response on any body read error, and when fewer
bytes than the declared size were written, not only on a digest mismatch.
A failed or short upstream body in passthrough mode no longer reaches the
client as a complete 200.
- Streamed upstream read failures are logged and counted as
stream_failed upstream errors before the response is aborted.
- checkCache reports a miss when artifact caching is off, so every cache
read path, including Swift archive HEAD requests, ignores entries stored
before the mode was enabled. Denylisted versions are still rejected.
- Rename storage.passthrough to storage.cache_artifacts (default true,
PROXY_STORAGE_CACHE_ARTIFACTS), which names what actually changes.
Tests cover an OCI blob shorter than its Content-Length and an npm tarball
whose chunked response ends without the final chunk, both served through
the HTTP handlers, plus a Swift HEAD request against a cached archive.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Rename Proxy.Passthrough to StreamArtifacts
Match the storage.cache_artifacts option: the field says what changes
(artifacts are streamed instead of stored) and its zero value keeps the
usual caching behaviour for every Proxy built without the server config,
such as the mirror command and tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 15:28:20 +07:00
|
|
|
if err != nil {
|
|
|
|
|
_ = artifact.Body.Close()
|
|
|
|
|
return nil, fmt.Errorf("parsing upstream digest: %w", err)
|
|
|
|
|
}
|
|
|
|
|
result.Reader, err = checks.wrapFailOnMismatch(body, func(reason string) {
|
|
|
|
|
p.Logger.Error("streamed artifact failed integrity check",
|
|
|
|
|
"purl", versionPURL, "filename", filename, "url", upstreamURL, "reason", reason)
|
Record the detail behind each failure counter
- Failure recorders take a FailureDetail: PURL, filename, URL, storage path, error
- Keep the last 20 failures per counter series in memory, reset on restart
- Strip userinfo, query and fragment from URLs, including those inside error text
- Pass the detail from upstream, storage, integrity, health probe and scan call sites
- Package names and URLs stay out of Prometheus labels
2026-10-12 09:41:42 +02:00
|
|
|
metrics.RecordIntegrityFailure(purl.NormalizeEcosystem(ecosystem), metrics.FailureDetail{
|
|
|
|
|
PURL: versionPURL, Filename: filename, URL: upstreamURL, Err: errors.New(reason)})
|
Add storage.cache_artifacts to serve artifacts without storing them (#387)
* Add storage.passthrough to serve artifacts without storing them
With storage.passthrough enabled the proxy streams every artifact from
upstream to the client and never writes it to storage or the cache
database. Metadata filtering, cooldown and the denylist work as before,
so the proxy can sit behind another cache (e.g. an Artifactory remote)
purely as a policy layer without holding a second copy of every package.
Artifacts whose digest is known up front (OCI, Swift, Helm) are verified
while streaming. Their responses are sent chunked and the connection is
aborted on a mismatch, so a client never receives a tampered artifact as
a complete response.
Passthrough is rejected together with scanning, direct_serve and
mirror_api, and the mirror command refuses to run with it, since all of
them depend on stored artifacts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Address review: abort truncated streams, guard every cache read
- serveArtifact aborts the response on any body read error, and when fewer
bytes than the declared size were written, not only on a digest mismatch.
A failed or short upstream body in passthrough mode no longer reaches the
client as a complete 200.
- Streamed upstream read failures are logged and counted as
stream_failed upstream errors before the response is aborted.
- checkCache reports a miss when artifact caching is off, so every cache
read path, including Swift archive HEAD requests, ignores entries stored
before the mode was enabled. Denylisted versions are still rejected.
- Rename storage.passthrough to storage.cache_artifacts (default true,
PROXY_STORAGE_CACHE_ARTIFACTS), which names what actually changes.
Tests cover an OCI blob shorter than its Content-Length and an npm tarball
whose chunked response ends without the final chunk, both served through
the HTTP handlers, plus a Swift HEAD request against a cached archive.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Rename Proxy.Passthrough to StreamArtifacts
Match the storage.cache_artifacts option: the field says what changes
(artifacts are streamed instead of stored) and its zero value keeps the
usual caching behaviour for every Proxy built without the server config,
such as the mirror command and tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 15:28:20 +07:00
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
_ = artifact.Body.Close()
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
2026-10-14 15:36:26 +02:00
|
|
|
if hash != "" {
|
|
|
|
|
result.Artifact.Digest = digest.Digest("sha256:" + hash)
|
|
|
|
|
}
|
Add storage.cache_artifacts to serve artifacts without storing them (#387)
* Add storage.passthrough to serve artifacts without storing them
With storage.passthrough enabled the proxy streams every artifact from
upstream to the client and never writes it to storage or the cache
database. Metadata filtering, cooldown and the denylist work as before,
so the proxy can sit behind another cache (e.g. an Artifactory remote)
purely as a policy layer without holding a second copy of every package.
Artifacts whose digest is known up front (OCI, Swift, Helm) are verified
while streaming. Their responses are sent chunked and the connection is
aborted on a mismatch, so a client never receives a tampered artifact as
a complete response.
Passthrough is rejected together with scanning, direct_serve and
mirror_api, and the mirror command refuses to run with it, since all of
them depend on stored artifacts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Address review: abort truncated streams, guard every cache read
- serveArtifact aborts the response on any body read error, and when fewer
bytes than the declared size were written, not only on a digest mismatch.
A failed or short upstream body in passthrough mode no longer reaches the
client as a complete 200.
- Streamed upstream read failures are logged and counted as
stream_failed upstream errors before the response is aborted.
- checkCache reports a miss when artifact caching is off, so every cache
read path, including Swift archive HEAD requests, ignores entries stored
before the mode was enabled. Denylisted versions are still rejected.
- Rename storage.passthrough to storage.cache_artifacts (default true,
PROXY_STORAGE_CACHE_ARTIFACTS), which names what actually changes.
Tests cover an OCI blob shorter than its Content-Length and an npm tarball
whose chunked response ends without the final chunk, both served through
the HTTP handlers, plus a Swift HEAD request against a cached archive.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Rename Proxy.Passthrough to StreamArtifacts
Match the storage.cache_artifacts option: the field says what changes
(artifacts are streamed instead of stored) and its zero value keeps the
usual caching behaviour for every Proxy built without the server config,
such as the mirror command and tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 15:28:20 +07:00
|
|
|
result.Artifact.Size = -1
|
|
|
|
|
return result, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// streamErrorLogger reports the first read error of a streamed upstream body.
|
|
|
|
|
// The error itself still reaches serveArtifact, which aborts the response.
|
|
|
|
|
type streamErrorLogger struct {
|
|
|
|
|
io.ReadCloser
|
|
|
|
|
onError func(read int64, err error)
|
|
|
|
|
read int64
|
|
|
|
|
logged bool
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (r *streamErrorLogger) Read(p []byte) (int, error) {
|
|
|
|
|
n, err := r.ReadCloser.Read(p)
|
|
|
|
|
r.read += int64(n)
|
|
|
|
|
if err != nil && err != io.EOF && !r.logged {
|
|
|
|
|
r.logged = true
|
|
|
|
|
r.onError(r.read, err)
|
|
|
|
|
}
|
|
|
|
|
return n, err
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-03 16:59:12 +01:00
|
|
|
// ErrArtifactDigestMismatch indicates that fetched bytes did not match the
|
|
|
|
|
// checksum the upstream declared and were not recorded in the cache database.
|
|
|
|
|
var ErrArtifactDigestMismatch = errors.New("artifact digest mismatch")
|
|
|
|
|
|
2026-09-02 13:06:34 +01:00
|
|
|
func artifactHashMatches(got, expected string) bool {
|
|
|
|
|
return expected == "" || strings.EqualFold(got, expected)
|
|
|
|
|
}
|