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
327c4c4 to
e17c271
Compare
sluongng
left a comment
There was a problem hiding this comment.
small doc nits, but lgtm
e17c271 to
eeec2d1
Compare
|
Thinking about this more: if a client wants to start uploading and hashing at the same time, the blob hash shouldn't be required in the first request - it just needs to be in some request, or the last one. Routing isn't important here because the chunks will exist on many shards, so only the final frame needs routing to know where to store the verified entry. Going to update to account for this |
eeec2d1 to
e2bbd85
Compare
|
Just made in important change, that will further make this more performant: Updated This does not weaken integrity: each chunk is content-addressed, and the server incrementally hashes the concatenated chunk contents in order, then compares the result with Earlier requests do not need the blob digest for routing because each chunk is routed independently by its own digest. Only the final commit needs |
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 |
9f14f78 to
2f5bab3
Compare
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.
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
This PR adds streaming versions of 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.
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.