Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,11 +58,14 @@ Metadata is not cached - always fetched fresh. This ensures clients see new vers
**Cache Miss:**
- Resolve download URL using Resolver
- Fetch artifact from upstream using Fetcher
- Copy the body to Storage and, for a client GET without a `Range`
header, to the client at once (`storeWhileServing`); the client's copy
is checked against the upstream's digest as it is read, and a mismatch
aborts it. A block-mode scanner, `HEAD` and range requests, and
requests joining a fetch already running wait for the stored file.
- Store artifact in Storage (returns size, hash)
- Create/update database records (package, version, artifact)
- Open stored file
- Return reader to handler
- Handler streams file to client
- Requests that waited open the stored file and stream it to their client

Artifact handlers honor single byte ranges when the storage reader supports
efficient seeking (currently the local filesystem reader). They advertise
Expand Down
21 changes: 13 additions & 8 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,14 @@ storage:

`storage.max_size` counts cached artifacts and cached metadata documents (`cache_metadata`). When the total goes over the limit, the least recently served entries of either kind are evicted first; an evicted metadata document is fetched from upstream again on the next request for it. Pushed artifacts are not counted or evicted. An artifact replaced by a refetch stays in storage for at least an hour, or `storage.direct_serve_ttl` if longer, so requests already reading it can finish, and storage use can exceed the limit by what was replaced in that time.

### Cache misses

On a cache miss the proxy sends the artifact to the client while it stores it, so the client gets its first bytes as soon as the upstream sends them, rather than after the whole artifact is stored. Clients that give up after a while without data, such as git-lfs (30 seconds) or pacman (10 seconds), no longer time out on large artifacts. The response goes out chunked, without `Content-Length`, as with `cache_artifacts: false`; once stored, the artifact is served with its size.

The bytes are checked against the digest the upstream declares, where it declares one, as they are sent. On a mismatch the connection is aborted before the response completes, so the client never receives a tampered artifact as a good one, and nothing is cached. The upstream is read at its own speed whatever the client's: a client more than 8 MB behind continues from the stored copy once it is stored, so a slow client doesn't hold back the download, other requests waiting for it, or `artifact_timeout`. If the client goes away, the artifact is still stored. If storing it fails, for example because the disk is full, the client still gets the whole artifact; the failure is logged at error level and counted in `proxy_artifacts_served_uncached_total`, so a cache that has stopped filling doesn't go unnoticed while every request succeeds.

Requests for the same artifact that arrive while it is being stored wait for the stored copy, as do `HEAD` requests and requests with a `Range` header, which are answered from it with its size. With a `block`-mode scanner configured for the ecosystem, a miss is stored and scanned before anything is sent, as before (see [Artifact Scanning](#artifact-scanning)).

### Serving artifacts without storing them

With `storage.cache_artifacts: false` the proxy streams every artifact download from upstream to the client and stores no artifacts. Metadata is still filtered and cached, so cooldown and the denylist apply as usual, and the database still holds the publish times cooldown needs. Use it when another caching layer, such as an Artifactory remote repository, sits in front of the proxy and caching artifacts twice only costs storage.
Expand Down Expand Up @@ -854,13 +862,10 @@ repository, such as a fork, it is copied from there instead of downloaded,
once the upstream has confirmed this repository has it; the copy takes its
own space.

Large LFS objects may need longer timeouts. The proxy downloads an object it
doesn't have before sending any of it, within `artifact_timeout`, and the
whole response, download included, must fit within `write_timeout`; both
default to 5 minutes. git-lfs gives up on a response after 30 seconds without
data (`lfs.activitytimeout`) and retries. A retry waits for the download still
running, so the object is served once it is cached, but a download that
outlasts git-lfs's retries fails that clone or pull until it finishes.
Large LFS objects may need longer timeouts. An object the proxy doesn't
have is sent to git-lfs as it is downloaded (see [Cache misses](#cache-misses)),
but the download must finish within `artifact_timeout` and the whole response
within `write_timeout`; both default to 5 minutes.
Download URLs are often signed; only the URL without its query is logged
and recorded with the object. An object is only cached once its content
matches its sha256 object ID. Objects are cached per repository, so one
Expand Down Expand Up @@ -1250,7 +1255,7 @@ scanning:

### How caching defers to a scan verdict

The proxy never uploads artifact bytes to a scanner. When an artifact is fetched from upstream, it's stored to the configured storage backend first, exactly as without scanning. If scanning is enabled for the artifact's ecosystem, the proxy then notifies each applicable scanner with package metadata and a short-lived, HMAC-signed URL pointing at the internal `/_internal/scan-fetch` route; each scanner GETs that URL itself to pull the exact bytes staged in storage and runs its own scan against them.
The proxy never uploads artifact bytes to a scanner. When an artifact is fetched from upstream, it's stored to the configured storage backend first, exactly as without scanning. If scanning is enabled for the artifact's ecosystem, the proxy then notifies each applicable scanner with package metadata and a short-lived, HMAC-signed URL pointing at the internal `/_internal/scan-fetch` route; each scanner GETs that URL itself to pull the exact bytes staged in storage and runs its own scan against them. With a `block`-mode scanner for the ecosystem, the client gets nothing before the verdict; with only `monitor`-mode scanners, a cache miss is sent to the client while it is stored, and the scan runs after (see [Cache misses](#cache-misses)).

Scanners configured for the same ecosystem all run concurrently, never sequentially. The moment any `block`-mode scanner reports a not-allowed verdict (or errors, unless `fail_open` is set), the proxy cancels the in-flight calls to the other scanners and deletes the staged artifact — it's never committed to the cache database, so it was never visible to a client. If nothing blocks, the proxy waits for every `block`-mode scanner to finish before caching the artifact and serving it. A `monitor`-mode scanner's findings are logged and never gate the wait or the caching decision, even when it reports not-allowed.

Expand Down
20 changes: 17 additions & 3 deletions internal/handler/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -653,7 +653,7 @@ func (p *Proxy) fetchAndCache(ctx context.Context, ecosystem, name, version, fil
}
metrics.RecordUpstreamFetch(ecosystem, fetchDuration)

return p.storeArtifact(ctx, ecosystem, name, version, filename, pkgPURL, versionPURL, info.URL, "", "", artifact)
return p.storeFetched(ctx, ecosystem, name, version, filename, pkgPURL, versionPURL, info.URL, "", "", artifact)
}

// storeArtifact writes a fetched artifact to storage, verifies it against
Expand Down Expand Up @@ -917,6 +917,9 @@ func (p *Proxy) coalesceFetch(ctx context.Context, key string, recheck func() (a
p.fetchMu.Unlock()

if !joined {
if streamsCacheMiss(ctx) {
return p.runSharedFetchStreaming(ctx, key, f, recheck, commit)
}
return p.runSharedFetch(ctx, key, f, recheck, commit)
}

Expand Down Expand Up @@ -1041,12 +1044,23 @@ func (p *Proxy) runSharedFetch(ctx context.Context, key string, f *inflightFetch
if err != nil {
return nil, err
}
// A caller handed the stream already has the bytes; opening the stored
// copy would cost a read it never uses, a GET on S3 or GCS.
h := handoffFrom(ctx)
handedOver := h != nil && h.handedOver()
if unrecorded {
f.unrecorded = &unrecordedObject{delete: func() { p.deleteUnrecorded(path) }}
f.unrecorded.refs.Add(1) // this caller
f.unrecorded.refs.Add(1) // until the waiters are counted
if handedOver {
h.holdUnrecorded(f.unrecorded)
return nil, nil
}
f.unrecorded.refs.Add(1) // this caller
return p.serveUnrecorded(ctx, f.unrecorded, stored, path)
}
if handedOver {
return nil, nil
}
return p.openStoredArtifact(ctx, stored, path)
}

Expand Down Expand Up @@ -2480,7 +2494,7 @@ func (p *Proxy) fetchAndCacheFromURL(ctx context.Context, ecosystem, name, versi
return artifacts.Artifact{}, "", fmt.Errorf("fetching from upstream: %w", err)
}

return p.storeArtifact(ctx, ecosystem, name, version, filename, pkgPURL, versionPURL, downloadURL, upstreamHash, declared, artifact)
return p.storeFetched(ctx, ecosystem, name, version, filename, pkgPURL, versionPURL, downloadURL, upstreamHash, declared, artifact)
}

type upstreamLastModifiedKey struct{}
Expand Down
Loading
Loading