Skip to content

Replace CAS with CS and CLS - #366

Open
meroton-benjamin wants to merge 10 commits into
mainfrom
cdc-support
Open

meroton-benjamin wants to merge 10 commits into
mainfrom
cdc-support

Conversation

@meroton-benjamin

@meroton-benjamin meroton-benjamin commented Sep 6, 2026 •

Copy link
Copy Markdown
Contributor

This commit builds on top of our split and splice blob support to make
it a mandatory first class feature in Buildbarn. With this commit the
Content Addressable Storage (CAS) is created from two Storage
configurations that work in tandem. A Chunk Storage (CS) which is
content addressed and contains chunks of blobs, and a Chunk List Storage
(CLS) which is addressed by a blob digest and contains a manifest
describing the chunks that make up the blob.

All api calls are automatically translated to use Chunk Lists created
with RepMaxCDC. Effectively this means that large blobs no longer exists
in the storage layer, individual chunks of the large blobs are in turn
deduplicated in such a manner that the chunks are stored only once.

The automatic translation makes certain that clients that are not cdc
aware can still continue to use the storage backend without performing
any changes. Clients which support RepMaxCDC also gets a significant
reduction in the amount of blobs to transfer as they only need to
transfer modified chunks rather than the entire blob.

Comment thread pkg/blobstore/cdc/bypass.go Outdated
// that signals downstream storage layers that the chunk list has
// already been validated (or was freshly generated) and does not need
// expensive re-validation.
func NewContextWithChunkListValidationBypass(ctx context.Context) context.Context {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that we should get rid of this, and turn it into a function argument. For example, change BlobAccess.Put() to take a validated bool parameter.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

validated is now a property of chunklist.ChunkList

Comment thread pkg/ttlcache/ttl_cache.go Outdated
// Optional: Storage backend to use when Reference objects refer to
// objects stored in another Content Addressable Storage.
BlobAccessConfiguration content_addressable_storage = 5;
ContentAddressableStorageConfiguration content_addressable_storage = 5;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, this is interesting. We'll need to figure out how to make ICAS work in the post chunking world. But are we sure that this is conceptually correct? Could a Chunk Store of type ReferenceExpanding be backed by another CAS? I can understand how it can be backed by another Chunk Store.

If we don't know how to solve this specific aspect, I'd be happy to phase out this specific feature. As in, keep ICAS support there, but remove the ability to reference another CAS.

@meroton-benjamin meroton-benjamin Sep 20, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The other CAS may have other chunking parameters making it so that if we refer to chunks from a different chunk storage then we have to consider it to be a CAS in the context.

@@ -0,0 +1,13 @@
syntax = "proto3";

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

chunklist.proto to match the directory name.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ack

Comment thread pkg/digest/function.go Outdated
}
if digest.SizeBytes < 0 {
return BadDigest, status.Error(codes.InvalidArgument, "Negative size digest provided")
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this specific change necessary? Function.NewDigest() also has a similar check, right?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it was not

Comment on lines +110 to +121
serverCapabilities, err := getServerCapabilitiesWithCacheCapabilities(ctx, ba.capabilitiesClient, instanceName)
if err != nil {
return nil, err
}
cacheCapabilities := serverCapabilities.CacheCapabilities
// Only return fields that pertain to Chunk List Storage.
return &remoteexecution.ServerCapabilities{
CacheCapabilities: &remoteexecution.CacheCapabilities{
SplitBlobSupport: cacheCapabilities.SplitBlobSupport,
SpliceBlobSupport: cacheCapabilities.SpliceBlobSupport,
},
}, nil

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought we agreed that GetCapabilities() should always be invoked against the Chunk Store, not the Chunk List Store. In other words, this needs a panic(), just like we have for ICAS, ISCC, etc.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed

Comment thread pkg/cas/operations.go Outdated
}

// GetBytes returns the bytes of a digest from the CAS as a byteslice.
func GetBytes(ctx context.Context, cas ContentAddressableStorage, d digest.Digest) ([]byte, error) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A function like this should respect an upper size limit.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed

Comment thread pkg/cas/content_addressable_storage.go Outdated

// ContentAddressableStorage is an interface which describes
// interactions with a Content Addressable Storage (CAS).
type ContentAddressableStorage interface {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we need to take a step back and ask ourselves what this interface actually abstracts. Right now it's only there to stitch a couple of things together: Chunk Store, Chunk List Store, and fetching of capabilities, and I'm not sure that's worth a lot.

Why can't we have implementations of MessageReader and StreamReader that are directly backed by:

  • An instance of cdc.ParametersFetcher
  • A Chunk Store BlobAccess
  • A Chunk List Store BlobAccess

?

For uploading files/protos into the CAS, that's where we need to write a new abstraction. But that can potentially live in bb-remote-execution?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have removed the ContentAddressableStorage interface in favor of taking whatever is needed in the operations themselves

Comment on lines +399 to +400
SplitBlobSupport: cacheCapabilities.SplitBlobSupport,
SpliceBlobSupport: cacheCapabilities.SpliceBlobSupport,

@EdSchouten EdSchouten Sep 8, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So the entire idea behind these GetCapabilities() implementations is that they return/yield the kinds of properties that are variable. In this implementation we're going to require that implementations support chunking.

So with that in mind, I think that this function should only check that the CacheCapabilities returned by the server have SplitBlobSupport and SpliceBlobSupport set to true. It doesn't need to return those fields here. Then in cmd/bb_storage/main.go, we should set these fields to true (at the same place where we set SupportedCompressors).

Similarly, this code should never return a ServerCapabilities that doesn't have RepMaxCdcParams set.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ack

var casCapabilitiesProvider = capabilities.NewStaticProvider(&remoteexecution.ServerCapabilities{
var csCapabilitiesProvider = capabilities.NewStaticProvider(&remoteexecution.ServerCapabilities{
CacheCapabilities: &remoteexecution.CacheCapabilities{
DigestFunctions: digest.SupportedDigestFunctions,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should have SupportsSplitBlobs and such set.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ack

Comment thread pkg/blobstore/blob_access.go Outdated
@@ -16,7 +15,6 @@ type BlobAccess interface {
capabilities.Provider

Get(ctx context.Context, digest digest.Digest) buffer.Buffer

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This may now return (buffer.Buffer, error)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Returns (T, error)

Comment thread pkg/blobstore/blob_access.go Outdated

Get(ctx context.Context, digest digest.Digest) buffer.Buffer
GetFromComposite(ctx context.Context, parentDigest, childDigest digest.Digest, slicer slicing.BlobSlicer) buffer.Buffer
Put(ctx context.Context, digest digest.Digest, b buffer.Buffer) error

@EdSchouten EdSchouten Sep 8, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider making Buffer a generic type now.

  • CAS: buffer.Buffer[[]byte]
  • AC: buffer.Buffer[*remoteexecution.ActionResult]
  • CLS: buffer.Buffer[cdc.ChunkList]

?

type BufferMarshaler[T any] {
      Marshal(digestFunction digest.Function, T any) ([]byte, error)
      Unmarshal(digestFunction digest.Function, []byte) (T, error)
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

blobstore.BlobAccess is now generic

@meroton-benjamin

Copy link
Copy Markdown
Contributor Author

State of latest commit:

Review comments addressed and rebased on top of the lossymap implementation. This version does not use the lossymap but it could be an alternative to using the generic ttlcache.TTLCache. One question that remains before we do that is if we case about the eviction set eviction policy. It may be desireable to pick a different implementation for different caches, say an LRU implementation for the chunk cache, a FIFO implementation for the chunk list cache etc etc.

Comment thread pkg/blobstore/buffer/chunk.go Outdated
@@ -0,0 +1,147 @@
package buffer

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we think that it's fitting to put this in pkg/blobstore/buffer? Maybe the correct thing to do is to rename pkg/blobstore/chunklist to pkg/blobstore/chunk, and then move this in there as well?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

moved to pkg/blobstore/chunk

Comment thread pkg/blobstore/buffer/chunk.go Outdated

mu sync.Mutex

hasData atomic.Bool

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Out of curiosity, when would a buffer not have any data in decompressed form? Given that we now always perform checksum validation, we always end up decompressing objects anyway. This is probably not necessary?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the get path of a blob access we don't decompress to validate the chunks. So when fetching compressed data there is decompression on the storage side to validate the integrity of the blob but otherwise all in betweens just pass the compressed data back without inspecting it.

We could reduce that to zero decompressions on the get path by letting the ChunkCoder not perform integrity checks on decoding and joining the ChunkCoder with the XXH64SufficCoder for integrity checks (effectively taking the XXH64 of the compressed bytes).

Comment thread pkg/blobstore/buffer/chunk.go Outdated
type Chunk struct {
zstdPool zstd.Pool

mu sync.Mutex

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need this mutex?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Slice assignment is not atomic so the mutex is needed for correctness. The risk is otherwise that a thread could read new slice header + old length though I'm uncertain if that could happen in practice in the codebase. In either case with the switch to atomic.Pointer[[]byte] makes it moot.

Comment thread pkg/blobstore/buffer/chunk.go Outdated
data []byte

hasCompressedData atomic.Bool
compressedData []byte

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe it's a bit more elegant to use a atomic.Pointer[[]byte] here?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or maybe use a sync.Once?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did atomic.Pointer as the function theoretically could fail and sync.Once can not take a context or return an error so a cancelation on the GetEncoder would consume the Once and have the value be error for ever.

Comment thread pkg/blobstore/buffer/read_at_closer.go Outdated
@@ -0,0 +1,9 @@
package buffer

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given that nothing in pkg/blobstore/buffer uses this interface, could we remove this? Or maybe move it elsewhere?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ack

Comment thread pkg/blobstore/coder/chunk_list_coder.go Outdated
// chunklist.ChunkList into an efficient binary format.
//
// The binary schema uses a Struct-of-Arrays (SoA) layout to group sizes
// together which maximizes compressibility but does not itself compress

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Even with the sizes and hashes split, I don't think that compressing these records is ever advantageous. The hashes are 32 bytes in size and are simply not compressible. The sizes are only 4 bytes. So in the best case we only save 4/(32+4) = 1/9 the space.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Zstd encoder removed

Comment thread pkg/blobstore/coder/zstd_coder.go Outdated
zstdMinimumBufferSize = 64
)

type zstdCoder struct {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So this Coder is only going to be used by non-chunkstore, right? So this would allow compressing AC entries, etc. Have we ever observed this to be necessary? AC entries are tiny.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I mostly added it for symmetry with the chunk store but it might be true that we never need to do this.

Comment on lines -194 to -197
// Any errors generated above may be caused by
// data corruption on the Tree object. Force
// reading the Tree until completion, and prefer
// read errors over any errors generated above.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe that in the case of REv2 this still holds. It may well be the case that storage serves us a bad ChunkList, where the chunks when concatenated don't add up to the original digest. Implementations of StreamReader still need to check for this. They should reject handing out the final chunk in those cases.

}

func (xxh64SuffixCoder) Encode(data []byte, parentDigest digest.Digest) ([]byte, error) {
hash := xxhash.Sum64(data)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would consider adding parentDigest to the hash as well. That way we don't just validate that the object is intact. We also verify that it belongs to the digest under which it is stored.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clarification: we should likely include parentDigest.GetKey(digest.KeyWithoutInstance). So not the instance name.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

DigestFunctions: digest.SupportedDigestFunctions,
DigestFunctions: digest.SupportedDigestFunctions,
SplitBlobSupport: true,
SpliceBlobSupport: true,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Again, I think that setting these fields should be the responsibility of cmd/bb_storage/main.go. Inside of BlobAccess we should only set fields that are actually variable.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ack

func (ba *clsBlobAccess) Get(ctx context.Context, blobDigest digest.Digest) (chunklist.ChunkList, error) {
digestFunction := blobDigest.GetDigestFunction()

// TODO: Replace with streaming variant

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Even though this is already stated as a TODO, I'm specifically adding a comment here so we don't forget. In my opinion we should only invoke the new API internally.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed

Comment on lines +49 to +57
for i, proto := range splitBlobsResponse.ChunkDigests {
d, err := digestFunction.NewDigestFromProto(proto)
if err != nil {
return chunklist.ChunkList{}, err
}
chunkList.Offsets[i] = offset
chunkList.Digests[i] = d
offset += uint64(d.GetSizeBytes())
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should likely be the place where we also validate that the combined size of the chunk list matches the digest. Probably want to use https://pkg.go.dev/math/bits#Add64 here to ensure stuff doesn't overflow.

}

r := resp.Responses[0]
if r.GetStatus().GetCode() != int32(codes.OK) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe cleaner to write something like this:

if err := status.ErrorProto(r.Status); err != nil {
    return nil, err
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ack


switch r.Compressor {
case remoteexecution.Compressor_IDENTITY:
return buffer.NewChunk(ba.zstdPool, r.Data), nil

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

buffer.NewChunk() can't fail right now. Wouldn't it be better to do the checksum validation at this exact spot?

if err != nil {
return nil, err
}
for i := 0; i < len(digests); i++ {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for i, digest := range digests {
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ack

Comment on lines +228 to +230
// Limit the amount of data that is read to one byte beyond the
// advertised size, so that corrupt or malicious streams cannot
// trigger unbounded decompression.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not sufficient to ensure a client can't send a malicious request that causes the server to run out of memory. You could create a legitimate request that contains a 1 TB blob that compresses down to just a couple of bytes. Then this code will call io.ReadAll(1TB + 1).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, I just removed the PutBytes convenience function as PutReader already handles that case perfectly well and consumers can just wrap their bytes with bytes.NewReader themselves, they don't need a helper for it.

if status.Code(err) != codes.NotFound {
return zero, util.StatusWrap(err, name)
}
err = replicator.ReplicateMultiple(ctx, digest.ToSingletonSet())

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doesn't this mean that we actually load the same object twice from the second backend? The idea behind ReplicateSingle() was that this could be prevented.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It does, but isn't this already the behavior internally for e.g. concurrency_limiting and queueing replicators?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For some implementations that's indeed the case, but not for ones like local. And the nice thing is that in the absence of streaming there is even more of a reason to offer it. The reason concurrency_limiting and queueing couldn't do it was because they couldn't guarantee that the caller processes the buffer immediately/fast enough. However, if we don't have large objects, then this is never any issue.

Comment thread pkg/blobstore/blob_access.go Outdated
Comment on lines +11 to +13
// capablitiesProvider is a copy of the capabilities.Provider interface
// which must be written inline for go mockgen to function in source
// mode.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is awful. :-(

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is indeed, really sad about that aspect of our gomocks but could not manage to find any other solution.

//
// More details: https://github.com/bazelbuild/bazel/issues/11063
func NewEmptyBlobInjectingBlobAccess(base BlobAccess) BlobAccess {
func NewEmptyBlobInjectingBlobAccess(base BlobAccess[*buffer.Chunk]) BlobAccess[*buffer.Chunk] {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was thinking: do we even need a BlobAccess like this now?

With chunking in place, we already have logic at the high level to distinguish between small and large objects. Maybe it's better if the empty digest is also handled in those cases?

In other words: we forbid the creation of empty Chunk objects.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not sure, what about things like completeness checking blob access, tree/directory objects or chunk lists? Couldn't they refer to empty blobs and thus we would have to handle it at their call sites as well to be spec compliant.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They could. But the digests that they embed always refer to high-level CAS objects (read: files), not individual chunks. And EmptyBlobInjectingBlobAccess would only be capable of injecting empty chunks, which is something we should never see.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good riddance! :)

Comment thread cmd/bb_copy/main.go
return util.StatusWrapf(err, "Failed to read configuration from %s", os.Args[1])
}

if configuration.TraversalConcurrency <= 0 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this option actually needed? I don't see it getting used anywhere.

Comment thread cmd/bb_copy/main.go
configuration.Source,
blobAccessCreator,
)
zstdPool := bb_zstd.NewPoolFromConfiguration(nil)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just out of curiosity, do you think we still need zstd pools? I think the only reason we added them was because we had to do streaming compression/decompression. Now that we only work with chunks, I suspect it's perfectly reasonable if we had a runtime.NumCPU() sized pool encoders/decoders, and placed a semaphore around it?

}
chunkDigests = append(chunkDigests, chunkDigest)
}
if len(chunkDigests) > s.maximumChunkCount {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Q: Is there any particular reason we want to check this at this specific level? Wouldn't it be sufficient if we checked this in ChunkWhateverValidatingBlobAccess?

var blobDigestProto *remoteexecution.Digest
var chunkDigests []digest.Digest
for i := 0; ; i++ {
in, err := stream.Recv()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have the feeling that this code would be slightly more elegant if it was written like this:

m, err := stream.Recv()
if err != nil {
    if err == io.EOF {
        return status.Error(codes.InvalidArgument, "Did not receive initial message")
    }
    return err
}
instanceName = ...
digestFunction = ...

for {
   chunkDigest = ...

   m, err = stream.Recv()
   if err != nil {
       if err == io.EOF {
           break
       }
   }
}

Comment on lines +268 to +291
case 0:
// No chunks, just check that it's the empty blob.
if d.GetSizeBytes() != 0 {
return status.Error(codes.InvalidArgument, "Chunk mapping does not compose to blob")
}
return nil
case 1:
// Single chunk blob, check that the chunk digest matches the
// blob digest and that the chunk is present in storage.
if chunkDigests[0] != d {
return status.Error(codes.InvalidArgument, "Chunk mapping does not compose to blob")
}
params, err := s.cdcParametersFetcher.FetchCDCParameters(ctx, d.GetInstanceName())
if err != nil {
return err
}
missing, err := cas.FindMissing(ctx, s.chunkStorage, s.chunkMappingStorage, params, chunkDigests[0].ToSingletonSet())
if err != nil {
return err
}
if !missing.Empty() {
return status.Error(codes.NotFound, "At least one chunk is missing from storage.")
}
return nil

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Even though it isn't necessarily equal to what is in REv2, what are your thoughts on simply rejecting calls like these? The phrasing that's in the spec is all written with FastCDC in mind, which requires this kind of nonsense. Given that we only want to support RepMaxCDC, I think we should do our best to bash clients into shape.

Comment thread pkg/ttlcache/ttl_cache.go Outdated
}

// NewTTLCache instantiates a reusable TTLCache for any key-value pair.
func NewTTLCache[K comparable, V any](clock clock.Clock, evictionSet eviction.Set[K], maxItems int, cacheDuration time.Duration) *TTLCache[K, V] {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So I fully understand your concern that the hash map implementation we have in pkg/lossymap is a bit too convoluted for what you're trying to do here.

However, what are your thoughts on moving this into pkg/lossymap and letting it be an alternative implementation of lossymap.Map? As in, add a simple_map.go or something that provides the same contract.

Comment thread pkg/blobstore/chunk/mapping.go Outdated
// case the Validated field should be true.
type Mapping struct {
Offsets []uint64
Digests []digest.Digest

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think one of the reasons we wanted to add this type was to avoid the storage space usage of regular Digest messages.

Why don't we also implement this type so that it is backed by a []byte of concatenated hashes?

Comment thread pkg/blobstore/chunk/mapping.go Outdated
}

// MappingFetcher retrieves a ChunkMapping for a digest.
type MappingFetcher interface {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This type has no relationship with anything above/below. Maybe best to move it into a separate file?

Comment thread pkg/blobstore/chunk/mapping.go Outdated
Comment on lines +71 to +80
i, exact := slices.BinarySearch(l.Offsets, off)
if exact {
return i, 0
}

localOffset := int64(off - l.Offsets[i-1])
if localOffset < l.Digests[i-1].GetSizeBytes() {
return i - 1, localOffset
}
return len(l.Offsets), 0

@EdSchouten EdSchouten Sep 28, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My gut feeling tells me that something like this should have been sufficient:

i := sort.Search(len(l.Offsets), func(i int) bool {
    return l.Offsets[i] > off
}) - 1
return i, off - l.Offsets[i]

Comment thread pkg/blobstore/chunk/mapping.go Outdated
Comment on lines +68 to +69
// offset and the offset within that chunk. Offsets at or beyond the end
// of the blob yield an index equal to the number of chunks in the mapping.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not a big fan of such a contract. Why can't we say:

This method MUST be called with an offset smaller than the size of the blob.

Comment thread pkg/blobstore/cdc/chunker.go Outdated
)

// Chunk is a struct of raw binary data with its digest.
type Chunk struct {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Move this code into pkg/cas, make it private.

Comment thread pkg/blobstore/cdc/reader_chunker.go Outdated
// The internal RepMaxContentDefinedChunker may peek up to this many
// bytes. We therefore make sure that the underlying buffer is big
// enough to prevent bufio.ErrBufferFull errors.
bufferSizeBytes := 2*minChunkSizeBytes + horizonSizeBytes

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

}

func (f *cachingMappingFetcher) FetchChunkMapping(ctx context.Context, d digest.Digest) (Mapping, error) {
if chunkMapping, err := f.cache.Get(d, f.clock.Now()); err == nil {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pease move locking out of the lossy map into here.

type Chunk struct {
data []byte

zstdPool zstd.Pool

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's unfortunate that we have the pool here. We should get rid of it.

Comment thread pkg/digest/bare_function.go Outdated
emptyHash string
}

func newBareFunction(enumValue remoteexecution.DigestFunction_Value, hasherFactory func(expectedSizeBytes int64) hash.Hash, hashBytesSize int) bareFunction {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we're doing this, then you can also get rid of the hashSizeBytes argument. You can just do len(emptyHash)/2.

Comment on lines +202 to +205
if err != nil {
return nil, err
}
return data, nil

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

return data, err

@@ -30,17 +28,6 @@ var (
[]string{"storage_type", "operation", "grpc_code"},
)

blobReplicatorOperationsBlobSizeBytes = prometheus.NewHistogramVec(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe restore this, but simply use the digest.GetSizeBytes() ?

@@ -3,13 +3,12 @@ package blobstore
import (

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TODO remove

FetchCDCParameters(ctx context.Context, instanceName digest.InstanceName) (*remoteexecution.RepMaxCdcParams, error)
}

type capabilitiesCDCParametersFetcher struct {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

baseCDCParametersFetcher

// NewCDCParametersFetcher creates a CDCParametersFetcher that obtains
// the CDC parameters by calling GetCapabilities() on the provided
// capabilities.Provider.
func NewCDCParametersFetcher(provider Provider) CDCParametersFetcher {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NewBaseBla()

@EdSchouten
EdSchouten marked this pull request as ready for review October 4, 2026 13:07
This commit adds support for the SplitBlob and SpliceBlob methods from
the Remote Execution v2 (REv2) api. SplitBlob and SpliceBlob can be used
to facilitate uploads and downloads of large files but a naïve
implementation like this has some major drawbacks as well.

The blobs must exist in both their chunked and non chunked form,
which may significantly increase storage requirements for large blobs.
The protocol gives no guarantee that a large blob stored in the CAS
exists in its chunked form which forces you to perform a fairly heavy
Split call that loads the entire large blob in order to decomposition it
into its chunks.

This implementation mostly exists as a stepping stone for a different
implementation where Buildbarn internally manages all blobs as chunked
blobs.
This commit makes makes chunking a mandatory first class feature in
Buildbarn. This removes the Content Addressable Storage (CAS) as a
single blob access and it instead is a logical combination of two
storage configurations that work in tandem. A Chunk Storage (CS) which
is content addressed and contains chunks of blobs, and a Chunk Mapping
Storage (CMS) which is addressed by a blob digest and contains a
chunk.Mapping describing the chunks that make up the blob.

All API calls are automatically translated to use Chunk Mappings created
with RepMaxCDC. Effectively this means that large blobs no longer exists
in the storage layer, individual chunks of the large blobs are in turn
deduplicated in such a manner that the chunks are stored only once.

The automatic translation makes certain that clients that are not CDC
aware can still continue to use the storage backend without performing
any changes. Clients which support RepMaxCDC also gets a significant
reduction in the amount of blobs to transfer as they only need to
transfer modified chunks rather than the entire blob.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants