Support streaming split/splice APIs for large blobs - #377
Conversation
f2161ab to
e8f806f
Compare
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
Adds streamed equivalents of CAS blob split/splice operations to avoid unary message size limits while exposing capability flags for feature discovery.
Changes:
- Document unary SplitBlob/SpliceBlob size limitations and recommend streaming alternatives.
- Add
SplitChunks(server-streaming) andSpliceChunks(client-streaming) RPCs plus request/response messages. - Extend
CacheCapabilitieswith support flags for the new streaming RPCs.
Files not reviewed (2)
- build/bazel/remote/execution/v2/remote_execution.pb.go: Language not supported
- build/bazel/remote/execution/v2/remote_execution_grpc.pb.go: Language not supported
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
I got added as a reviewer, so here's my take: just like how chunking has been added to the protocol in its current form, I'm not a fan of this. Not having streaming, but requiring a recursive construction of very large files using Prolly trees is in my opinion the way to go. That said, if people actually on the working group think that this is the way to go, go for it! :-) |
@EdSchouten Thanks, that makes sense. I agree that a recursive/Merkle-style representation such as Prolly trees is probably the better long-term model for representing extremely large blobs, since it bounds individual node sizes and avoids one giant flat chunk list. My goal with this PR is narrower: provide a transport-level alternative to #376 without making clients materialize or dereference a chunk-list object in CAS. Streaming lets clients send the existing ordered chunk mapping in bounded batches, and it lets servers begin verification as chunks become available instead of waiting until every chunk has been uploaded and then receiving the whole mapping in one unary I also see this as compatible with a future recursive representation. A server could still store the received mapping internally as a Prolly tree or some other recursive structure. The protocol question here is whether clients need to understand and construct that representation, or whether they can just stream the chunk references they already have. Longer term, I would prefer for the streaming APIs to become the primary split/splice shape for clients that support them, with the existing unary APIs remaining as the compatibility/simple-case path. For cases where the chunk list fits in one request, sending it as a single stream message should be performance-equivalent to a unary gRPC request in practice, while still leaving the same API able to scale when the list grows. A recursive representation also fits into that model: the stream does not require the server to store a flat list internally, it just gives the server live visibility into the composition progress. The server can choose to persist that mapping as a Prolly tree, a flat manifest, or another internal structure, while still getting the benefits of streaming transport: bounded message sizes, earlier validation, and the ability to pipeline verification while the client is still uploading or discovering chunks, since chunking is inherently sequential. Some recursive composition can be approximated with the current API today by splicing subranges and then splicing those intermediate blobs, but standardizing that as the preferred model seems like it would need more protocol work: tree node encoding, fanout rules, digest/validation semantics, and how |
|
@EdSchouten One open question I have for a Prolly tree design is how it preserves the existing full-blob digest semantics. In REAPI, the digest in an If the Prolly root digest is a digest of tree-node bytes, it is not a substitute for the file's content digest. If the key remains the full blob digest, the server still has to verify the leaves in order by hashing the reconstructed content, or trust a mapping it has already validated. And if a server only has chunks/tree nodes under one chunking strategy, it is not obvious how it can know that a different client-provided tree already represents the same blob without either having a stored full-digest-to-tree mapping or walking/rehashing the content. |
5af53b6 to
638b7e6
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 1 out of 3 changed files in this pull request and generated 2 comments.
Files not reviewed (2)
- build/bazel/remote/execution/v2/remote_execution.pb.go: Language not supported
- build/bazel/remote/execution/v2/remote_execution_grpc.pb.go: Language not supported
That is true for this specific RPC, but many other parts of the protocol already work this way.
I agree that it would probably be easier to adapt most servers and clients.
The speed improvement I can see is that splicing can finish faster after the last chunk has been uploaded. The work still has to be done, so it is a matter of cutting latency, instead of loading GB of data at the end, it can be loaded while the client is uploading the chunks. @tyler-french Do you see the latency as a problem in your current clusters? My biggest concern is that the servers may have to hold an infinite number of connections open, waiting for more chunks to arrive.
To address my concerns, I might implement the streaming splicing by waiting for the last message and then do the splicing anyway, effectively converting it into the chunk-list version.
The chunk-list approach also keeps the old API fully usable by older clients.
Either we have the feature of handling large blobs capability gated or we can put it as requirement on Split/Splice API from a certain version of the REv2 API. |
|
@moroten (and cc @meroton-benjamin) Thanks, these are good concerns. I am also happy to support #376 if that is where we land. I needed to add streaming support on our side anyway, so this felt like a good time to see whether it could also solve the large chunk-list problem. I think the main point is that streaming does not remove the splice work. It gives clients a bounded-message way to send the chunk list, and it lets servers start seeing the mapping earlier if they want to. A client does not have to keep the stream open while chunks are still uploading. It can upload the chunks first, then call
Yes. We have hit issues with blobs around 1 GB. Today the client uploads chunks with I agree that the work still has to be done. The benefit is mostly message sizing and tail latency. With a fast chunker, I would expect an incremental
That risk is real, but it is similar to Also, clients that want to avoid long idle splice streams can wait until all chunks are uploaded, then send the mapping in one or a few
I think this should follow the normal RPC deadline model. For Bazel, the call still needs to complete inside
This is the main tradeoff. If the stream fails before the server commits the splice, the client has to retry. After the chunks are uploaded, that retry is much cheaper than retrying the original upload. There is also a useful fallback. If the chunk list fits in one message, the client can retry with unary
Agreed. Keeping a []digests is pretty cheap. You could even just keep []offsets to save even more memory.
I got this feedback, and I think it makes sense the request requires
That seems like a valid implementation. It would still avoid the unary message size limit and let clients use one API shape for larger blobs.
Agreed. I did not mean that the chunk-list approach breaks older clients. The difference I see is that PR #376 adds another representation to the existing unary API shape. Newer clients then need logic to write, read, and interpret chunk-list objects. With streaming, the old unary APIs stay as the simple path, and the large-blob path is a separate capability-gated RPC. A client that is not aware of indirection may fail if the SplitBlob response sets it. |
|
@tyler-french I would be happy with the streaming approach as well. Both approaches have their pros and cons, but the main problem is that the whole API refers to blobs as whole files, not a Prolly tree. Maybe set the decision next monthly? I'll add some code comments as well. |
638b7e6 to
d06fafd
Compare
d5f84bb to
3b44d1c
Compare
How does the client know that it does need to upload the blob at all? I thought My original though was to first run Does calling many
Well, that depends on the implementation of your server. Some servers might want to shard out the splicing process depending on the final With the |
2f5bab3 to
d6cb9ee
Compare
|
@moroten good points. I moved |
There was a problem hiding this comment.
Having implemented a client/server pair with this RPC I think we need to add guidance towards how this should be used to to upload a large blob. The naïve implementation, where you calculate a chunk, check if it exists, uploads it if it doesn't exist and sends a message with the chunk to the streaming rpc is incredibly inefficient and splicing is very intensive server side.
In essence a well behaved client should do the following for a large blob:
Step 1:
- Calculate the blob digest and determine if the blob exists or not
Step 2:
- Calculate a N of chunks of the blob where N is as many as can be comfortably held by the client, if calculating from a situation where you can do random access (e.g. when uploading a blob from disk) this may be something like 1MB worth of digests.
- Determine which if any of the chunks are missing from the CAS with FMB
- Upload those missing chunks
- Repeat until all chunks exist in CAS
Step 3:
- From the chunk list calculated earlier split into safe message sizes (e.g. below 1MB) and stream the splice chunks call.
While the api formally will work for clients which don't follow the above recipe, in practice server implementations will struggle to be able to serve it. A naïve implementation from above would from a very modest 1GB blob of random data create the equivalent of 10000 unary rpc calls.
| // chunks. | ||
| rpc SplitBlob(SplitBlobRequest) returns (SplitBlobResponse) { | ||
| option (google.api.http) = { get: "/v2/{instance_name=**}/blobs/{blob_digest.hash}/{blob_digest.size_bytes}:splitBlob" }; | ||
| rpc SplitChunks(SplitChunksRequest) returns (stream SplitChunksResponse) { |
There was a problem hiding this comment.
Could we rename this into SplitIntoChunks as SplitChunks doesn't really make any sense.
There was a problem hiding this comment.
On this note: everyone I've discussed these RPCs with gets confused about the directionality of split/splice.
Maybe we could be more clear with something like PutSplitBlob/GetSplitBlob so directionality is clearer. Even if we want to be pedantic, RegisterSplitBlob/RetrieveSplitBlob are much clearer.
There was a problem hiding this comment.
We could even go as simple as Get and Set 🤔
The gRPC service-config support from #29912 is now on master. It lets users lengthen the whole-call deadline for `ByteStream.Read` so large downloads can keep running while they make progress. A longer deadline also lets a stalled stream wait just as long, because a total RPC deadline cannot distinguish progress from inactivity. This adds `--remote_grpc_download_idle_timeout` as an independent inactivity limit for `ByteStream.Read`. It defaults to 60 seconds to match the existing HTTP remote-cache default; setting it to `0` disables it. When a read goes idle, Bazel cancels it and translates the timeout-owned `CANCELLED` closure to `DEADLINE_EXCEEDED` so the normal cache retrier can resume from the received offset. Other RPCs, including `ByteStream.Write`, `Execute`, and `WaitExecution`, are unaffected. Timeout tracking uses a monotonic deadline and at most one scheduled task per call. This avoids retaining a canceled task for every response and prevents a timer from canceling a read that has since made progress. Normal gRPC forwarding stays in `finally` blocks so timeout bookkeeping failures cannot suppress messages or callbacks. Tests cover: - the 60-second option default and exclusion of `ByteStream.Write`; - cancellation status and single-task scheduling as reads make progress; - request and response forwarding when timeout bookkeeping fails; and - an in-process `ByteStream` flow that sends a prefix, stalls, fires the timeout deterministically, and verifies that the retry resumes from the next offset. The interceptor currently applies only to `ByteStream.Read`. The download-specific option name leaves room to cover future server-streaming download RPCs such as [`SplitChunks`](bazelbuild/remote-apis#377) without implying that every remote stream should share this timeout. Closes #29916. PiperOrigin-RevId: 956359263 Change-Id: Id8ae7c6dbb172f1e7e799536193458d9cbfb8c0c
The gRPC service-config support from bazelbuild#29912 is now on master. It lets users lengthen the whole-call deadline for `ByteStream.Read` so large downloads can keep running while they make progress. A longer deadline also lets a stalled stream wait just as long, because a total RPC deadline cannot distinguish progress from inactivity. This adds `--remote_grpc_download_idle_timeout` as an independent inactivity limit for `ByteStream.Read`. It defaults to 60 seconds to match the existing HTTP remote-cache default; setting it to `0` disables it. When a read goes idle, Bazel cancels it and translates the timeout-owned `CANCELLED` closure to `DEADLINE_EXCEEDED` so the normal cache retrier can resume from the received offset. Other RPCs, including `ByteStream.Write`, `Execute`, and `WaitExecution`, are unaffected. Timeout tracking uses a monotonic deadline and at most one scheduled task per call. This avoids retaining a canceled task for every response and prevents a timer from canceling a read that has since made progress. Normal gRPC forwarding stays in `finally` blocks so timeout bookkeeping failures cannot suppress messages or callbacks. Tests cover: - the 60-second option default and exclusion of `ByteStream.Write`; - cancellation status and single-task scheduling as reads make progress; - request and response forwarding when timeout bookkeeping fails; and - an in-process `ByteStream` flow that sends a prefix, stalls, fires the timeout deterministically, and verifies that the retry resumes from the next offset. The interceptor currently applies only to `ByteStream.Read`. The download-specific option name leaves room to cover future server-streaming download RPCs such as [`SplitChunks`](bazelbuild/remote-apis#377) without implying that every remote stream should share this timeout. Closes bazelbuild#29916. PiperOrigin-RevId: 956359263 Change-Id: Id8ae7c6dbb172f1e7e799536193458d9cbfb8c0c (cherry picked from commit 6e27dd1)
The gRPC service-config support from bazelbuild#29912 is now on master. It lets users lengthen the whole-call deadline for `ByteStream.Read` so large downloads can keep running while they make progress. A longer deadline also lets a stalled stream wait just as long, because a total RPC deadline cannot distinguish progress from inactivity. This adds `--remote_grpc_download_idle_timeout` as an independent inactivity limit for `ByteStream.Read`. It defaults to 60 seconds to match the existing HTTP remote-cache default; setting it to `0` disables it. When a read goes idle, Bazel cancels it and translates the timeout-owned `CANCELLED` closure to `DEADLINE_EXCEEDED` so the normal cache retrier can resume from the received offset. Other RPCs, including `ByteStream.Write`, `Execute`, and `WaitExecution`, are unaffected. Timeout tracking uses a monotonic deadline and at most one scheduled task per call. This avoids retaining a canceled task for every response and prevents a timer from canceling a read that has since made progress. Normal gRPC forwarding stays in `finally` blocks so timeout bookkeeping failures cannot suppress messages or callbacks. Tests cover: - the 60-second option default and exclusion of `ByteStream.Write`; - cancellation status and single-task scheduling as reads make progress; - request and response forwarding when timeout bookkeeping fails; and - an in-process `ByteStream` flow that sends a prefix, stalls, fires the timeout deterministically, and verifies that the retry resumes from the next offset. The interceptor currently applies only to `ByteStream.Read`. The download-specific option name leaves room to cover future server-streaming download RPCs such as [`SplitChunks`](bazelbuild/remote-apis#377) without implying that every remote stream should share this timeout. Closes bazelbuild#29916. PiperOrigin-RevId: 956359263 Change-Id: Id8ae7c6dbb172f1e7e799536193458d9cbfb8c0c (cherry picked from commit 6e27dd1)
The gRPC service-config support from bazelbuild#29912 is now on master. It lets users lengthen the whole-call deadline for `ByteStream.Read` so large downloads can keep running while they make progress. A longer deadline also lets a stalled stream wait just as long, because a total RPC deadline cannot distinguish progress from inactivity. This adds `--remote_grpc_download_idle_timeout` as an independent inactivity limit for `ByteStream.Read`. It defaults to 60 seconds to match the existing HTTP remote-cache default; setting it to `0` disables it. When a read goes idle, Bazel cancels it and translates the timeout-owned `CANCELLED` closure to `DEADLINE_EXCEEDED` so the normal cache retrier can resume from the received offset. Other RPCs, including `ByteStream.Write`, `Execute`, and `WaitExecution`, are unaffected. Timeout tracking uses a monotonic deadline and at most one scheduled task per call. This avoids retaining a canceled task for every response and prevents a timer from canceling a read that has since made progress. Normal gRPC forwarding stays in `finally` blocks so timeout bookkeeping failures cannot suppress messages or callbacks. Tests cover: - the 60-second option default and exclusion of `ByteStream.Write`; - cancellation status and single-task scheduling as reads make progress; - request and response forwarding when timeout bookkeeping fails; and - an in-process `ByteStream` flow that sends a prefix, stalls, fires the timeout deterministically, and verifies that the retry resumes from the next offset. The interceptor currently applies only to `ByteStream.Read`. The download-specific option name leaves room to cover future server-streaming download RPCs such as [`SplitChunks`](bazelbuild/remote-apis#377) without implying that every remote stream should share this timeout. Closes bazelbuild#29916. PiperOrigin-RevId: 956359263 Change-Id: Id8ae7c6dbb172f1e7e799536193458d9cbfb8c0c (cherry picked from commit 6e27dd1)
d6cb9ee to
4c963f1
Compare
68d161f to
258267a
Compare
|
@armandomontanez @meroton-benjamin per-request, I renamed the APIs: @tjgq @fmeum @sluongng if you all also have time to take another look! |
| // Servers MUST use the value from the first request and ignore values sent on | ||
| // subsequent requests. | ||
| // | ||
| // If the digest function used is one of MD5, MURMUR3, SHA1, SHA256, SHA384, |
There was a problem hiding this comment.
Symmetry is an argument for this, but perhaps we shouldn't carry these defaults forward to new APIs?
There was a problem hiding this comment.
We can do that, but I'd like that to be separate from this PR. If we want to still do it, we can make this change before a release, and focus the discussion there. This API is flexible until we publish a release
There was a problem hiding this comment.
Indeed; this sentence should not be added to new RPCs. Also notice how the exact list of digest functions differs per RPC/field. Take ExecuteRequest:
// If the digest function used is one of MD5, MURMUR3, SHA1, SHA256,
// SHA384, SHA512, or VSO, the client MAY leave this field unset.
Now look at ExecuteOperationMetadata:
// If the digest function used is one of BLAKE3, MD5, MURMUR3, SHA1,
// SHA256, SHA256TREE, SHA384, SHA512, or VSO, the server MAY leave
// this field unset.
We only list digest functions here that could be used in combination with those messages whose use predates the introduction of the digest_function field. Because BLAKE3 was added AFTER ExecuteRequest.digest_function was added, it's never valid to use BLAKE3 and leave ExecuteRequest.digest_function unset.
So by copying the phrasing from just one of these places you're doing something totally arbitrary. For a new RPC this should simply not be added. Symmetry is not a valid argument.
The gRPC service-config support from bazelbuild#29912 is now on master. It lets users lengthen the whole-call deadline for `ByteStream.Read` so large downloads can keep running while they make progress. A longer deadline also lets a stalled stream wait just as long, because a total RPC deadline cannot distinguish progress from inactivity. This adds `--remote_grpc_download_idle_timeout` as an independent inactivity limit for `ByteStream.Read`. It defaults to 60 seconds to match the existing HTTP remote-cache default; setting it to `0` disables it. When a read goes idle, Bazel cancels it and translates the timeout-owned `CANCELLED` closure to `DEADLINE_EXCEEDED` so the normal cache retrier can resume from the received offset. Other RPCs, including `ByteStream.Write`, `Execute`, and `WaitExecution`, are unaffected. Timeout tracking uses a monotonic deadline and at most one scheduled task per call. This avoids retaining a canceled task for every response and prevents a timer from canceling a read that has since made progress. Normal gRPC forwarding stays in `finally` blocks so timeout bookkeeping failures cannot suppress messages or callbacks. Tests cover: - the 60-second option default and exclusion of `ByteStream.Write`; - cancellation status and single-task scheduling as reads make progress; - request and response forwarding when timeout bookkeeping fails; and - an in-process `ByteStream` flow that sends a prefix, stalls, fires the timeout deterministically, and verifies that the retry resumes from the next offset. The interceptor currently applies only to `ByteStream.Read`. The download-specific option name leaves room to cover future server-streaming download RPCs such as [`SplitChunks`](bazelbuild/remote-apis#377) without implying that every remote stream should share this timeout. Cherry-pick of bazelbuild#29916. Stacked on bazelbuild#30666, which backports bazelbuild#29912. Co-authored-by: Ian (Hee) Cha <heec@google.com>
|
@tjgq @sluongng friendly ping here - I think we should go ahead and try to get this merged. Leaving it open is causing a follow-up churn as we continue to iterate on CDC, and I want to avoid making this PR a grab-bag CDC related changes. Its in a good shape now, but I'd like any additional follow-ups to be raised by in separate PRs, to keep this one scoped. We can wait to do a version release until we're confident in the shape of the spec, so merging the PR doesn't prevent changes to these unreleased APIs. |
258267a to
702503a
Compare
TL;DR: Unary split/splice forces large chunk manifests into a single size-limited message and makes the receiver wait for the complete manifest before processing it; streaming keeps messages bounded and enables incremental processing, improving scalability and performance for large blobs.
This PR adds
GetChunkMappingandRegisterChunkMapping, streaming replacements for split and splice. This lets clients send the ordered chunk list across multiple messages instead of fitting it all into one unary request or response.The names make the direction of each operation explicit and describe what the RPCs exchange: an ordered blob-to-chunk digest mapping. They do not transfer chunk data, so names such as
GetChunksorPutChunkswould be misleading.Streaming also gives servers a chance to process the mapping as it arrives. For splice, this can avoid doing all of the work in one final RPC after every chunk has already been uploaded. Servers can still choose to wait until the stream is complete and process it like the unary path.
This solves the issues described in #376.