Compose stores
The wrapper vocabulary (fast_slow, verify, dedup, existence_cache, size_partitioning, shard and the rest) and the order the layers have to go in.
Who this is for: anyone who has picked a backend and now needs it to be fast, cheap and trustworthy. What you'll have at the end: a layered store you can explain line by line, and the vocabulary to read anyone else's config. Time: thirty minutes.
Before you start
A config file with at least one backend in it. See Storage backends.
Most store types in NativeLink are not places to put bytes. They are wrappers: each one takes another store and changes something about how it behaves. A real CAS store is four or five of these nested inside each other, and the nesting order is the design.
The things at the bottom of the nesting are the backends: filesystem, S3 and compatible, GCS, Azure Blob, OCI, Redis, Mongo, each with a page of its own under Storage backends. Everything on this page goes above them.
The two ways stores refer to each other
A wrapper can hold its inner store inline:
{
name: "CAS_MAIN_STORE",
verify: {
verify_size: true,
verify_hash: true,
backend: {
filesystem: {
content_path: "/var/lib/nativelink/cas/content",
temp_path: "/var/lib/nativelink/cas/tmp",
eviction_policy: { max_bytes: "50gb" },
},
},
},
},Or it can name a store defined elsewhere in the stores array, with
ref_store:
stores: [
{
name: "CAS_MAIN_STORE",
verify: {
verify_size: true,
verify_hash: true,
backend: { ref_store: { name: "CAS_DISK" } },
},
},
{
name: "CAS_DISK",
filesystem: {
content_path: "/var/lib/nativelink/cas/content",
temp_path: "/var/lib/nativelink/cas/tmp",
eviction_policy: { max_bytes: "50gb" },
},
},
],Both configs describe the same thing. Order in the array does not matter, because
ref_store is resolved after every entry is read, so a store may reference one
defined below it. Reach for ref_store when two stores genuinely share one
backend, or when nesting has got deep enough to be unreadable.
The wrappers
fast_slow: a tier in front of a tier
The single most important wrapper. Reads try fast first and fall through to
slow on a miss, populating fast as they go. Writes go to both.
{
name: "CAS_MAIN_STORE",
fast_slow: {
fast: {
filesystem: {
content_path: "/var/lib/nativelink/cas/content",
temp_path: "/var/lib/nativelink/cas/tmp",
eviction_policy: { max_bytes: "50gb", evict_bytes: "5gb" },
},
},
slow: {
experimental_cloud_object_store: {
provider: "aws",
region: "us-east-1",
bucket: "my-nativelink-cas",
key_prefix: "cas/",
},
},
},
},Two knobs are worth knowing. fast_direction and slow_direction restrict
what each half participates in: update means writes land here but reads
never consult it, which only makes sense on the fast half; get, read_only
and the default both do what they sound like.
bypass_dedup_threshold_bytes makes reads at or above a given size skip the
fast tier entirely and stream straight from slow. It defaults to 0, which
disables the bypass; 256 MiB is a reasonable starting point if huge artifacts
are evicting everything useful out of your fast tier.
verify: refuse to store corruption
Checks the size and hash of an upload against the digest it is filed under, and rejects the write on a mismatch. Reads pass straight through to the backend unchecked, so this protects what enters the store, not what a backend later hands back.
VerifyStore{
name: "CAS_MAIN_STORE",
verify: {
verify_size: true,
verify_hash: true,
backend: { ref_store: { name: "CAS_DISK" } },
},
},Verify the CAS, not the Action Cache
A CAS entry is addressed by the hash of its content, so both checks are
meaningful. An Action Cache entry is not: its key is the action digest, and
its value is a result message, so verify_size and verify_hash should both
be false for an AC store. Setting them true there rejects every valid
update.
completeness_checking: the Action Cache wrapper
The AC's counterpart. Before returning an action result as a hit, it confirms every output digest that result references still exists in the CAS. Without it, a client gets a cache hit pointing at blobs that have since been evicted.
{
name: "AC_MAIN_STORE",
completeness_checking: {
backend: { ref_store: { name: "AC_DISK" } },
cas_store: { ref_store: { name: "CAS_MAIN_STORE" } },
},
},This is why the AC and CAS eviction policies are coupled in practice: evicting aggressively from the CAS quietly invalidates AC entries, and this wrapper is what turns that from a confusing build failure into a clean cache miss.
existence_cache: stop asking the same question
Caches the answer to "does this digest exist" so FindMissingBlobs doesn't hit
the backend for every blob of every build.
{
name: "CAS_MAIN_STORE",
existence_cache: {
backend: { ref_store: { name: "CAS_SLOW" } },
eviction_policy: { max_seconds: 3600 },
},
},The eviction_policy here governs the existence index, not the data. It is
a small map of digests, so it can hold far more entries than the store itself.
This is the highest-leverage wrapper in front of object storage, where every
existence check is a billed request.
compression: smaller on the backend
Compresses blobs with LZ4 before they reach the wrapped store.
{
name: "CAS_SLOW_STORE",
compression: {
compression_algorithm: { lz4: {} },
backend: { ref_store: { name: "CAS_BUCKET" } },
},
},LZ4 is the only algorithm. block_size defaults to 64 KiB, and
max_decode_block_size defaults to match it. The latter bounds how much a
malformed block can ask the decoder to allocate, so raise it only alongside
block_size.
This is store-level compression, on the way to the backend. It is a different thing from remote cache compression, which compresses on the wire between the client and NativeLink. The two are independent and can both be on.
dedup: store the changed parts only
Splits blobs into content-defined chunks, stores each chunk once, and keeps a per-blob index of which chunks it is made of.
{
name: "CAS_MAIN_STORE",
dedup: {
index_store: { ref_store: { name: "FAST_INDEX" } },
content_store: { ref_store: { name: "CAS_BUCKET" } },
min_size: 65536,
normal_size: 262144,
max_size: 524288,
},
},index_store should be small and fast; content_store is the large slow one.
The three size fields bound the chunker: normal_size is the target average,
min_size and max_size are hard limits, and they default to 64 KiB, 256 KiB
and 512 KiB. Every blob gets an index entry, even one small enough to be a
single chunk; there is no size below which the store skips the index.
max_concurrent_fetch_per_get (default 10) bounds parallel chunk fetches.
Worst-case memory per get() is about
max_concurrent_fetch_per_get × max_size, so the defaults cost roughly 5 MB
per in-flight read.
size_partitioning: route by blob size
Sends everything under size to one store and everything at or above it to
another.
{
name: "CAS_MAIN_STORE",
size_partitioning: {
size: 262144,
lower_store: { ref_store: { name: "SMALL_OBJECT_STORE" } },
upper_store: { ref_store: { name: "DEDUPED_LARGE_STORE" } },
},
},The usual use is to keep small objects out of a dedup store, where the
per-blob index costs more than the deduplication saves. The other use is a hard
ceiling: point upper_store at noop and blobs above the threshold are
discarded rather than stored.
shard: spread across several backends
Distributes blobs across stores by digest, weighted.
{
name: "CAS_MAIN_STORE",
shard: {
stores: [
{ store: { ref_store: { name: "SHARD_A" } }, weight: 1 },
{ store: { ref_store: { name: "SHARD_B" } }, weight: 1 },
{ store: { ref_store: { name: "SHARD_C" } }, weight: 2 },
],
},
},A store's share is its weight divided by the sum of all weights, so SHARD_C
above takes half. weight defaults to 1, so an unweighted list is an even
split.
Changing the shard set invalidates placement
Blobs are placed by hashing against the current set of stores and weights. Add, remove or reweight a shard and existing entries stop being looked for where they were put. Treat a shard change as a cache flush.
cache_metrics: measure one layer
Wraps a store to record hit rates and timings under a label:
{
name: "CAS_MAIN_STORE",
cache_metrics: {
cache_type: "cas",
backend: { ref_store: { name: "CAS_LAYERS" } },
},
},cache_type should be low-cardinality: cas or ac, not something per-user.
The wrapper is opt-in: stores not wrapped by it are built without it and pay
none of its hot-path timing cost. Wrap the layer whose behaviour you actually
want to see, not every layer.
The three with nothing to configure
memory takes an eviction_policy and nothing else. It is the right fast half
for a short-lived process and the wrong one for anything that must survive a
restart.
grpc points at another REAPI cache upstream, and can attach headers and
forward selected headers from the incoming request. Use it to front an existing
cache.
noop takes nothing at all and discards everything written to it, reporting
every read as a miss. It is how you deliberately turn a half of something off.
The order the layers go in
Reading a real config outside-in, each layer answers a different question:
Four placements are load-bearing rather than stylistic.
verify goes outermost. It checks an upload against the digest the client
filed it under. Underneath compression or dedup, the bytes it sees are no
longer the bytes the digest describes.
existence_cache goes above the expensive store, not below it. Its whole
purpose is to avoid a round trip, so anything between it and the backend is
work it was meant to skip.
dedup goes above compression, never below it. Compressed bytes of
similar inputs are not similar, so a dedup store fed by a compression
wrapper finds nothing to share. Put compression on the dedup store's
content_store, as the full example below does.
fast_slow goes innermost, just above the backends. Layers above it apply
to both halves once, rather than being paid twice.
A full example
The shipped GCS and Azure examples both use this shape, and it is a good default for a cloud-backed CAS:
stores: [
{
name: "CAS_MAIN_STORE",
verify: {
verify_size: true,
verify_hash: true,
backend: {
dedup: {
index_store: {
fast_slow: {
fast: {
filesystem: {
content_path: "/data/index/content",
temp_path: "/data/index/tmp",
eviction_policy: { max_bytes: "500mb" },
},
},
slow: {
experimental_cloud_object_store: {
provider: "gcs",
bucket: "my-nativelink-cas",
key_prefix: "index/",
},
},
},
},
content_store: {
compression: {
compression_algorithm: { lz4: {} },
backend: {
fast_slow: {
fast: {
filesystem: {
content_path: "/data/content/content",
temp_path: "/data/content/tmp",
eviction_policy: { max_bytes: "2gb" },
},
},
slow: {
experimental_cloud_object_store: {
provider: "gcs",
bucket: "my-nativelink-cas",
key_prefix: "cas/",
},
},
},
},
},
},
},
},
},
},
],Read outside-in: verify what we serve; dedup so we store each chunk once; keep the index on fast local disk backed by the bucket; compress the content; tier that over the bucket too.
Steps
Start with the backend alone and confirm it works. Every layer you add makes a failure harder to attribute.
Add
fast_slowwith a local filesystem fast half, and confirm the second build is a hit with the fast tier emptied.Add
verifyto the CAS, and not to the Action Cache.Add
completeness_checkingto the Action Cache, pointingcas_storeat the CAS store by name.Add
existence_cacheif the slow tier is object storage, and watch the request count rather than the byte count to see whether it helped.Add
dedupandcompressionlast, and only if artifact size is the problem you actually have.
You did it right if
- The config loads. An unknown field anywhere in the nesting is rejected at startup, so a clean start means every layer name is right.
- A build is a cache hit on the second run with the fast tier emptied, which proves the layers pass through to the slow tier.
- Backend request counts are far below blob counts once
existence_cacheis in. - Uploading a blob under a digest that does not match its content (for
example with a hand-built ByteStream write) is rejected rather than stored.
That's
verifyearning its place.
When it doesn't work
The other kind of compression: on the wire, between the client and NativeLink.
SidewaysProduction configurationWhat these compositions look like once the three processes are separate.
Oracle Cloud (OCI) Object Storage
Back the cache with OCI Object Storage through its S3 Compatibility API: Customer Secret Keys, the two adjustments the store makes for you, and what has actually been verified.
Remote cache compression
Cut remote cache transfer bytes for compressible artifacts with REAPI zstd wire compression and Bazel's --remote_cache_compression.