| // Copyright 2023 The Chromium Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| package reapi |
| |
| import ( |
| "bytes" |
| "compress/flate" |
| "context" |
| "errors" |
| "fmt" |
| "io" |
| "iter" |
| "maps" |
| "path" |
| "runtime" |
| "slices" |
| "sort" |
| "strconv" |
| "strings" |
| "sync" |
| "time" |
| |
| log "github.com/golang/glog" |
| "github.com/google/uuid" |
| "github.com/klauspost/compress/zstd" |
| "golang.org/x/sync/errgroup" |
| xsemaphore "golang.org/x/sync/semaphore" |
| bpb "google.golang.org/genproto/googleapis/bytestream" |
| "google.golang.org/grpc/codes" |
| "google.golang.org/grpc/status" |
| "google.golang.org/protobuf/proto" |
| |
| "go.chromium.org/build/hashigo/digest" |
| rpb "go.chromium.org/build/remote-apis/build/bazel/remote/execution/v2" |
| |
| "go.chromium.org/build/siso/blob" |
| "go.chromium.org/build/siso/o11y/clog" |
| "go.chromium.org/build/siso/o11y/trace" |
| "go.chromium.org/build/siso/reapi/bytestreamio" |
| "go.chromium.org/build/siso/reapi/fetch" |
| "go.chromium.org/build/siso/reapi/retry" |
| "go.chromium.org/build/siso/sync/semaphore" |
| ) |
| |
| // FileSemaphore limits concurrent file access to create BatchUpdateBlobgs to protect from runtime thread exhaustion. |
| var FileSemaphore = semaphore.New("reapi-cas-file", runtime.GOMAXPROCS(0)) |
| |
| const ( |
| // defaultBatchUpdateByteLimit is bytes limit for cas BatchUpdateBlobs. |
| defaultBatchUpdateByteLimit = 4 * 1024 * 1024 |
| |
| // batchBlobUploadLimit is max number of blobs in BatchUpdateBlobs. |
| batchBlobUploadLimit = 1000 |
| ) |
| |
| // uploadConcurrency is the per-UploadAll cap for batch and stream RPCs. |
| // Defaults to 1 (serial) so callers must opt into parallel upload. |
| func (c *Client) uploadConcurrency() int { |
| if c.opt.UploadConcurrency > 0 { |
| return c.opt.UploadConcurrency |
| } |
| return 1 |
| } |
| |
| func selectCompressor(serverSupported []rpb.Compressor_Value) rpb.Compressor_Value { |
| if len(serverSupported) == 0 { |
| // No compressor support. |
| return rpb.Compressor_IDENTITY |
| } |
| for _, c := range []rpb.Compressor_Value{ |
| rpb.Compressor_ZSTD, |
| rpb.Compressor_DEFLATE, |
| rpb.Compressor_IDENTITY, |
| } { |
| if slices.Contains(serverSupported, c) { |
| return c |
| } |
| } |
| return rpb.Compressor_IDENTITY |
| } |
| |
| type uploadOp struct { |
| ch chan struct{} |
| err error |
| } |
| |
| func newUploadOp() *uploadOp { |
| return &uploadOp{ |
| ch: make(chan struct{}), |
| err: errUploadNotFinished, |
| } |
| } |
| |
| func (u *uploadOp) done(err error) { |
| u.err = err |
| close(u.ch) |
| } |
| |
| func (u *uploadOp) wait(ctx context.Context) error { |
| select { |
| case <-ctx.Done(): |
| return context.Cause(ctx) |
| case <-u.ch: |
| return u.err |
| } |
| } |
| |
| var ( |
| errUploadNotFinished = errors.New("upload not finished") |
| errUploadNoResponse = errors.New("upload no response") |
| ) |
| |
| // compressor returns a compressor for ByteStream Read/Write APIs. |
| func (c *Client) compressor(d digest.Digest) rpb.Compressor_Value { |
| if d.SizeBytes < c.opt.CompressedBlob { |
| return rpb.Compressor_IDENTITY |
| } |
| return c.opt.compressor |
| } |
| |
| // compressorForBatchUpdate returns a compressor for BatchUpdateBlobs API. |
| func (c *Client) compressorForBatchUpdate(d digest.Digest) rpb.Compressor_Value { |
| if d.SizeBytes < c.opt.BatchCompressedBlob { |
| return rpb.Compressor_IDENTITY |
| } |
| return c.opt.compressorForBatchUpdateBlobs |
| } |
| |
| // acceptableCompressors returns acceptable compressors for BatchReadBlobs API. |
| // If blob-level compression is disabled or the blob is too small, it returns nil. |
| func (c *Client) acceptableCompressors(d digest.Digest) []rpb.Compressor_Value { |
| if c.opt.BatchCompressedBlob == 0 || d.SizeBytes < c.opt.BatchCompressedBlob { |
| return nil |
| } |
| return []rpb.Compressor_Value{ |
| rpb.Compressor_ZSTD, |
| rpb.Compressor_DEFLATE, |
| } |
| } |
| |
| // resourceName constructs a resource name for reading the blob identified by the digest. |
| // For uncompressed blob. the format is |
| // |
| // `{instance_name}/blobs/{hash}/{size}` |
| // |
| // For compressed blob, the format is |
| // |
| // `{instance_name}/compressed-blobs/{compressor}/{uncompressed_hash}/{uncompressed_size}` |
| // |
| // See also the API document. |
| // https://github.com/bazelbuild/remote-apis/blob/64cc5e9e422c93e1d7f0545a146fd84fcc0e8b47/build/bazel/remote/execution/v2/remote_execution.proto#L285-L292 |
| func (c *Client) resourceName(d digest.Digest) string { |
| if compressor := c.compressor(d); compressor != rpb.Compressor_IDENTITY { |
| return path.Join(c.opt.Instance, "compressed-blobs", |
| strings.ToLower(compressor.String()), c.digestFunctionSegment(), |
| d.Hash, strconv.FormatInt(d.SizeBytes, 10)) |
| } |
| return path.Join(c.opt.Instance, "blobs", c.digestFunctionSegment(), |
| d.Hash, strconv.FormatInt(d.SizeBytes, 10)) |
| } |
| |
| // digestFunctionSegment returns the resource-name path segment for the client's |
| // digest function, or "" when REAPI omits it (sha256 and sha1, inferred by the |
| // server from the hash length). path.Join drops empty segments. |
| func (c *Client) digestFunctionSegment() string { |
| return c.digestFn.ResourceNameSegment() |
| } |
| |
| // probeSize is the largest compressed blob size that takes the sync |
| // DecodeAll path. |
| const probeSize = 128 * 1024 |
| |
| // freshDecoderThreshold (decompressed bytes) routes blobs at or above |
| // this size to a fresh, non-pooled decoder so its window-sized h.b |
| // (streaming) or syncStream.dstBuf (sync DecodeAll on a highly |
| // compressible blob) can't end up in the pool. Smaller blobs share |
| // pool decoders. |
| const freshDecoderThreshold = 4 * 1024 * 1024 |
| |
| // zstdDecoderOpts is the shared decoder configuration. Concurrency(1) |
| // avoids the per-decoder GOMAXPROCS worker fanout; siso's outer |
| // parallelism saturates CPUs. WithDecodeBuffersBelow(probeSize+1) |
| // makes the decoder take the sync path for any *bytes.Buffer with |
| // Len() <= probeSize. |
| var zstdDecoderOpts = []zstd.DOption{ |
| zstd.WithDecoderConcurrency(1), |
| zstd.WithDecodeBuffersBelow(probeSize + 1), |
| } |
| |
| var probeBufPool = sync.Pool{ |
| New: func() any { return bytes.NewBuffer(make([]byte, 0, probeSize+1)) }, |
| } |
| |
| func (c *Client) newDecoder(r io.Reader, d digest.Digest) (io.ReadCloser, error) { |
| return c.newDecoderForCompressor(r, d, c.compressor(d)) |
| } |
| |
| // newDecoderForCompressor returns a decoder for the given compressor value. |
| func (c *Client) newDecoderForCompressor(r io.Reader, d digest.Digest, compressor rpb.Compressor_Value) (io.ReadCloser, error) { |
| switch compressor { |
| case rpb.Compressor_IDENTITY: |
| return io.NopCloser(r), nil |
| case rpb.Compressor_ZSTD: |
| buf := probeBufPool.Get().(*bytes.Buffer) |
| buf.Reset() |
| // Read probeSize+1 so a reader holding exactly probeSize bytes |
| // (gRPC delivers EOF only on the read past the last byte) returns |
| // io.EOF here and is detected as fitting. |
| _, err := io.CopyN(buf, r, probeSize+1) |
| var src io.Reader = buf |
| switch { |
| case errors.Is(err, io.EOF): |
| // Entire compressed blob fits in buf. Keeping src as |
| // *bytes.Buffer lets zstd use its sync DecodeAll path for |
| // small decoded blobs. |
| case err == nil: |
| // Probe filled, more data remains. MultiReader replays the |
| // probed bytes and is not a byter, so zstd uses streaming. |
| src = io.MultiReader(buf, r) |
| default: |
| probeBufPool.Put(buf) |
| return nil, err |
| } |
| if d.SizeBytes < freshDecoderThreshold { |
| return c.newPooledBufferedDecoder(src, buf) |
| } |
| sd, err := zstd.NewReader(src, zstdDecoderOpts...) |
| if err != nil { |
| probeBufPool.Put(buf) |
| return nil, err |
| } |
| return &discardableDecoder{Decoder: sd, buf: buf}, nil |
| case rpb.Compressor_DEFLATE: |
| return flate.NewReader(r), nil |
| default: |
| return nil, fmt.Errorf("unsupported compressor %q", compressor) |
| } |
| } |
| |
| // newPooledBufferedDecoder takes a pooled decoder, points it at src, |
| // and pairs it with the probe buf so the buf is recycled on Close. |
| func (c *Client) newPooledBufferedDecoder(src io.Reader, buf *bytes.Buffer) (io.ReadCloser, error) { |
| dec := c.zstdDecoderPool.Get().(*pooledDecoder) |
| if err := dec.Reset(src); err != nil { |
| probeBufPool.Put(buf) |
| return nil, err |
| } |
| return &bufferedDecoder{pooledDecoder: dec, buf: buf}, nil |
| } |
| |
| // discardableDecoder is dropped on Close (not pooled) so the |
| // window-sized h.b a streaming frame retains, or the syncStream.dstBuf |
| // a highly-compressible sync DecodeAll retains, can't end up in the |
| // pool. |
| type discardableDecoder struct { |
| *zstd.Decoder |
| // buf is held alive while the MultiReader fed to the decoder still references it. |
| buf *bytes.Buffer |
| } |
| |
| func (s *discardableDecoder) Close() error { |
| if s.buf == nil { |
| return nil |
| } |
| s.Decoder.Close() |
| probeBufPool.Put(s.buf) |
| s.buf = nil |
| return nil |
| } |
| |
| type pooledDecoder struct { |
| *zstd.Decoder |
| pool *sync.Pool |
| } |
| |
| func (d *pooledDecoder) Close() error { |
| // Drop the io.Reader reference held by the decoder. |
| if err := d.Reset(nil); err != nil { |
| d.Decoder.Close() |
| return err |
| } |
| d.pool.Put(d) |
| return nil |
| } |
| |
| // bufferedDecoder pairs a pooled decoder with the probe buf that |
| // must outlive the decoder's reads. |
| type bufferedDecoder struct { |
| *pooledDecoder |
| buf *bytes.Buffer |
| } |
| |
| func (b *bufferedDecoder) Close() error { |
| if b.buf == nil { |
| return nil |
| } |
| err := b.pooledDecoder.Close() |
| probeBufPool.Put(b.buf) |
| b.buf = nil |
| return err |
| } |
| |
| // Get fetches the content of blob from CAS by digest. |
| // For small blobs, it uses BatchReadBlobs. |
| // For large blobs, it uses Read method of the ByteStream API |
| func (c *Client) Get(ctx context.Context, d digest.Digest, name string) ([]byte, error) { |
| if c == nil { |
| return nil, fmt.Errorf("reapi is not configured") |
| } |
| if d.SizeBytes == 0 { |
| return nil, nil |
| } |
| |
| ctx, span := trace.NewSpan(ctx, "reapi-get") |
| defer span.Close(nil) |
| span.SetAttr("sizebytes", d.SizeBytes) |
| |
| if d.SizeBytes < c.opt.ByteStreamReadThreshold { |
| return c.getWithBatchReadBlobs(ctx, d, name) |
| } |
| return c.getWithByteStream(ctx, d, name) |
| } |
| |
| // GetReader returns an io.ReadCloser to stream the blob content from CAS. |
| // For small blobs, it fetches the content using BatchReadBlobs and wraps it in a reader. |
| // For large blobs, it returns the streaming decoder directly from the ByteStream API. |
| // The caller must close the returned reader. |
| func (c *Client) GetReader(ctx context.Context, d digest.Digest, name string) (io.ReadCloser, error) { |
| if c == nil { |
| return nil, fmt.Errorf("reapi is not configured") |
| } |
| if d.SizeBytes == 0 { |
| return io.NopCloser(bytes.NewReader(nil)), nil |
| } |
| |
| if d.SizeBytes < c.opt.ByteStreamReadThreshold { |
| buf, err := c.getWithBatchReadBlobs(ctx, d, name) |
| if err != nil { |
| return nil, err |
| } |
| return io.NopCloser(bytes.NewReader(buf)), nil |
| } |
| src := digestSource{ |
| c: c, |
| d: d, |
| fname: name, |
| } |
| return src.Open(ctx) |
| } |
| |
| // getWithBatchReadBlobs fetches the content of blob using BatchReadBlobs rpc of CAS. |
| func (c *Client) getWithBatchReadBlobs(ctx context.Context, d digest.Digest, name string) ([]byte, error) { |
| started := time.Now() |
| if log.V(1) { |
| clog.Infof(ctx, "getWithBatchReadBlobs %s", d) |
| } |
| casClient := rpb.NewContentAddressableStorageClient(c.casConn) |
| var resp *rpb.BatchReadBlobsResponse |
| err := retry.Do(ctx, func() error { |
| var err error |
| resp, err = casClient.BatchReadBlobs(ctx, &rpb.BatchReadBlobsRequest{ |
| InstanceName: c.opt.Instance, |
| Digests: []*rpb.Digest{d.Proto()}, |
| AcceptableCompressors: c.acceptableCompressors(d), |
| DigestFunction: c.digestFn.Value(), |
| }) |
| return err |
| }) |
| if err != nil { |
| c.m.ReadDone(0, err) |
| return nil, fmt.Errorf("failed to read blobs %s for %s in %s: %w", d, name, time.Since(started), err) |
| } |
| if len(resp.Responses) != 1 { |
| c.m.ReadDone(0, err) |
| return nil, fmt.Errorf("failed to read blobs %s for %s in %s: responses=%d", d, name, time.Since(started), len(resp.Responses)) |
| } |
| data, err := c.decodeForBatchRead(d, resp.Responses[0].Data, resp.Responses[0].Compressor) |
| if err != nil { |
| c.m.ReadDone(0, err) |
| return nil, fmt.Errorf("failed to decompress blobs %s for %s in %s: %w", d, name, time.Since(started), err) |
| } |
| c.m.ReadDone(len(data), nil) |
| return data, nil |
| } |
| |
| // decodeForBatchRead decompresses data from a BatchReadBlobs response. |
| func (c *Client) decodeForBatchRead(d digest.Digest, data []byte, compressor rpb.Compressor_Value) ([]byte, error) { |
| r, err := c.newDecoderForCompressor(bytes.NewReader(data), d, compressor) |
| if err != nil { |
| return nil, err |
| } |
| defer r.Close() |
| buf := make([]byte, d.SizeBytes) |
| if _, err := io.ReadFull(r, buf); err != nil { |
| return nil, err |
| } |
| if err := expectEOF(r); err != nil { |
| return nil, fmt.Errorf("decompressed data exceeds expected size %d", d.SizeBytes) |
| } |
| return buf, nil |
| } |
| |
| // expectEOF verifies that r yields no more data before EOF. After a blob of |
| // known size has been fully read, the stream must be at EOF; a trailing byte |
| // means the server sent more than the digest declares, i.e. a blob that does |
| // not match the requested digest. |
| func expectEOF(r io.Reader) error { |
| switch n, err := io.CopyN(io.Discard, r, 1); { |
| case n > 0: |
| return errors.New("stream has trailing data past digest size") |
| case errors.Is(err, io.EOF): |
| return nil |
| default: |
| return err |
| } |
| } |
| |
| // getWithByteStream fetches the content of blob using the ByteStream API |
| func (c *Client) getWithByteStream(ctx context.Context, d digest.Digest, name string) ([]byte, error) { |
| started := time.Now() |
| resourceName := c.resourceName(d) |
| if log.V(1) { |
| clog.Infof(ctx, "getWithByteStream %s resourceName=%s", d, resourceName) |
| } |
| buf := make([]byte, d.SizeBytes) |
| err := retry.Do(ctx, func() error { |
| ctx, cancel := fetch.ContextWithTimeout(ctx, d) |
| defer cancel() |
| r, err := bytestreamio.Open(ctx, bpb.NewByteStreamClient(c.casDataConn), resourceName) |
| if err != nil { |
| c.m.ReadDone(0, err) |
| return err |
| } |
| rd, err := c.newDecoder(r, d) |
| if err != nil { |
| c.m.ReadDone(0, err) |
| return err |
| } |
| defer rd.Close() |
| n, err := io.ReadFull(rd, buf) |
| c.m.ReadDone(n, err) |
| if err != nil { |
| return err |
| } |
| if err := expectEOF(rd); err != nil { |
| clog.Warningf(ctx, "blob %s for %s: %v", d, name, err) |
| } |
| return nil |
| }) |
| if err != nil { |
| return buf, fmt.Errorf("failed to read stream %s for %s in %s: %w", d, name, time.Since(started), err) |
| } |
| return buf, nil |
| } |
| |
| // Missing returns digests of missing blobs. |
| func (c *Client) Missing(ctx context.Context, blobs []digest.Digest) ([]digest.Digest, error) { |
| blobspb := make([]*rpb.Digest, 0, len(blobs)) |
| for _, b := range blobs { |
| blobspb = append(blobspb, b.Proto()) |
| } |
| cas := rpb.NewContentAddressableStorageClient(c.casConn) |
| |
| // Limit each *rpb.FindMissingBlobsRequest under gRPC's default 4MiB message |
| // size. Each repeated Digest costs its hex hash plus a small constant of |
| // proto framing (sub-message tag/length, hash field tag/length, and the |
| // size_bytes field). Derive the batch from the current function's hash length |
| // so long hashes (e.g. 128 hex bytes for SHA-512) still stay under the limit; |
| // the reserve leaves room for the request's instance_name and digest_function |
| // fields. |
| const maxRequestBytes = 4 << 20 |
| const reserveBytes = 4 << 10 |
| const perDigestOverhead = 16 |
| maxBlobs := (maxRequestBytes - reserveBytes) / (c.digestFn.HexLen() + perDigestOverhead) |
| |
| var ret []digest.Digest |
| for len(blobspb) > 0 { |
| var remain []*rpb.Digest |
| if len(blobspb) > maxBlobs { |
| remain = blobspb[maxBlobs:] |
| blobspb = blobspb[:maxBlobs] |
| } |
| var resp *rpb.FindMissingBlobsResponse |
| // TODO(b/328332495): grpc should retry by service config? |
| err := retry.Do(ctx, func() error { |
| ctx, cancel := context.WithTimeout(ctx, 10*time.Second) |
| defer cancel() |
| var err error |
| resp, err = cas.FindMissingBlobs(ctx, &rpb.FindMissingBlobsRequest{ |
| InstanceName: c.opt.Instance, |
| BlobDigests: blobspb, |
| DigestFunction: c.digestFn.Value(), |
| }) |
| c.m.OpsDone(err) |
| return err |
| }) |
| if status.Code(err) == codes.DeadlineExceeded { |
| // consider blobs are missing and try uploading. |
| // uploading may detect they already exist in CAS. |
| for _, b := range blobspb { |
| ret = append(ret, digest.FromProto(b)) |
| } |
| } else if err != nil { |
| return nil, fmt.Errorf("find missing: %w", err) |
| } else { |
| for _, b := range resp.GetMissingBlobDigests() { |
| ret = append(ret, digest.FromProto(b)) |
| } |
| } |
| blobspb = remain |
| } |
| return ret, nil |
| } |
| |
| // UploadAll uploads all blobs specified in ds that are still missing in the CAS. |
| func (c *Client) UploadAll(ctx context.Context, ds *blob.Store) (numUploaded int, err error) { |
| if c.casConn == nil { |
| return 0, status.Error(codes.FailedPrecondition, "conn is not configured") |
| } |
| |
| ctx, span := trace.NewSpan(ctx, "upload-all") |
| defer span.Close(nil) |
| blobs := ds.List() |
| span.SetAttr("blobs", len(blobs)) |
| |
| // First, partition the "blobs" list into three possible cases. |
| newBlobs := make(map[digest.Digest]*uploadOp) |
| pendingBlobs := make(map[digest.Digest]*uploadOp) |
| skippedBlobs := 0 |
| for _, d := range blobs { |
| uop, loaded := c.knownDigests.LoadOrStore(d, newUploadOp()) |
| if loaded { |
| switch v := uop.(type) { |
| case bool: |
| // Case 1: This blob is already present in the CAS, we're done. |
| skippedBlobs++ |
| case *uploadOp: |
| // Case 2: We need to wait for another thread to upload this blob. |
| pendingBlobs[d] = v |
| default: |
| panic(fmt.Sprintf("unknown type %T", v)) |
| } |
| continue |
| } |
| // Case 3: We need to upload this blob after confirming that it's missing. |
| newBlobs[d] = uop.(*uploadOp) |
| } |
| var foundBlobs map[digest.Digest]bool |
| var durFindMissing time.Duration |
| var durUpload time.Duration |
| var durWaitPending time.Duration |
| defer func() { |
| for d, uop := range newBlobs { |
| if uop.err == nil { |
| // uop.done(nil) was called |
| c.knownDigests.CompareAndSwap(d, uop, true) |
| continue |
| } |
| var s string |
| if data, ok := ds.Get(d); ok { |
| s = data.String() |
| } else { |
| s = d.String() |
| } |
| if errors.Is(uop.err, errUploadNotFinished) { |
| // uop.done(err) is not called |
| if err != nil { |
| uop.err = err |
| } |
| clog.Warningf(ctx, "upload %s not finished: %v", s, err) |
| close(uop.ch) |
| } else { |
| // uop.done(err) was called with non-nil err. |
| clog.Warningf(ctx, "upload %s failed: %v", s, uop.err) |
| } |
| // forget this digest, so next will try to upload again. |
| c.knownDigests.CompareAndDelete(d, uop) |
| } |
| clog.Infof(ctx, "upload all: blobs=%d -> {uploaded=%d, found=%d, pending=%d, skipped=%d}, timing: {find_missing=%s, upload=%s, wait_pending=%s}: %v", |
| len(blobs), len(newBlobs), len(foundBlobs), len(pendingBlobs), skippedBlobs, |
| durFindMissing.Round(time.Microsecond), |
| durUpload.Round(time.Microsecond), |
| durWaitPending.Round(time.Microsecond), |
| err) |
| }() |
| |
| span.SetAttr("upload", len(newBlobs)) |
| span.SetAttr("pending", len(pendingBlobs)) |
| span.SetAttr("skipped", skippedBlobs) |
| |
| // For all "new" blobs, use FindMissingBlobs to ask the remote CAS which of them are |
| // really still missing - they might already be present and we just don't know about it yet. |
| var missingBlobs []digest.Digest |
| if len(newBlobs) > 0 { |
| t := time.Now() |
| newBlobDigests := make([]digest.Digest, 0, len(newBlobs)) |
| foundBlobs = make(map[digest.Digest]bool, len(newBlobs)) |
| for d := range newBlobs { |
| newBlobDigests = append(newBlobDigests, d) |
| foundBlobs[d] = true |
| } |
| missingBlobs, err = c.Missing(ctx, newBlobDigests) |
| if err != nil { |
| for _, v := range newBlobs { |
| v.done(err) |
| } |
| return 0, err |
| } |
| // All "new" blobs that the remote CAS did *not* report as missing are by inverse confirmed |
| // to be present, so we can memoize this in the knownDigests map and notify any other |
| // threads waiting for them. |
| for _, d := range missingBlobs { |
| delete(foundBlobs, d) |
| } |
| for d := range foundBlobs { |
| c.knownDigests.CompareAndSwap(d, newBlobs[d], true) |
| newBlobs[d].done(nil) |
| delete(newBlobs, d) |
| } |
| span.SetAttr("missing", len(missingBlobs)) |
| span.SetAttr("founds", len(foundBlobs)) |
| durFindMissing = time.Since(t) |
| } |
| |
| // Let's upload the blobs that we know are still missing. |
| if len(missingBlobs) > 0 { |
| t := time.Now() |
| numUploaded, err = c.upload(ctx, ds, missingBlobs, newBlobs) |
| if err != nil { |
| return numUploaded, fmt.Errorf("upload: %w", err) |
| } |
| span.SetAttr("uploaded", numUploaded) |
| durUpload = time.Since(t) |
| } |
| |
| // Finally, wait for any blobs that are being uploaded by other threads, before we return. |
| if len(pendingBlobs) > 0 { |
| t := time.Now() |
| for d, uop := range pendingBlobs { |
| if err := uop.wait(ctx); err != nil { |
| return numUploaded, fmt.Errorf("wait for digest=%s: %w", d, err) |
| } |
| } |
| durWaitPending = time.Since(t) |
| } |
| |
| return numUploaded, err |
| } |
| |
| // CheckWritable checks reapi instance writable permission. |
| func (c *Client) CheckWritable(ctx context.Context) error { |
| defer trace.Begin(ctx, "reapi.CheckWritable").End() |
| if c.casConn == nil { |
| return status.Error(codes.FailedPrecondition, "conn is not configured") |
| } |
| data := blob.FromBytes(c.digestFn, "empty", nil) |
| ds := blob.NewStore() |
| ds.Set(data) |
| blobs := []digest.Digest{data.Digest()} |
| uploads := map[digest.Digest]*uploadOp{ |
| data.Digest(): newUploadOp(), |
| } |
| _, err := c.upload(ctx, ds, blobs, uploads) |
| if err != nil { |
| return fmt.Errorf("failed to check writable: %w", err) |
| } |
| return nil |
| } |
| |
| var errBlobNotInReq = errors.New("blob not in request") |
| |
| type missingBlob struct { |
| Digest digest.Digest |
| Err error |
| } |
| |
| type missingBlobs struct { |
| mu sync.Mutex |
| blobs []missingBlob |
| } |
| |
| func (m *missingBlobs) append(mb missingBlob) { |
| m.mu.Lock() |
| defer m.mu.Unlock() |
| m.blobs = append(m.blobs, mb) |
| } |
| |
| func (m *missingBlobs) Size() int { |
| m.mu.Lock() |
| defer m.mu.Unlock() |
| return len(m.blobs) |
| } |
| |
| func (m *missingBlobs) get() []missingBlob { |
| m.mu.Lock() |
| defer m.mu.Unlock() |
| return m.blobs |
| } |
| |
| type missingError struct { |
| Blobs []missingBlob |
| } |
| |
| func (e missingError) Error() string { |
| return fmt.Sprintf("missing %d blobs", len(e.Blobs)) |
| } |
| |
| // upload uploads blobs in digest stores. |
| func (c *Client) upload(ctx context.Context, ds *blob.Store, blobs []digest.Digest, uploads map[digest.Digest]*uploadOp) (int, error) { |
| ctx, span := trace.NewSpan(ctx, "upload") |
| defer span.Close(nil) |
| |
| byteLimit := int64(defaultBatchUpdateByteLimit) |
| c.mu.Lock() |
| if max := c.capabilities.GetCacheCapabilities().GetMaxBatchTotalSizeBytes(); max > 0 { |
| byteLimit = max |
| } |
| c.mu.Unlock() |
| |
| // Separate small blobs and large blobs because they are going to use different RPCs. |
| smalls, larges := separateBlobs(c.opt.Instance, c.digestFn, blobs, byteLimit) |
| clog.Infof(ctx, "upload by batch %d out of %d", len(smalls), len(blobs)) |
| span.SetAttr("small", len(smalls)) |
| span.SetAttr("large", len(larges)) |
| |
| // Upload small blobs with BatchUpdateBlobs rpc. |
| var missing missingError |
| if len(smalls) > 0 { |
| missingBlobs, err := c.uploadWithBatchUpdateBlobs(ctx, smalls, uploads, ds, byteLimit) |
| if err != nil { |
| return 0, fmt.Errorf("upload batch: %w", err) |
| } |
| missing.Blobs = missingBlobs |
| } |
| |
| // Upload large blobs with ByteStream API. |
| if len(larges) > 0 { |
| missingBlobs := c.uploadWithByteStream(ctx, larges, uploads, ds) |
| missing.Blobs = append(missing.Blobs, missingBlobs...) |
| } |
| |
| if len(missing.Blobs) > 0 { |
| return len(blobs) - len(missing.Blobs), missing |
| } |
| return len(blobs), nil |
| } |
| |
| // separateBlobs separates blobs to two groups. |
| // One group is for small blobs that can fit in BatchUpdateBlobsRequest, and the other is for large blobs. |
| // TODO(b/273884978): simplify and optimize the code. |
| func separateBlobs(instance string, fn digest.Function, blobs []digest.Digest, byteLimit int64) (smalls, larges []digest.Digest) { |
| if len(blobs) == 0 { |
| return nil, nil |
| } |
| sort.Slice(blobs, func(i, j int) bool { |
| return blobs[i].SizeBytes < blobs[j].SizeBytes |
| }) |
| maxSizeBytes := min(blobs[len(blobs)-1].SizeBytes, byteLimit) |
| // Prepare a dummy request message to calculate the size of the BatchUpdateBlobsRequest accurately. |
| dummyReq := &rpb.BatchUpdateBlobsRequest{ |
| InstanceName: instance, |
| DigestFunction: fn.Value(), |
| Requests: []*rpb.BatchUpdateBlobsRequest_Request{ |
| {Data: make([]byte, 0, maxSizeBytes)}, |
| }, |
| } |
| i := sort.Search(len(blobs), func(i int) bool { |
| if blobs[i].SizeBytes >= byteLimit { |
| return true |
| } |
| // When the BatchUpdateBlobsRequest with the single blob already exceeds the size limit. |
| // It can't send the blob via BatchUpdateBlobsRequest. |
| dummyReq.Requests[0].Digest = blobs[i].Proto() |
| dummyReq.Requests[0].Data = dummyReq.Requests[0].Data[:blobs[i].SizeBytes] |
| return int64(proto.Size(dummyReq)) >= byteLimit |
| }) |
| if i < len(blobs) { |
| return blobs[:i], blobs[i:] |
| } |
| return blobs, nil |
| } |
| |
| // uploadWithBatchUpdateBlobs uploads blobs using BatchUpdateBlobs RPC. |
| // The blobs will be bundled into multiple batches that fit in the size limit. |
| func (c *Client) uploadWithBatchUpdateBlobs(ctx context.Context, digests []digest.Digest, uploads map[digest.Digest]*uploadOp, ds *blob.Store, byteLimit int64) ([]missingBlob, error) { |
| blobReqs, missingBlobs := c.blobsToUpload(ctx, digests, ds, byteLimit) |
| |
| // Bundle the blobs to multiple batch requests. |
| batchReqs := createBatchUpdateBlobsRequests(c.opt.Instance, c.digestFn, blobReqs, byteLimit, batchBlobUploadLimit) |
| casClient := rpb.NewContentAddressableStorageClient(c.casConn) |
| |
| eg, gctx := errgroup.WithContext(ctx) |
| eg.SetLimit(c.uploadConcurrency()) |
| for batchReq := range batchReqs { |
| eg.Go(func() error { |
| return c.processBatchUpdateBlobsReq(gctx, casClient, batchReq, uploads, ds, missingBlobs) |
| }) |
| } |
| if err := eg.Wait(); err != nil { |
| return nil, err |
| } |
| return missingBlobs.get(), nil |
| } |
| |
| // processBatchUpdateBlobsReq sends one BatchUpdateBlobs RPC and reconciles the response. |
| func (c *Client) processBatchUpdateBlobsReq(ctx context.Context, casClient rpb.ContentAddressableStorageClient, batchReq *rpb.BatchUpdateBlobsRequest, uploads map[digest.Digest]*uploadOp, ds *blob.Store, missingBlobs *missingBlobs) error { |
| var batchResp *rpb.BatchUpdateBlobsResponse |
| checkBlobs := make(map[digest.Digest]bool) |
| for _, req := range batchReq.Requests { |
| checkBlobs[digest.FromProto(req.Digest)] = true |
| } |
| // TODO(b/328332495): grpc should retry by service config? |
| err := retry.Do(ctx, func() error { |
| var err error |
| batchResp, err = casClient.BatchUpdateBlobs(ctx, batchReq) |
| return err |
| }) |
| if err != nil { |
| c.m.WriteDone(0, err) |
| return status.Errorf(status.Code(err), "batch update blobs: %v", err) |
| } |
| |
| for _, res := range batchResp.Responses { |
| blob := digest.FromProto(res.Digest) |
| delete(checkBlobs, blob) |
| data, ok := ds.Get(blob) |
| if !ok { |
| clog.Warningf(ctx, "Not found %s in store", blob) |
| missingBlobs.append(missingBlob{ |
| Digest: blob, |
| Err: errBlobNotInReq, |
| }) |
| continue |
| } |
| st := status.FromProto(res.GetStatus()) |
| if st.Code() != codes.OK { |
| clog.Warningf(ctx, "Failed to batch-update %s: %v", data, st) |
| err := status.Errorf(st.Code(), "batch update blobs: %v", res.Status) |
| missingBlobs.append(missingBlob{ |
| Digest: blob, |
| Err: err, |
| }) |
| c.m.WriteDone(int(res.Digest.SizeBytes), err) |
| uploads[blob].done(err) |
| continue |
| } |
| clog.Infof(ctx, "uploaded in batch: %s", data) |
| c.m.WriteDone(int(res.Digest.SizeBytes), nil) |
| uploads[blob].done(nil) |
| } |
| clog.Infof(ctx, "upload by batch %d->%d blobs (noresp:%d) (missing:%d)", len(batchReq.Requests), len(batchResp.Responses), len(checkBlobs), missingBlobs.Size()) |
| if len(checkBlobs) == 0 { |
| return nil |
| } |
| // check again if digest is missing in batchResp. |
| checks := slices.Collect(maps.Keys(checkBlobs)) |
| clog.Warningf(ctx, "batch no response for %s", checks) |
| missings, err := c.Missing(ctx, checks) |
| if err != nil { |
| clog.Warningf(ctx, "recheck missing %s: %v", checks, err) |
| for _, d := range checks { |
| uploads[d].done(err) |
| missingBlobs.append(missingBlob{ |
| Digest: d, |
| Err: err, |
| }) |
| } |
| return nil |
| } |
| for _, d := range missings { |
| clog.Warningf(ctx, "recheck missing %s: not uploaded", d) |
| uploads[d].done(errUploadNoResponse) |
| missingBlobs.append(missingBlob{ |
| Digest: d, |
| Err: errUploadNoResponse, |
| }) |
| delete(checkBlobs, d) |
| } |
| // checkBlobs has blobs that are not reported in BatchUpdateBlobsResponse, |
| // but not reported as missing by FindMissing, so we can believe them |
| // exists in CAS. |
| for d := range checkBlobs { |
| clog.Warningf(ctx, "recheck missing %s: exists", d) |
| uploads[d].done(nil) |
| } |
| return nil |
| } |
| |
| // blobsToUpload returns a list of blobs to upload by looking up the digest store. |
| func (c *Client) blobsToUpload(ctx context.Context, blobs []digest.Digest, ds *blob.Store, byteLimit int64) (iter.Seq[*rpb.BatchUpdateBlobsRequest_Request], *missingBlobs) { |
| var missings missingBlobs |
| ch := make(chan *rpb.BatchUpdateBlobsRequest_Request) |
| |
| go func() { |
| defer close(ch) |
| // allocate for at most 2 BatchUpdateBlobsRequest messages. |
| sema := xsemaphore.NewWeighted(byteLimit * 2) |
| var wg sync.WaitGroup |
| for _, blob := range blobs { |
| err := sema.Acquire(ctx, blob.SizeBytes) |
| if err != nil { |
| missings.append(missingBlob{ |
| Digest: blob, |
| Err: err, |
| }) |
| clog.Warningf(ctx, "failed to acquire %d: %v", blob.SizeBytes, err) |
| continue |
| } |
| wg.Go(func() { |
| defer sema.Release(blob.SizeBytes) |
| data, ok := ds.Get(blob) |
| if !ok { |
| missings.append(missingBlob{ |
| Digest: blob, |
| Err: errBlobNotInReq, |
| }) |
| clog.Warningf(ctx, "missing %s to upload: %v", blob, errBlobNotInReq) |
| return |
| } |
| var req *rpb.BatchUpdateBlobsRequest_Request |
| err := FileSemaphore.Do(ctx, func(ctx context.Context) error { |
| var err error |
| req, err = c.encodeForBatchUpload(ctx, data) |
| return err |
| }) |
| if err != nil { |
| missings.append(missingBlob{ |
| Digest: blob, |
| Err: err, |
| }) |
| clog.Warningf(ctx, "read %s to upload: %v", blob, err) |
| return |
| } |
| ch <- req |
| }) |
| } |
| wg.Wait() |
| }() |
| return func(yield func(*rpb.BatchUpdateBlobsRequest_Request) bool) { |
| for req := range ch { |
| if !yield(req) { |
| return |
| } |
| } |
| }, &missings |
| } |
| |
| // encodeForBatchUpload reads blob data and optionally compresses it for batch upload. |
| func (c *Client) encodeForBatchUpload(ctx context.Context, data blob.Data) (*rpb.BatchUpdateBlobsRequest_Request, error) { |
| d := data.Digest() |
| compressor := c.compressorForBatchUpdate(d) |
| b, err := blob.DataToBytes(ctx, data) |
| if err != nil { |
| return nil, err |
| } |
| if compressor == rpb.Compressor_IDENTITY { |
| return &rpb.BatchUpdateBlobsRequest_Request{ |
| Digest: d.Proto(), |
| Data: b, |
| }, nil |
| } |
| var buf bytes.Buffer |
| w, err := newEncoderForCompressor(&buf, compressor) |
| if err != nil { |
| return nil, err |
| } |
| if _, err := w.Write(b); err != nil { |
| w.Close() |
| return nil, err |
| } |
| if err := w.Close(); err != nil { |
| return nil, err |
| } |
| return &rpb.BatchUpdateBlobsRequest_Request{ |
| Digest: d.Proto(), |
| Data: buf.Bytes(), |
| Compressor: compressor, |
| }, nil |
| } |
| |
| // createBatchUpdateBlobsRequests bundles blobs into multiple batch requests. |
| func createBatchUpdateBlobsRequests(instance string, fn digest.Function, blobReqs iter.Seq[*rpb.BatchUpdateBlobsRequest_Request], byteLimit int64, numLimit int) iter.Seq[*rpb.BatchUpdateBlobsRequest] { |
| return func(yield func(*rpb.BatchUpdateBlobsRequest) bool) { |
| // Initial batch request size without blobs: the instance name plus |
| // the digest_function field, which every emitted request carries. |
| batchReqNoReqsSize := int64(proto.Size(&rpb.BatchUpdateBlobsRequest{ |
| InstanceName: instance, |
| DigestFunction: fn.Value(), |
| })) |
| size := batchReqNoReqsSize |
| // totalSizeBytes tracks the sum of uncompressed Digest.SizeBytes |
| // in the current batch. Some backends interpret |
| // MaxBatchTotalSizeBytes as this sum rather than the proto size, |
| // so both constraints must be satisfied. |
| var totalSizeBytes int64 |
| var reqs []*rpb.BatchUpdateBlobsRequest_Request |
| for req := range blobReqs { |
| reqs = append(reqs, req) |
| nextSize := size + int64(proto.Size(&rpb.BatchUpdateBlobsRequest{Requests: reqs[len(reqs)-1:]})) |
| nextTotalSizeBytes := totalSizeBytes + req.Digest.SizeBytes |
| switch { |
| case byteLimit > 0 && (nextSize > byteLimit || nextTotalSizeBytes > byteLimit): |
| // When the batch request exceeds the size limit, it starts creating a new batch request. |
| if !yield(&rpb.BatchUpdateBlobsRequest{ |
| InstanceName: instance, |
| Requests: reqs[:len(reqs)-1], |
| DigestFunction: fn.Value(), |
| }) { |
| return |
| } |
| size = batchReqNoReqsSize + nextSize - size |
| totalSizeBytes = req.Digest.SizeBytes |
| reqs = []*rpb.BatchUpdateBlobsRequest_Request{req} |
| |
| case len(reqs) == numLimit: |
| // When the batch request exceeds the number of blobs. it start creating a new batch request. |
| if !yield(&rpb.BatchUpdateBlobsRequest{ |
| InstanceName: instance, |
| Requests: reqs, |
| DigestFunction: fn.Value(), |
| }) { |
| return |
| } |
| size = batchReqNoReqsSize |
| totalSizeBytes = 0 |
| reqs = nil |
| default: |
| size = nextSize |
| totalSizeBytes = nextTotalSizeBytes |
| } |
| } |
| if len(reqs) > 0 { |
| yield(&rpb.BatchUpdateBlobsRequest{ |
| InstanceName: instance, |
| Requests: reqs, |
| DigestFunction: fn.Value(), |
| }) |
| } |
| } |
| } |
| |
| func (c *Client) uploadWithByteStream(ctx context.Context, digests []digest.Digest, uploads map[digest.Digest]*uploadOp, ds *blob.Store) []missingBlob { |
| clog.Infof(ctx, "upload by streaming %d", len(digests)) |
| |
| bsClient := bpb.NewByteStreamClient(c.casDataConn) |
| var ( |
| mu sync.Mutex |
| missing []missingBlob |
| ) |
| addMissing := func(mb missingBlob) { |
| mu.Lock() |
| missing = append(missing, mb) |
| mu.Unlock() |
| } |
| |
| eg, gctx := errgroup.WithContext(ctx) |
| eg.SetLimit(c.uploadConcurrency()) |
| for _, d := range digests { |
| eg.Go(func() error { |
| c.streamOneBlob(gctx, bsClient, d, uploads, ds, addMissing) |
| return nil |
| }) |
| } |
| _ = eg.Wait() |
| clog.Infof(ctx, "uploaded by streaming %d blobs (missing:%d)", len(digests), len(missing)) |
| return missing |
| } |
| |
| // streamOneBlob uploads one blob via ByteStream; errors go to addMissing, not propagated. |
| func (c *Client) streamOneBlob(ctx context.Context, bsClient bpb.ByteStreamClient, d digest.Digest, uploads map[digest.Digest]*uploadOp, ds *blob.Store, addMissing func(missingBlob)) { |
| started := time.Now() |
| data, ok := ds.Get(d) |
| if !ok { |
| clog.Warningf(ctx, "Not found %s in store", d) |
| addMissing(missingBlob{Digest: d, Err: errBlobNotInReq}) |
| return |
| } |
| err := retry.Do(ctx, func() error { |
| ctx, cancel := fetch.ContextWithTimeout(ctx, d) |
| defer cancel() |
| rd, err := data.Open(ctx) |
| if err != nil { |
| return err |
| } |
| defer rd.Close() |
| if log.V(1) { |
| clog.Infof(ctx, "put %s", c.uploadResourceName(d)) |
| } |
| wr, err := bytestreamio.Create(ctx, bsClient, c.uploadResourceName(d), data.String()) |
| if err != nil { |
| return err |
| } |
| cwr, err := c.newEncoder(wr, d) |
| if err != nil { |
| wr.Close() |
| return err |
| } |
| _, err = io.Copy(cwr, rd) |
| if err != nil { |
| cwr.Close() |
| wr.Close() |
| return err |
| } |
| err = cwr.Close() |
| if err != nil { |
| wr.Close() |
| return err |
| } |
| err = wr.Close() |
| if err != nil { |
| // Some REAPI backends may return non-standard committed size, |
| // or error in CloseAndRecv. Check via FindMissingBlobs whether |
| // the blob is already uploaded or not. |
| ds, merr := c.Missing(ctx, []digest.Digest{d}) |
| if merr == nil && len(ds) == 0 { |
| clog.Infof(ctx, "bytestreamio.Create: %v -> %v exists in CAS", err, d) |
| return nil |
| } |
| clog.Warningf(ctx, "bytestreamio.Create: %v -> missing %v, %v", err, ds, merr) |
| return status.Errorf(codes.Internal, "%v", err) |
| } |
| return nil |
| }) |
| c.m.WriteDone(int(d.SizeBytes), err) |
| uploads[d].done(err) |
| if err != nil { |
| clog.Warningf(ctx, "Failed to stream %s in %s: %v", data, time.Since(started), err) |
| addMissing(missingBlob{Digest: d, Err: err}) |
| return |
| } |
| clog.Infof(ctx, "uploaded streaming %s in %s", data, time.Since(started)) |
| } |
| |
| // resourceName constructs a resource name for uploading the blob identified by the digest. |
| // For uncompressed blob. the format is |
| // |
| // `{instance_name}/uploads/{uuid}/blobs/{digest_function/}{hash}/{size}` |
| // |
| // # For compressed blob, the format is |
| // |
| // `{instance_name}/uploads/{uuid}/compressed-blobs/{compressor}/{digest_function/}{uncompressed_hash}/{uncompressed_size}` |
| // |
| // The digest_function segment is omitted for sha256 (per spec); path.Join drops |
| // empty segments. |
| // |
| // See also the API document. |
| // https://github.com/bazelbuild/remote-apis/blob/64cc5e9e422c93e1d7f0545a146fd84fcc0e8b47/build/bazel/remote/execution/v2/remote_execution.proto#L211-L239 |
| func (c *Client) uploadResourceName(d digest.Digest) string { |
| if compressor := c.compressor(d); compressor != rpb.Compressor_IDENTITY { |
| return path.Join(c.opt.Instance, "uploads", uuid.New().String(), |
| "compressed-blobs", |
| strings.ToLower(compressor.String()), |
| c.digestFunctionSegment(), |
| d.Hash, |
| strconv.FormatInt(d.SizeBytes, 10)) |
| } |
| return path.Join(c.opt.Instance, "uploads", uuid.New().String(), "blobs", c.digestFunctionSegment(), d.Hash, strconv.FormatInt(d.SizeBytes, 10)) |
| } |
| |
| // FileURI returns bytestream URI for digest. |
| func (c *Client) FileURI(d digest.Digest) string { |
| // compressed-blobs is not supported? |
| return fmt.Sprintf("bytestream://%s/%s", c.opt.Address, path.Join(c.opt.Instance, "blobs", c.digestFunctionSegment(), d.Hash, strconv.FormatInt(d.SizeBytes, 10))) |
| } |
| |
| // newEncoder returns an encoder to compress blob. |
| // For uncompressed blob, it returns a nop closer. |
| func (c *Client) newEncoder(w io.Writer, d digest.Digest) (io.WriteCloser, error) { |
| return newEncoderForCompressor(w, c.compressor(d)) |
| } |
| |
| // newEncoderForCompressor returns an encoder for the given compressor value. |
| func newEncoderForCompressor(w io.Writer, compressor rpb.Compressor_Value) (io.WriteCloser, error) { |
| switch compressor { |
| case rpb.Compressor_IDENTITY: |
| return nopWriteCloser{w}, nil |
| case rpb.Compressor_ZSTD: |
| return zstd.NewWriter(w) |
| case rpb.Compressor_DEFLATE: |
| return flate.NewWriter(w, flate.DefaultCompression) |
| default: |
| return nil, fmt.Errorf("unsupported compressor %q", compressor) |
| } |
| } |
| |
| type nopWriteCloser struct { |
| io.Writer |
| } |
| |
| func (nopWriteCloser) Close() error { return nil } |