Skip to content

Remote AC writes drop output_directories for REv2.0 root-capture (output_directories=[""]), poisoning the cache for downstream consumers #23372

Description

@sbarnat

Describe the bug
Pants's remote_cache::CommandRunner::update_action_cache silently drops output_directories from the ActionResult it writes via UpdateActionResult whenever the action declares a root-capture output — i.e. Process(..., output_directories=["."]) on the client side, which Pants normalizes to output_directories=[""] on the REv2 wire. The resulting AC entry has exit_code: 0, empty output_directories, and passes completeness-checking on the BB side because there are no referenced blobs left to validate. Downstream consumers receive a "successful" cache hit with no outputs, which silently merges in nothing and breaks the build several steps later with no usable error message.

Pants version
2.32.0

OS
Linux, but the issue is not OS dependent

Additional info

Reproduction

  1. Define a Pants process that declares output_directories=[""] (REv2.0 root capture).
  2. Configure [GLOBAL] remote_cache_write = true against any REv2 server that exposes a working ActionCache write path (we used Buildbarn bb-storage:20250827T121715Z).
  3. Flush the AC so a fresh write happens.
  4. Run the goal that exercises the process. Pants reports success; the worker ExecuteResponse includes a proper outputDirectories: [{ treeDigest: <2824-byte Tree of XVM/...> }].
  5. Query the AC entry Pants just wrote:
# Synthetic example using REv2 Python proto bindings.
res = ac_stub.GetActionResult(GetActionResultRequest(
    instance_name="fuse",
    action_digest=Digest(hash="<the action digest from worker logs>", size_bytes=141),
))
print(res.output_directories)  # -> [] (BUG: should contain the root tree)
print(res.execution_metadata.worker_completed_timestamp)
# -> seconds: 1, nanos: ~3e8  (also wrong, but cosmetic)

##Root Cause
make_tree_for_output_directory in src/rust/process_execution/remote/src/remote_cache.rs is:

pub(crate) fn make_tree_for_output_directory(
    root_trie: &DigestTrie,
    directory_path: RelativePath,
) -> Result<Option<(Tree, Vec<Digest>)>, String> {
    let sub_trie = match root_trie.entry(&directory_path)? {
        None => return Ok(None),          // <-- triggered for empty path
        Some(directory::Entry::Directory(d)) => d.tree(),
        ...
    };
    ...
}
When directory_path is empty (e.g. from Command.output_directories = [""]), root_trie.entry(&path) iterates zero path components and returns Ok(None) (see DigestTrie::entry_helper in fs/src/directory.rs). The caller in make_action_result then hits its None => continue arm and silently drops the output:

for output_directory in &command.output_directories {
    let (tree, file_digests) = match Self::make_tree_for_output_directory(...)? {
        Some(res) => res,
        None => continue,        // <-- silently skips output_directories=[""]
    };
    ...
}
The None => continue is intentional per #11772 (REAPI says missing declared outputs should be skipped, not errored). It just never accounted for the legitimate root-capture case where the "path" is the root itself.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions