Watch
1
0
Fork
You've already forked pkg-proxy
1
mirror of https://github.com/git-pkgs/proxy.git synced 2026-11-09 07:39:19 -05:00
pkg-proxy/internal/handler/stream_miss.go
Simon Chrzanowski fb346ae355 Hold an unrecorded object for a client reading its stored copy
A client that fell behind is switched to the stored copy, which for an
unrecorded refetch is deleted once the waiters close. It now takes its
own hold, released when it closes.
2026-10-27 12:30:59 +01:00

458 lines
14 KiB
Go

package handler
import (
"context"
"errors"
"fmt"
"io"
"strings"
"sync"
"github.com/git-pkgs/artifacts"
"github.com/git-pkgs/proxy/internal/metrics"
"github.com/git-pkgs/registries/fetch"
"github.com/opencontainers/go-digest"
)
// On a cache miss a marked request is sent the bytes as they arrive from
// upstream while they are stored, rather than after the whole artifact is
// stored. Clients with idle timeouts need this for large artifacts: git-lfs
// gives up after 30 seconds without data, pacman after 10. The caller that
// starts the shared fetch is handed the stream; callers joining it still wait
// for the stored artifact.
type streamCacheMissKey struct{}
// StreamCacheMiss marks ctx so that on an artifact cache miss the bytes are
// sent to the client while they are being stored. The server sets it on GET
// requests without a Range header; a range of a stream that is still
// arriving can't be served.
func StreamCacheMiss(ctx context.Context) context.Context {
return context.WithValue(ctx, streamCacheMissKey{}, true)
}
func streamsCacheMiss(ctx context.Context) bool {
stream, _ := ctx.Value(streamCacheMissKey{}).(bool)
return stream && !servingHead(ctx)
}
// missHandoff passes the client's stream from the shared fetch, which runs on
// its own goroutine, to the caller that started it. The caller can leave
// before a stream is offered, and the fetch must then not write to a stream
// nobody reads.
type missHandoff struct {
mu sync.Mutex
ready chan struct{}
stream *CacheResult
taken bool
abandoned bool
// feed is the stream's source. Set and read only on the fetch's
// goroutine.
feed *missFeed
}
type missHandoffKey struct{}
func handoffFrom(ctx context.Context) *missHandoff {
h, _ := ctx.Value(missHandoffKey{}).(*missHandoff)
return h
}
// offer hands stream to the caller and reports whether it took it.
func (h *missHandoff) offer(stream *CacheResult) bool {
h.mu.Lock()
defer h.mu.Unlock()
if h.abandoned {
return false
}
h.stream, h.taken = stream, true
close(h.ready)
return true
}
// holdUnrecorded gives a client that fell behind, and reads the stored copy
// of u, its own hold on u until it closes, so u isn't deleted under it.
func (h *missHandoff) holdUnrecorded(u *unrecordedObject) {
if h.feed != nil {
h.feed.holdUntilClose(u)
}
}
// handedOver reports whether the caller took a stream.
func (h *missHandoff) handedOver() bool {
h.mu.Lock()
defer h.mu.Unlock()
return h.taken
}
// runSharedFetchStreaming runs the shared fetch on its own goroutine and
// returns as soon as it offers a stream, or with its result if it finishes
// without one: on a recheck hit, an artifact another instance stored, an
// error, or an artifact a block-mode scanner has to see first. Waiters and
// the fetch lease still see the fetch end only once the artifact is stored.
func (p *Proxy) runSharedFetchStreaming(ctx context.Context, key string, f *inflightFetch, recheck func() (artifacts.Artifact, string, bool), commit func(context.Context) (artifacts.Artifact, string, error)) (*CacheResult, error) {
h := &missHandoff{ready: make(chan struct{})}
type outcome struct {
res *CacheResult
err error
}
done := make(chan outcome, 1)
go func() {
res, err := p.runSharedFetch(context.WithValue(ctx, missHandoffKey{}, h), key, f, recheck, commit)
h.mu.Lock()
defer h.mu.Unlock()
if h.taken || h.abandoned {
// The caller has the stream, or has gone; nobody reads this.
if res != nil && res.Reader != nil {
_ = res.Reader.Close()
}
return
}
done <- outcome{res, err}
}()
select {
case <-h.ready:
return h.stream, nil
case o := <-done:
return o.res, o.err
case <-ctx.Done():
h.mu.Lock()
defer h.mu.Unlock()
if h.taken {
return h.stream, nil
}
h.abandoned = true
select {
case o := <-done:
if o.res != nil && o.res.Reader != nil {
_ = o.res.Reader.Close()
}
default:
}
return nil, ctx.Err()
}
}
// storeFetched stores an artifact fetched from upstream. When the caller
// that started the fetch is waiting for a stream, it is sent the bytes as
// they are stored; see storeWhileServing.
func (p *Proxy) storeFetched(ctx context.Context, ecosystem, name, version, filename, pkgPURL, versionPURL, upstreamURL, upstreamHash, declared string, artifact *fetch.Artifact) (artifacts.Artifact, string, error) {
h := handoffFrom(ctx)
if h == nil || p.Scanners.Blocks(ecosystem) {
return p.storeArtifact(ctx, ecosystem, name, version, filename, pkgPURL, versionPURL, upstreamURL, upstreamHash, declared, artifact)
}
return p.storeWhileServing(ctx, h, ecosystem, name, version, filename, pkgPURL, versionPURL, upstreamURL, upstreamHash, declared, artifact)
}
// errNoReaderLeft stops reading upstream once neither storage nor the
// client takes the bytes any more.
var errNoReaderLeft = errors.New("neither storage nor the client is reading the artifact")
// storeWhileServing copies the upstream body to storage and to the client at
// once, at the upstream's speed; see missFeed for a client that can't keep up.
// The client's copy is checked before its last bytes, so a mismatch aborts
// the response. A storage failure leaves the client's copy running.
func (p *Proxy) storeWhileServing(ctx context.Context, h *missHandoff, ecosystem, name, version, filename, pkgPURL, versionPURL, upstreamURL, upstreamHash, declared string, artifact *fetch.Artifact) (artifacts.Artifact, string, error) {
hash := strings.ToLower(upstreamHash)
checks, err := newIntegrityChecks(hash, declared)
if err != nil {
_ = artifact.Body.Close()
return artifacts.Artifact{}, "", err
}
feed := newMissFeed(clientFeedLimit)
var client io.ReadCloser = feed
if hash != "" || declared != "" {
if client, err = checks.wrapFailOnMismatch(feed, func(reason string) {
p.Logger.Warn("artifact sent on a cache miss failed its integrity check; aborting the response",
"purl", versionPURL, "filename", filename, "reason", reason)
}); err != nil {
_ = artifact.Body.Close()
return artifacts.Artifact{}, "", err
}
}
stream := &CacheResult{
Reader: client,
Artifact: artifacts.Artifact{
PURL: versionPURL,
Filename: filename,
MediaType: artifact.ContentType,
// Unknown until stored, so the response goes out chunked and
// can still be aborted.
Size: -1,
},
}
if hash != "" {
stream.Artifact.Digest = digest.Digest("sha256:" + hash)
}
if !h.offer(stream) {
_ = feed.Close()
return p.storeArtifact(ctx, ecosystem, name, version, filename, pkgPURL, versionPURL, upstreamURL, upstreamHash, declared, artifact)
}
h.feed = feed
storeR, storeW := io.Pipe()
type stored struct {
artifact artifacts.Artifact
path string
err error
}
storeDone := make(chan stored, 1)
go func() {
// Not cancelled with ctx: the client's request ends once it has the
// last byte, while storage may still be finishing the upload and the
// database write. Reading the upstream still stops with ctx, which
// ends the copy to storage too.
a, path, err := p.storeArtifact(context.WithoutCancel(ctx), ecosystem, name, version, filename, pkgPURL, versionPURL, upstreamURL, upstreamHash, declared,
&fetch.Artifact{Body: storeR, Size: artifact.Size, ContentType: artifact.ContentType, ETag: artifact.ETag})
// storeArtifact closes storeR; this covers a return before it read
// anything, so the copy below never blocks on it.
_ = storeR.Close()
storeDone <- stored{a, path, err}
}()
readErr := copyToBoth(artifact.Body, storeW, feed)
_ = artifact.Body.Close()
_ = storeW.CloseWithError(readErr)
feed.end(readErr)
out := <-storeDone
if feed.lagging() {
// Opened before returning, so an unrecorded object is still there;
// runSharedFetch then gives the client a hold on it.
feed.resolve(p.openStoredForFeed(ctx, out.artifact, out.path, out.err))
}
if out.err != nil && readErr == nil && feed.fedFully() {
p.Logger.Error("artifact sent to the client but not cached",
"purl", versionPURL, "filename", filename, "url", upstreamURL, "error", out.err)
metrics.RecordArtifactServedUncached(ecosystem, metrics.FailureDetail{
PURL: versionPURL, Filename: filename, URL: upstreamURL, Err: out.err})
}
return out.artifact, out.path, out.err
}
// openStoredForFeed opens the stored copy for a client that fell behind. An
// unrecorded object is opened too: it is deleted only after this returns, and
// the client holds it from then on (missHandoff.holdUnrecorded).
func (p *Proxy) openStoredForFeed(ctx context.Context, a artifacts.Artifact, path string, storeErr error) (io.ReadCloser, error) {
if storeErr != nil && !errors.Is(storeErr, errUnrecorded) {
return nil, storeErr
}
res, err := p.openStoredArtifact(ctx, a, path)
if err != nil {
return nil, err
}
return res.Reader, nil
}
// copyToBoth copies src to store and to the client's feed until src ends,
// dropping the store once a write to it fails. It returns the error that
// ended the copy, nil at the end of src. Feeding the client never blocks.
func copyToBoth(src io.Reader, store io.Writer, client *missFeed) error {
buffer := artifactCopyBufferPool.Get().(*[]byte)
defer artifactCopyBufferPool.Put(buffer)
buf := *buffer
storeOK := true
for {
n, err := src.Read(buf)
if n > 0 {
if storeOK {
if _, werr := store.Write(buf[:n]); werr != nil {
storeOK = false
}
}
// A client that has gone, or that waits for the stored copy,
// has no use for the rest once storing has failed.
if !client.push(buf[:n]) && !storeOK {
return errNoReaderLeft
}
}
if errors.Is(err, io.EOF) {
return nil
}
if err != nil {
return err
}
}
}
// clientFeedLimit bounds the bytes held for a client that reads a streamed
// miss slower than they arrive. A variable so tests can lower it.
var clientFeedLimit = 8 << 20
// missFeed is the client's side of a streamed miss. The upstream read pushes
// into it without blocking, so a slow client can't hold back the copy to
// storage, the fetch lease or the callers waiting on the fetch. Once the
// client is more than the limit behind, it stops being fed: it reads what
// is held, then continues from the stored copy where it left off.
type missFeed struct {
mu sync.Mutex
cond *sync.Cond
chunks [][]byte
held int
limit int
// fed counts the bytes pushed before the client fell behind, where it
// continues in the stored copy.
fed int64
ended bool
endErr error
behind bool
closed bool
early bool // closed before reading everything it was fed
stored io.ReadCloser
storedErr error
resolved bool
skipped bool
// release drops the client's hold on an unrecorded stored copy.
release func()
}
func newMissFeed(limit int) *missFeed {
m := &missFeed{limit: limit}
m.cond = sync.NewCond(&m.mu)
return m
}
// push queues a copy of b and reports whether the client is still fed.
func (m *missFeed) push(b []byte) bool {
m.mu.Lock()
defer m.mu.Unlock()
if m.closed || m.behind {
return false
}
if m.held+len(b) > m.limit {
m.behind = true
return false
}
m.chunks = append(m.chunks, append([]byte(nil), b...))
m.held += len(b)
m.fed += int64(len(b))
m.cond.Broadcast()
return true
}
// end records how the upstream read ended.
func (m *missFeed) end(err error) {
m.mu.Lock()
defer m.mu.Unlock()
m.ended, m.endErr = true, err
m.cond.Broadcast()
}
func (m *missFeed) lagging() bool {
m.mu.Lock()
defer m.mu.Unlock()
return m.behind && !m.closed
}
// fedFully reports whether the client was sent every byte from upstream.
func (m *missFeed) fedFully() bool {
m.mu.Lock()
defer m.mu.Unlock()
return !m.behind && !m.early
}
// resolve gives a client that fell behind the stored copy, or the error
// that left none.
func (m *missFeed) resolve(r io.ReadCloser, err error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.closed {
if r != nil {
_ = r.Close()
}
return
}
m.stored, m.storedErr, m.resolved = r, err, true
m.cond.Broadcast()
}
// holdUntilClose takes a hold on u for a client reading its stored copy and
// releases it when the client closes.
func (m *missFeed) holdUntilClose(u *unrecordedObject) {
m.mu.Lock()
defer m.mu.Unlock()
if m.closed || m.stored == nil {
return
}
u.refs.Add(1)
m.release = u.release
}
func (m *missFeed) Read(p []byte) (int, error) {
m.mu.Lock()
for {
switch {
case m.closed:
m.mu.Unlock()
return 0, io.ErrClosedPipe
case len(m.chunks) > 0:
n := copy(p, m.chunks[0])
if m.chunks[0] = m.chunks[0][n:]; len(m.chunks[0]) == 0 {
m.chunks[0] = nil
m.chunks = m.chunks[1:]
}
m.held -= n
m.mu.Unlock()
return n, nil
case m.behind && m.resolved:
r, err, skipped := m.stored, m.storedErr, m.skipped
m.skipped = true
m.mu.Unlock()
if err != nil {
return 0, err
}
if !skipped {
if err := skipTo(r, m.fed); err != nil {
return 0, err
}
}
return r.Read(p)
case !m.behind && m.ended:
err := m.endErr
m.mu.Unlock()
if err == nil {
err = io.EOF
}
return 0, err
}
m.cond.Wait()
}
}
func (m *missFeed) Close() error {
m.mu.Lock()
if m.closed {
m.mu.Unlock()
return nil
}
m.closed = true
m.early = !m.ended || len(m.chunks) > 0
m.chunks, m.held = nil, 0
if m.stored != nil {
_ = m.stored.Close()
}
release := m.release
m.release = nil
m.cond.Broadcast()
m.mu.Unlock()
if release != nil {
// Outside the lock: the last release deletes the object.
release()
}
return nil
}
// skipTo moves r past the first n bytes.
func skipTo(r io.Reader, n int64) error {
if s, ok := r.(io.Seeker); ok {
_, err := s.Seek(n, io.SeekStart)
return err
}
if _, err := io.CopyN(io.Discard, r, n); err != nil {
return fmt.Errorf("skipping to byte %d of the stored artifact: %w", n, err)
}
return nil
}