Skip to content

Commit 8051ca9

Browse files
committed
directory_cache: short-circuit zero-byte files
FilesystemStore (and several other CAS backends) refuse to store zero-byte blobs, so a get_part_unchunked for the zero-byte digest (af1349b9... / e3b0c449...) returns NotFound. Bazel input trees routinely contain empty marker/config files (.linksearchpaths, empty .env, .toml, etc.), so without this fix a single such file in any directory causes the entire DirectoryCache construction to fail — roughly 30% of cache attempts per PR TraceMachina#2243. Short-circuit create_file: if the digest is the zero-byte digest, write b"" to disk directly and never consult the CAS. Cross-platform correctness fix. Extracted from TraceMachina/nativelink PR TraceMachina#2243 (commit d198902).
1 parent 1ddce0f commit 8051ca9

1 file changed

Lines changed: 86 additions & 10 deletions

File tree

nativelink-worker/src/directory_cache.rs

Lines changed: 86 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ use nativelink_proto::build::bazel::remote::execution::v2::{
2525
Directory as ProtoDirectory, DirectoryNode, FileNode, SymlinkNode,
2626
};
2727
use nativelink_store::ac_utils::get_and_decode_digest;
28+
use nativelink_store::cas_utils::is_zero_digest;
2829
use nativelink_util::common::DigestInfo;
2930
use nativelink_util::fs_util::{
3031
CloneMethod, hardlink_directory_tree, set_readonly_recursive, set_readwrite_recursive,
@@ -299,17 +300,29 @@ impl DirectoryCache {
299300

300301
trace!(?file_path, ?digest, "Creating file");
301302

302-
// Fetch file content from CAS
303-
let data = self
304-
.cas_store
305-
.get_part_unchunked(StoreKey::Digest(digest), 0, None)
306-
.await
307-
.err_tip(|| format!("Failed to fetch file: {}", file_path.display()))?;
303+
// Zero-byte files (digest af1349b9...-0) are not stored in
304+
// FilesystemStore / many CAS backends, so a get_part_unchunked here
305+
// returns NotFound. In Bazel-style trees these show up frequently as
306+
// empty marker / config files (.linksearchpaths, empty .env, .toml,
307+
// etc.), and a single failure aborts the whole DirectoryCache
308+
// construction. Short-circuit and write the empty file directly.
309+
if is_zero_digest(digest) {
310+
fs::write(&file_path, b"")
311+
.await
312+
.err_tip(|| format!("Failed to write empty file: {}", file_path.display()))?;
313+
} else {
314+
// Fetch file content from CAS
315+
let data = self
316+
.cas_store
317+
.get_part_unchunked(StoreKey::Digest(digest), 0, None)
318+
.await
319+
.err_tip(|| format!("Failed to fetch file: {}", file_path.display()))?;
308320

309-
// Write to disk
310-
fs::write(&file_path, data.as_ref())
311-
.await
312-
.err_tip(|| format!("Failed to write file: {}", file_path.display()))?;
321+
// Write to disk
322+
fs::write(&file_path, data.as_ref())
323+
.await
324+
.err_tip(|| format!("Failed to write file: {}", file_path.display()))?;
325+
}
313326

314327
// Set permissions
315328
#[cfg(unix)]
@@ -601,4 +614,67 @@ mod tests {
601614

602615
Ok(())
603616
}
617+
618+
/// A Directory containing a zero-byte file must be constructible even when
619+
/// the CAS has no entry for the zero-byte digest. In production CAS
620+
/// backends (FilesystemStore in particular) refuse to store zero-byte
621+
/// blobs, so without the short-circuit this is a NotFound error and 30%+
622+
/// of cache constructions fail (per PR #2243).
623+
#[nativelink_test]
624+
async fn test_directory_cache_zero_byte_file() -> Result<(), Error> {
625+
let temp_dir = TempDir::new().unwrap();
626+
let cache_root = temp_dir.path().join("cache");
627+
let store = Store::new(MemoryStore::new(&MemorySpec::default()));
628+
629+
// RFC 6234 / Bazel zero-byte SHA-256 digest, hash for b"".
630+
let zero_digest = DigestInfo::try_new(
631+
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
632+
0,
633+
)
634+
.unwrap();
635+
// Deliberately do NOT upload the zero-byte blob — that's the whole
636+
// point: real CAS backends won't have it.
637+
638+
let directory = ProtoDirectory {
639+
files: vec![FileNode {
640+
name: "empty.txt".to_string(),
641+
digest: Some(zero_digest.into()),
642+
is_executable: false,
643+
..Default::default()
644+
}],
645+
directories: vec![],
646+
symlinks: vec![],
647+
..Default::default()
648+
};
649+
let mut dir_data = Vec::new();
650+
directory.encode(&mut dir_data).unwrap();
651+
let dir_digest = DigestInfo::try_new(
652+
"abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
653+
dir_data.len() as i64,
654+
)
655+
.unwrap();
656+
store
657+
.as_store_driver_pin()
658+
.update_oneshot(dir_digest.into(), dir_data.into())
659+
.await
660+
.unwrap();
661+
662+
let config = DirectoryCacheConfig {
663+
max_entries: 10,
664+
max_size_bytes: 1024 * 1024,
665+
cache_root,
666+
};
667+
let cache = DirectoryCache::new(config, store).await?;
668+
669+
let dest = temp_dir.path().join("dest_empty");
670+
let hit = cache.get_or_create(dir_digest, &dest).await?;
671+
assert!(!hit, "First construction should be a cache miss");
672+
673+
let empty_path = dest.join("empty.txt");
674+
assert!(empty_path.exists(), "zero-byte file should be created");
675+
let metadata = fs::metadata(&empty_path).await.unwrap();
676+
assert_eq!(metadata.len(), 0, "zero-byte file must be 0 bytes");
677+
678+
Ok(())
679+
}
604680
}

0 commit comments

Comments
 (0)