Add NestedFileSystemOperationNodes, canonical wire format and round-tripping.

Since NestedFileSystemOperationNodes are identified by a fingerprint of
their serialized representation uses a custom, canonical wire format. As
per https://protobuf.dev/programming-guides/serialization-not-canonical/,
protos are not canonical.

Performs some renamings for consistency.
* In some places renames Directory to Listing. This emphasizes that it's
the listing that matters for invalidation.
* For consistency, some places that were DirectoryListing are also
renamed as Listing.
* GetDependenciesResult is renamed to GetFileDependenciesResult for
consistency.

PiperOrigin-RevId: 688712493
Change-Id: I0456a3d7ab0a8f4077d057d9ded3f1061fd85d11
diff --git a/src/main/java/com/google/devtools/build/lib/skyframe/BUILD b/src/main/java/com/google/devtools/build/lib/skyframe/BUILD
index 58dfc5d..a23f807 100644
--- a/src/main/java/com/google/devtools/build/lib/skyframe/BUILD
+++ b/src/main/java/com/google/devtools/build/lib/skyframe/BUILD
@@ -1506,6 +1506,7 @@
         "DirectoryListingKey.java",
         "FileKey.java",
         "FileSystemOperationNode.java",
+        "NestedFileSystemOperationNodes.java",
     ],
     deps = [
         ":sky_functions",
@@ -1513,6 +1514,8 @@
         "//src/main/java/com/google/devtools/build/lib/skyframe/serialization/autocodec",
         "//src/main/java/com/google/devtools/build/lib/vfs",
         "//src/main/java/com/google/devtools/build/skyframe:skyframe-objects",
+        "//third_party:error_prone_annotations",
+        "//third_party:jsr305",
     ],
 )
 
diff --git a/src/main/java/com/google/devtools/build/lib/skyframe/FileSystemOperationNode.java b/src/main/java/com/google/devtools/build/lib/skyframe/FileSystemOperationNode.java
index 30c03ee..caed576 100644
--- a/src/main/java/com/google/devtools/build/lib/skyframe/FileSystemOperationNode.java
+++ b/src/main/java/com/google/devtools/build/lib/skyframe/FileSystemOperationNode.java
@@ -13,5 +13,10 @@
 // limitations under the License.
 package com.google.devtools.build.lib.skyframe;
 
-/** A {@link FileKey} or {@link DirectoryListingKey}. */
-public sealed interface FileSystemOperationNode permits FileKey, DirectoryListingKey {}
+/**
+ * Represents either a single file system operation or a nested set thereof.
+ *
+ * <p>If the set is unary, it is represented without a wrapper.
+ */
+public sealed interface FileSystemOperationNode
+    permits FileKey, DirectoryListingKey, NestedFileSystemOperationNodes {}
diff --git a/src/main/java/com/google/devtools/build/lib/skyframe/NestedFileSystemOperationNodes.java b/src/main/java/com/google/devtools/build/lib/skyframe/NestedFileSystemOperationNodes.java
new file mode 100644
index 0000000..1c4de62
--- /dev/null
+++ b/src/main/java/com/google/devtools/build/lib/skyframe/NestedFileSystemOperationNodes.java
@@ -0,0 +1,88 @@
+// Copyright 2024 The Bazel Authors. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+package com.google.devtools.build.lib.skyframe;
+
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
+import java.util.ArrayList;
+import javax.annotation.Nullable;
+
+/** Represents multiple {@link FileSystemOperationNode}s with nestable composition. */
+public final class NestedFileSystemOperationNodes implements FileSystemOperationNode {
+  private final FileSystemOperationNode[] nodes;
+
+  /**
+   * Opaque storage for use by serialization.
+   *
+   * <p>{@link FileSystemOperationNode}, {@link FileKey} and {@link DirectoryListingKey} are
+   * mutually dependent via {@link FileSystemOperationNode}. This type is opaque to avoid forcing
+   * {@link FileKey} and {@link DirectoryListingKey} to depend on serialization implementation code.
+   *
+   * <p>The serialization implementation initializes this field with double-checked locking so it is
+   * marked volatile.
+   */
+  private volatile Object serializationScratch;
+
+  public static FileSystemOperationNodeBuilder builder(FileSystemOperationNode node) {
+    return new FileSystemOperationNodeBuilder(node);
+  }
+
+  private NestedFileSystemOperationNodes(FileSystemOperationNode[] nodes) {
+    this.nodes = nodes;
+  }
+
+  public int count() {
+    return nodes.length;
+  }
+
+  public FileSystemOperationNode get(int index) {
+    return nodes[index];
+  }
+
+  @Nullable
+  public Object getSerializationScratch() {
+    return serializationScratch;
+  }
+
+  public void setSerializationScratch(Object value) {
+    this.serializationScratch = value;
+  }
+
+  /**
+   * Effectively, a builder for {@link NestedFileSystemOperationNodes}, but formally a builder for
+   * {@link FileSystemOperationNode}.
+   *
+   * <p>When there is only one node, this builder returns the node directly instead of creating a
+   * useless wrapper.
+   */
+  public static class FileSystemOperationNodeBuilder {
+    private final ArrayList<FileSystemOperationNode> nodes = new ArrayList<>();
+
+    private FileSystemOperationNodeBuilder(FileSystemOperationNode node) {
+      nodes.add(node);
+    }
+
+    @CanIgnoreReturnValue
+    public FileSystemOperationNodeBuilder add(FileSystemOperationNode node) {
+      nodes.add(node);
+      return this;
+    }
+
+    public FileSystemOperationNode build() {
+      if (nodes.size() > 1) {
+        return new NestedFileSystemOperationNodes(nodes.toArray(FileSystemOperationNode[]::new));
+      }
+      return nodes.get(0);
+    }
+  }
+}
diff --git a/src/main/java/com/google/devtools/build/lib/skyframe/serialization/PackedFingerprint.java b/src/main/java/com/google/devtools/build/lib/skyframe/serialization/PackedFingerprint.java
index 35023c7..3a1f981 100644
--- a/src/main/java/com/google/devtools/build/lib/skyframe/serialization/PackedFingerprint.java
+++ b/src/main/java/com/google/devtools/build/lib/skyframe/serialization/PackedFingerprint.java
@@ -36,7 +36,8 @@
  * @param lo the lower 64-bits of the fingerprint
  * @param hi the upper 64-bits of the fingerprint
  */
-public record PackedFingerprint(long lo, long hi) implements KeyBytesProvider {
+public record PackedFingerprint(long lo, long hi)
+    implements KeyBytesProvider, Comparable<PackedFingerprint> {
   @VisibleForTesting static final int BYTES = 16;
 
   /**
@@ -108,6 +109,15 @@
     return (int) lo;
   }
 
+  @Override
+  public int compareTo(PackedFingerprint o) {
+    int result = Long.compare(hi, o.hi);
+    if (result == 0) {
+      return Long.compare(lo, o.lo);
+    }
+    return result;
+  }
+
   /** Changes all 0 bytes of {@code input} into 1. */
   @VisibleForTesting
   static long offsetZeros(long input) {
diff --git a/src/main/java/com/google/devtools/build/lib/skyframe/serialization/analysis/BUILD b/src/main/java/com/google/devtools/build/lib/skyframe/serialization/analysis/BUILD
index 5d2ff7a..823c607 100644
--- a/src/main/java/com/google/devtools/build/lib/skyframe/serialization/analysis/BUILD
+++ b/src/main/java/com/google/devtools/build/lib/skyframe/serialization/analysis/BUILD
@@ -75,20 +75,23 @@
         "//src/main/protobuf:file_invalidation_data_java_proto",
         "//third_party:guava",
         "//third_party:jsr305",
+        "@protobuf//:protobuf_java",
     ],
 )
 
 java_library(
     name = "file_dependency_deserializer",
     srcs = [
-        "DirectoryListingDependencies.java",
         "FileDependencies.java",
         "FileDependencyDeserializer.java",
         "FileSystemDependencies.java",
+        "ListingDependencies.java",
+        "NestedDependencies.java",
     ],
     deps = [
         ":file_dependency_key_support",
         ":settable_future_with_ownership",
+        "//src/main/java/com/google/devtools/build/lib/concurrent",
         "//src/main/java/com/google/devtools/build/lib/skyframe/serialization",
         "//src/main/java/com/google/devtools/build/lib/vfs:ospathpolicy",
         "//src/main/java/com/google/devtools/build/lib/vfs:pathfragment",
diff --git a/src/main/java/com/google/devtools/build/lib/skyframe/serialization/analysis/FileDependencies.java b/src/main/java/com/google/devtools/build/lib/skyframe/serialization/analysis/FileDependencies.java
index fa2cc52..cb86ff7 100644
--- a/src/main/java/com/google/devtools/build/lib/skyframe/serialization/analysis/FileDependencies.java
+++ b/src/main/java/com/google/devtools/build/lib/skyframe/serialization/analysis/FileDependencies.java
@@ -32,7 +32,7 @@
  * value is invalidated.
  */
 abstract sealed class FileDependencies
-    implements FileSystemDependencies, FileDependencyDeserializer.GetDependenciesResult
+    implements FileSystemDependencies, FileDependencyDeserializer.GetFileDependenciesResult
     permits FileDependencies.SingleResolvedPath,
         FileDependencies.SingleResolvedPathAndDependency,
         FileDependencies.MultiplePaths {
diff --git a/src/main/java/com/google/devtools/build/lib/skyframe/serialization/analysis/FileDependencyDeserializer.java b/src/main/java/com/google/devtools/build/lib/skyframe/serialization/analysis/FileDependencyDeserializer.java
index da50172..afc3571 100644
--- a/src/main/java/com/google/devtools/build/lib/skyframe/serialization/analysis/FileDependencyDeserializer.java
+++ b/src/main/java/com/google/devtools/build/lib/skyframe/serialization/analysis/FileDependencyDeserializer.java
@@ -29,17 +29,22 @@
 import com.github.benmanes.caffeine.cache.Cache;
 import com.github.benmanes.caffeine.cache.Caffeine;
 import com.google.common.base.Function;
+import com.google.common.base.Functions;
 import com.google.common.util.concurrent.AsyncFunction;
+import com.google.common.util.concurrent.FutureCallback;
 import com.google.common.util.concurrent.Futures;
 import com.google.common.util.concurrent.ListenableFuture;
+import com.google.devtools.build.lib.concurrent.QuiescingFuture;
 import com.google.devtools.build.lib.skyframe.serialization.FingerprintValueService;
 import com.google.devtools.build.lib.skyframe.serialization.KeyBytesProvider;
+import com.google.devtools.build.lib.skyframe.serialization.PackedFingerprint;
 import com.google.devtools.build.lib.skyframe.serialization.SerializationException;
 import com.google.devtools.build.lib.skyframe.serialization.StringKey;
 import com.google.devtools.build.lib.skyframe.serialization.proto.DirectoryListingInvalidationData;
 import com.google.devtools.build.lib.skyframe.serialization.proto.FileInvalidationData;
 import com.google.devtools.build.lib.skyframe.serialization.proto.Symlink;
 import com.google.devtools.build.lib.vfs.OsPathPolicy;
+import com.google.protobuf.CodedInputStream;
 import com.google.protobuf.InvalidProtocolBufferException;
 import java.io.IOException;
 import javax.annotation.Nullable;
@@ -68,7 +73,7 @@
  *   <li>Request the file data corresponding to the directory (delegating to {@link
  *       #getFileDependencies}).
  *   <li>{@link WaitForListingFileDependencies}.
- *   <li>Create and cache the {@link DirectoryListingDependencies} instance.
+ *   <li>Create and cache the {@link ListingDependencies} instance.
  * </ol>
  */
 final class FileDependencyDeserializer {
@@ -93,31 +98,34 @@
    * retained by the {@code SkyValue}s that depend on them. When all such associated {@code
    * SkyValue}s are invalidated, the dependency information becomes eligible for GC.
    */
-  private final Cache<String, GetDependenciesResult> fileCache =
+  private final Cache<String, GetFileDependenciesResult> fileCache =
       Caffeine.newBuilder().weakValues().build();
 
   /**
-   * A cache for {@link DirectoryListingDependencies}, primarily for deduplication.
+   * A cache for {@link ListingDependencies}, primarily for deduplication.
    *
    * <p>This follows the design of {@link #fileCache} but is for directory listings.
    */
-  private final Cache<String, GetDirectoryListingDependenciesResult> listingCache =
+  private final Cache<String, GetListingDependenciesResult> listingCache =
+      Caffeine.newBuilder().weakValues().build();
+
+  private final Cache<PackedFingerprint, GetNestedDependenciesResult> nestedCache =
       Caffeine.newBuilder().weakValues().build();
 
   FileDependencyDeserializer(FingerprintValueService fingerprintValueService) {
     this.fingerprintValueService = fingerprintValueService;
   }
 
-  sealed interface GetDependenciesResult permits FileDependencies, FutureFileDependencies {}
+  sealed interface GetFileDependenciesResult permits FileDependencies, FutureFileDependencies {}
 
   /**
    * The main purpose of this class is to act as a {@link ListenableFuture<FileDependencies>}.
    *
    * <p>Its specific type is explicitly visible to clients to allow them to cleanly distinguish it
-   * as a permitted subtype of {@link GetDependenciesResult}.
+   * as a permitted subtype of {@link GetFileDependenciesResult}.
    */
   static final class FutureFileDependencies extends SettableFutureWithOwnership<FileDependencies>
-      implements GetDependenciesResult {}
+      implements GetFileDependenciesResult {}
 
   /**
    * Reconstitutes the set of file dependencies associated with {@code key}.
@@ -129,7 +137,7 @@
    * @return either an immediate {@link FileDependencies} instance or effectively a {@link
    *     ListenableFuture<FileDependencies>} instance.
    */
-  GetDependenciesResult getFileDependencies(String key) {
+  GetFileDependenciesResult getFileDependencies(String key) {
     FutureFileDependencies ownedFuture;
     switch (fileCache.get(key, unused -> new FutureFileDependencies())) {
       case FileDependencies dependencies:
@@ -142,37 +150,36 @@
         break;
     }
     // `ownedFuture` is owned by this thread, which must complete its value.
-    fetchInvalidationData(key, WaitForFileInvalidationData::new, ownedFuture);
+    fetchInvalidationData(key, this::getKeyBytes, WaitForFileInvalidationData::new, ownedFuture);
     return ownedFuture;
   }
 
-  sealed interface GetDirectoryListingDependenciesResult
-      permits DirectoryListingDependencies, FutureDirectoryListingDependencies {}
+  sealed interface GetListingDependenciesResult
+      permits ListingDependencies, FutureListingDependencies {}
 
   /**
-   * The main purpose of this class is to act as a {@link
-   * ListenableFuture<DirectoryListingDependencies>}.
+   * The main purpose of this class is to act as a {@link ListenableFuture<ListingDependencies>}.
    *
    * <p>Its specific type is explicitly visible to clients to allow them to cleanly distinguish it
-   * as a permitted subtype of {@link GetDirectoryListingDependenciesResult}.
+   * as a permitted subtype of {@link GetListingDependenciesResult}.
    */
-  static final class FutureDirectoryListingDependencies
-      extends SettableFutureWithOwnership<DirectoryListingDependencies>
-      implements GetDirectoryListingDependenciesResult {}
+  static final class FutureListingDependencies
+      extends SettableFutureWithOwnership<ListingDependencies>
+      implements GetListingDependenciesResult {}
 
   /**
    * Deserializes the resolved directory listing information associated with {@code key}.
    *
    * @param key should be as described at {@link DirectoryListingInvalidationData}.
-   * @return either an immediate {@link DirectoryListingDependencies} instance or effectively a
-   *     {@link ListenableFuture<DirectoryListingDependencies>} instance.
+   * @return either an immediate {@link ListingDependencies} instance or effectively a {@link
+   *     ListenableFuture<ListingDependencies>} instance.
    */
-  GetDirectoryListingDependenciesResult getDirectoryListingDependencies(String key) {
-    FutureDirectoryListingDependencies ownedFuture;
-    switch (listingCache.get(key, unused -> new FutureDirectoryListingDependencies())) {
-      case DirectoryListingDependencies dependencies:
+  GetListingDependenciesResult getListingDependencies(String key) {
+    FutureListingDependencies ownedFuture;
+    switch (listingCache.get(key, unused -> new FutureListingDependencies())) {
+      case ListingDependencies dependencies:
         return dependencies;
-      case FutureDirectoryListingDependencies future:
+      case FutureListingDependencies future:
         if (!future.tryTakeOwnership()) {
           return future; // Owned by another thread.
         }
@@ -180,7 +187,42 @@
         break;
     }
     // `ownedFuture` is owned by this thread, which must complete its value.
-    fetchInvalidationData(key, WaitForListingInvalidationData::new, ownedFuture);
+    fetchInvalidationData(key, this::getKeyBytes, WaitForListingInvalidationData::new, ownedFuture);
+    return ownedFuture;
+  }
+
+  sealed interface GetNestedDependenciesResult
+      permits NestedDependencies, FutureNestedDependencies {}
+
+  static final class FutureNestedDependencies
+      extends SettableFutureWithOwnership<NestedDependencies>
+      implements GetNestedDependenciesResult {}
+
+  /**
+   * Retrieves the nested dependency information associated with {@code key}.
+   *
+   * <p>Like the other implementations, this can be thought of as a simple state machine. There's
+   * one explicit state represented by {@link WaitForNestedNodeBytes}, which waits for the bytes
+   * associated with {@code key}. There's a second implicit state that waits for child elements,
+   * which may be files, listings or other nested nodes.
+   *
+   * @param key is a fingerprint of the byte representation described at {@link
+   *     FileDependencySerializer#computeNodeBytes}.
+   */
+  GetNestedDependenciesResult getNestedDependencies(PackedFingerprint key) {
+    FutureNestedDependencies ownedFuture;
+    switch (nestedCache.get(key, unused -> new FutureNestedDependencies())) {
+      case NestedDependencies dependencies:
+        return dependencies;
+      case FutureNestedDependencies future:
+        if (!future.tryTakeOwnership()) {
+          return future; // Owned by another thread.
+        }
+        ownedFuture = future;
+        break;
+    }
+    // `ownedFuture` is owned by this thread, which must complete its value.
+    fetchInvalidationData(key, Functions.identity(), WaitForNestedNodeBytes::new, ownedFuture);
     return ownedFuture;
   }
 
@@ -412,10 +454,10 @@
     return !previousParent.startsWith(newParent);
   }
 
-  // ---------- Begin DirectoryListingDependencies deserialization implementation ----------
+  // ---------- Begin ListingDependencies deserialization implementation ----------
 
   private class WaitForListingInvalidationData
-      implements AsyncFunction<byte[], DirectoryListingDependencies> {
+      implements AsyncFunction<byte[], ListingDependencies> {
     private final String key;
 
     private WaitForListingInvalidationData(String key) {
@@ -423,7 +465,7 @@
     }
 
     @Override
-    public ListenableFuture<DirectoryListingDependencies> apply(byte[] bytes)
+    public ListenableFuture<ListingDependencies> apply(byte[] bytes)
         throws InvalidProtocolBufferException {
       var data = DirectoryListingInvalidationData.parseFrom(bytes, getEmptyRegistry());
       if (data.hasOverflowKey() && !data.getOverflowKey().equals(key)) {
@@ -456,7 +498,7 @@
   }
 
   private class WaitForListingFileDependencies
-      implements Function<FileDependencies, DirectoryListingDependencies> {
+      implements Function<FileDependencies, ListingDependencies> {
     private final String key;
 
     private WaitForListingFileDependencies(String key) {
@@ -464,26 +506,173 @@
     }
 
     @Override
-    public DirectoryListingDependencies apply(FileDependencies dependencies) {
+    public ListingDependencies apply(FileDependencies dependencies) {
       return createAndCacheListingDependencies(key, dependencies);
     }
   }
 
-  private DirectoryListingDependencies createAndCacheListingDependencies(
+  private ListingDependencies createAndCacheListingDependencies(
       String key, FileDependencies dependencies) {
-    var result = new DirectoryListingDependencies(dependencies);
+    var result = new ListingDependencies(dependencies);
     listingCache.put(key, result);
     return result;
   }
 
+  // ---------- Begin NestedDependencies deserialization implementation ----------
+
+  private class WaitForNestedNodeBytes implements AsyncFunction<byte[], NestedDependencies> {
+    private final PackedFingerprint key;
+
+    private WaitForNestedNodeBytes(PackedFingerprint key) {
+      this.key = key;
+    }
+
+    /**
+     * Parses the {@code bytes} to create a {@link NestedDependencies} instance.
+     *
+     * <p>Refer to comment at {@link FileDependencySerializer#computeNodeBytes} for the data format.
+     * Uses delegation for children, which might not be completely resolved.
+     */
+    @Override
+    public ListenableFuture<NestedDependencies> apply(byte[] bytes) {
+      try {
+        var codedIn = CodedInputStream.newInstance(bytes);
+        int fileCount = codedIn.readInt32();
+        int listingCount = codedIn.readInt32();
+        int nestedCount = codedIn.readInt32();
+
+        var elements = new FileSystemDependencies[fileCount + listingCount + nestedCount];
+        var countdown = new PendingElementCountdown(key, elements);
+
+        for (int i = 0; i < fileCount; i++) {
+          String key = codedIn.readString();
+          switch (getFileDependencies(key)) {
+            case FileDependencies dependencies:
+              elements[i] = dependencies;
+              break;
+            case FutureFileDependencies future:
+              countdown.registerPendingElement();
+              Futures.addCallback(
+                  future, new WaitingForElement(elements, i, countdown), directExecutor());
+              break;
+          }
+        }
+
+        int directCount = fileCount + listingCount;
+        for (int i = fileCount; i < directCount; i++) {
+          String key = codedIn.readString();
+          switch (getListingDependencies(key)) {
+            case ListingDependencies dependencies:
+              elements[i] = dependencies;
+              break;
+            case FutureListingDependencies future:
+              countdown.registerPendingElement();
+              Futures.addCallback(
+                  future, new WaitingForElement(elements, i, countdown), directExecutor());
+              break;
+          }
+        }
+
+        int total = directCount + nestedCount;
+        for (int i = directCount; i < total; i++) {
+          var key = PackedFingerprint.readFrom(codedIn);
+          switch (getNestedDependencies(key)) {
+            case NestedDependencies dependencies:
+              elements[i] = dependencies;
+              break;
+            case FutureNestedDependencies future:
+              countdown.registerPendingElement();
+              Futures.addCallback(
+                  future, new WaitingForElement(elements, i, countdown), directExecutor());
+              break;
+          }
+        }
+        countdown.notifyInitializationDone();
+        return countdown;
+      } catch (IOException e) {
+        return immediateFailedFuture(e);
+      }
+    }
+  }
+
+  /**
+   * A future that keeps track of the count of elements that still need to be set.
+   *
+   * <p>This future completes once all the elements are set.
+   */
+  private class PendingElementCountdown extends QuiescingFuture<NestedDependencies> {
+    private final PackedFingerprint key;
+    private final FileSystemDependencies[] elements;
+
+    private PendingElementCountdown(PackedFingerprint key, FileSystemDependencies[] elements) {
+      this.key = key;
+      this.elements = elements;
+    }
+
+    private void registerPendingElement() {
+      increment();
+    }
+
+    private void notifyInitializationDone() {
+      decrement();
+    }
+
+    private void notifyElementSuccess() {
+      decrement();
+    }
+
+    private void notifyElementFailure(Throwable e) {
+      notifyException(e);
+    }
+
+    @Override
+    protected NestedDependencies getValue() {
+      var result = new NestedDependencies(elements);
+      nestedCache.put(key, result); // Replaces the future in the cache with the completed value.
+      return result;
+    }
+  }
+
+  /**
+   * Callback that populates the element at {@link #index} upon success.
+   *
+   * <p>Performs required bookkeeping for {@link PendingElementCountdown}.
+   */
+  private static class WaitingForElement implements FutureCallback<FileSystemDependencies> {
+    private final FileSystemDependencies[] elements;
+    private final int index;
+    private final PendingElementCountdown countdown;
+
+    private WaitingForElement(
+        FileSystemDependencies[] elements, int index, PendingElementCountdown countdown) {
+      this.elements = elements;
+      this.index = index;
+      this.countdown = countdown;
+    }
+
+    @Override
+    public void onSuccess(FileSystemDependencies dependencies) {
+      elements[index] = dependencies;
+      countdown.notifyElementSuccess();
+    }
+
+    @Override
+    public void onFailure(Throwable t) {
+      countdown.notifyElementFailure(t);
+    }
+  }
+
   // ---------- Begin shared helpers ----------
 
-  private <T, FutureT extends SettableFutureWithOwnership<T>> void fetchInvalidationData(
-      String key, Function<String, AsyncFunction<byte[], T>> waitFactory, FutureT ownedFuture) {
+  private <KeyT, T, FutureT extends SettableFutureWithOwnership<T>> void fetchInvalidationData(
+      KeyT key,
+      Function<KeyT, ? extends KeyBytesProvider> keyConverter,
+      Function<KeyT, AsyncFunction<byte[], T>> waitFactory,
+      FutureT ownedFuture) {
     try {
       ListenableFuture<byte[]> futureBytes;
       try {
-        futureBytes = fingerprintValueService.get(getKeyBytes(key));
+        futureBytes = fingerprintValueService.get(keyConverter.apply(key));
       } catch (IOException e) {
         ownedFuture.failWith(e);
         return;
diff --git a/src/main/java/com/google/devtools/build/lib/skyframe/serialization/analysis/FileDependencySerializer.java b/src/main/java/com/google/devtools/build/lib/skyframe/serialization/analysis/FileDependencySerializer.java
index bf0eb3a..6255dae 100644
--- a/src/main/java/com/google/devtools/build/lib/skyframe/serialization/analysis/FileDependencySerializer.java
+++ b/src/main/java/com/google/devtools/build/lib/skyframe/serialization/analysis/FileDependencySerializer.java
@@ -32,11 +32,14 @@
 import com.google.devtools.build.lib.analysis.ConfiguredRuleClassProvider.BundledFileSystem;
 import com.google.devtools.build.lib.skyframe.DirectoryListingKey;
 import com.google.devtools.build.lib.skyframe.FileKey;
+import com.google.devtools.build.lib.skyframe.NestedFileSystemOperationNodes;
 import com.google.devtools.build.lib.skyframe.serialization.FingerprintValueService;
 import com.google.devtools.build.lib.skyframe.serialization.KeyBytesProvider;
+import com.google.devtools.build.lib.skyframe.serialization.PackedFingerprint;
 import com.google.devtools.build.lib.skyframe.serialization.StringKey;
-import com.google.devtools.build.lib.skyframe.serialization.analysis.InvalidationDataReference.DirectoryInvalidationDataReference;
 import com.google.devtools.build.lib.skyframe.serialization.analysis.InvalidationDataReference.FileInvalidationDataReference;
+import com.google.devtools.build.lib.skyframe.serialization.analysis.InvalidationDataReference.ListingInvalidationDataReference;
+import com.google.devtools.build.lib.skyframe.serialization.analysis.InvalidationDataReference.NodeInvalidationDataReference;
 import com.google.devtools.build.lib.skyframe.serialization.proto.DirectoryListingInvalidationData;
 import com.google.devtools.build.lib.skyframe.serialization.proto.FileInvalidationData;
 import com.google.devtools.build.lib.skyframe.serialization.proto.Symlink;
@@ -45,22 +48,25 @@
 import com.google.devtools.build.skyframe.InMemoryGraph;
 import com.google.devtools.build.skyframe.InMemoryNodeEntry;
 import com.google.devtools.build.skyframe.Version;
+import com.google.protobuf.CodedOutputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
 import java.util.ArrayList;
 import java.util.List;
+import java.util.TreeSet;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.function.Consumer;
 import javax.annotation.Nullable;
 
 /** Records {@link FileKey} and {@link DirectoryListingKey} invalidation information. */
 final class FileDependencySerializer {
-
   private final VersionNumberExtractor versionExtractor;
   private final InMemoryGraph graph;
   private final FingerprintValueService fingerprintValueService;
 
   private final ConcurrentHashMap<FileKey, FileInvalidationDataReference> fileReferences =
       new ConcurrentHashMap<>();
-  private final ConcurrentHashMap<DirectoryListingKey, DirectoryInvalidationDataReference>
+  private final ConcurrentHashMap<DirectoryListingKey, ListingInvalidationDataReference>
       directoryReferences = new ConcurrentHashMap<>();
 
   interface VersionNumberExtractor {
@@ -171,15 +177,14 @@
   }
 
   @Nullable // null if `key` isn't relevant to invalidation
-  DirectoryInvalidationDataReference registerDependency(DirectoryListingKey key) {
+  ListingInvalidationDataReference registerDependency(DirectoryListingKey key) {
     RootedPath rootedPath = key.argument();
     if (rootedPath.getRoot().getFileSystem() instanceof BundledFileSystem) {
       return null; // This directory doesn't change.
     }
 
-    DirectoryInvalidationDataReference reference =
-        directoryReferences.computeIfAbsent(
-            key, unused -> new DirectoryInvalidationDataReference());
+    ListingInvalidationDataReference reference =
+        directoryReferences.computeIfAbsent(key, unused -> new ListingInvalidationDataReference());
     // Uses double-checked locking to determine ownership of `reference`.
     if (reference.getCacheKey() != null) {
       return reference;
@@ -225,6 +230,59 @@
   }
 
   /**
+   * Registers a dependency on the set of transitive dependencies represented by {@code node}.
+   *
+   * <p>Uploads the result to the {@link #fingerprintValueService}.
+   *
+   * @return an reference having a non-null {@link NodeInvalidationDataReference#getCacheKey} or
+   *     null. In particular, never returns {@link NodeInvalidationDataReference#EMPTY}.
+   */
+  @Nullable // null if `node` isn't relevant to invalidation
+  NodeInvalidationDataReference registerDependency(NestedFileSystemOperationNodes node) {
+    var reference = (NodeInvalidationDataReference) node.getSerializationScratch();
+    if (reference != null) {
+      return reference == NodeInvalidationDataReference.EMPTY ? null : reference;
+    }
+
+    byte[] nodeBytes;
+    var writeStatuses = new ArrayList<ListenableFuture<Void>>();
+    synchronized (node) {
+      reference = (NodeInvalidationDataReference) node.getSerializationScratch();
+      if (reference != null) {
+        return reference == NodeInvalidationDataReference.EMPTY ? null : reference;
+      }
+      // If this is reached, this thread must call `node.setSerializationScratch` to comply with the
+      // double-checked locking contract.
+
+      nodeBytes = computeNodeBytes(node, writeStatuses);
+      if (nodeBytes == null) {
+        node.setSerializationScratch(NodeInvalidationDataReference.EMPTY);
+        return null;
+      }
+
+      reference =
+          NodeInvalidationDataReference.create(fingerprintValueService.fingerprint(nodeBytes));
+      node.setSerializationScratch(reference);
+    }
+
+    // This thread owns completion of the future associated with `reference`.
+    boolean writeStatusSet = false;
+    try {
+      writeStatuses.add(fingerprintValueService.put(reference.getCacheKey(), nodeBytes));
+      reference.setWriteStatus(
+          writeStatuses.size() == 1
+              ? writeStatuses.get(0)
+              : Futures.whenAllSucceed(writeStatuses).call(() -> null, directExecutor()));
+      writeStatusSet = true;
+    } finally {
+      if (!writeStatusSet) {
+        reference.setUnexpectedlyUnsetError();
+      }
+    }
+    return reference;
+  }
+
+  /**
    * Requires that there are no symlink cycles (though ancestor references are benign).
    *
    * <p>This is assumed to hold for builds that succeed.
@@ -289,6 +347,94 @@
     }
   }
 
+  /**
+   * Computes a canonical byte representation of a {@code node}.
+   *
+   * <p>Logically, a node is a set of string file or listing keys, as described at {@link
+   * FileInvalidationData} and {@link DirectoryListingInvalidationData}, respectively, and a set of
+   * {@link NestedFileSystemOperationNodes} fingerprints. The byte representation is specified as
+   * follows.
+   *
+   * <ol>
+   *   <li>The count of file keys, as a proto-encoded int.
+   *   <li>The count of listing keys, as a proto-encoded int.
+   *   <li>The count of nested nodes, as a proto-encoded int.
+   *   <li>Sorted and deduplicated, proto-encoded strings of the file keys.
+   *   <li>Sorted and deduplicated, proto-encoded strings of the listing keys.
+   *   <li>The fingerprints of the {@link NestedFileSystemOperationNodes} byte representations.
+   * </ol>
+   *
+   * <p>More compact formats are possible, but this reduces the complexity of the deserializer.
+   *
+   * @param writeStatuses output parameter collecting transitive write statuses
+   */
+  @Nullable // null if there were no transitive dependencies relevant to invalidation
+  private byte[] computeNodeBytes(
+      NestedFileSystemOperationNodes node, List<ListenableFuture<Void>> writeStatuses) {
+    // Makes no assumptions about the ordering or multiplicity of node dependencies. Sorts and
+    // deduplicates them to make them canonical.
+    var fileKeys = new TreeSet<String>();
+    var listingKeys = new TreeSet<String>();
+    var nodeKeys = new TreeSet<PackedFingerprint>();
+    for (int i = 0; i < node.count(); i++) {
+      switch (node.get(i)) {
+        case FileKey fileKey:
+          FileInvalidationDataReference fileReference = registerDependency(fileKey);
+          if (fileReference != null) {
+            fileKeys.add(checkNotNull(fileReference.getCacheKey(), node));
+            writeStatuses.add(fileReference);
+          }
+          break;
+        case DirectoryListingKey listingKey:
+          ListingInvalidationDataReference listingReference = registerDependency(listingKey);
+          if (listingReference != null) {
+            listingKeys.add(checkNotNull(listingReference.getCacheKey(), node));
+            writeStatuses.add(listingReference);
+          }
+          break;
+        case NestedFileSystemOperationNodes nestedKeys:
+          NodeInvalidationDataReference nodeReference = registerDependency(nestedKeys);
+          if (nodeReference != null) {
+            nodeKeys.add(checkNotNull(nodeReference.getCacheKey(), node));
+            writeStatuses.add(nodeReference);
+          }
+          break;
+      }
+    }
+    if (writeStatuses.isEmpty()) {
+      return null; // Nothing in the transitive closure of `node` is relevant to invalidation.
+    }
+
+    // TODO: b/364831651 - there are multiple ways that result could become unary here, even if
+    // `node` always has at least 2 children. The following may reduce child count.
+    // 1. TreeSet deduplication.
+    // 2. null references.
+    // 3. NestedFileSystemOperationNodes with the same fingerprints.
+    // It may be worth special casing to avoid the additional wrapper if the result is unary.
+
+    try {
+      var bytesOut = new ByteArrayOutputStream();
+      var codedOut = CodedOutputStream.newInstance(bytesOut);
+      codedOut.writeInt32NoTag(fileKeys.size());
+      codedOut.writeInt32NoTag(listingKeys.size());
+      codedOut.writeInt32NoTag(nodeKeys.size());
+      for (String key : fileKeys) {
+        codedOut.writeStringNoTag(key);
+      }
+      for (String key : listingKeys) {
+        codedOut.writeStringNoTag(key);
+      }
+      for (PackedFingerprint fp : nodeKeys) {
+        fp.writeTo(codedOut);
+      }
+      codedOut.flush();
+      bytesOut.flush();
+      return bytesOut.toByteArray();
+    } catch (IOException e) {
+      throw new AssertionError("Unexpected IOException from ByteArrayOutputStream", e);
+    }
+  }
+
   private KeyBytesProvider getKeyBytes(String cacheKey, Consumer<String> overflowConsumer) {
     if (cacheKey.length() > MAX_KEY_LENGTH) {
       overflowConsumer.accept(cacheKey);
diff --git a/src/main/java/com/google/devtools/build/lib/skyframe/serialization/analysis/FileSystemDependencies.java b/src/main/java/com/google/devtools/build/lib/skyframe/serialization/analysis/FileSystemDependencies.java
index 072e135..6841d50 100644
--- a/src/main/java/com/google/devtools/build/lib/skyframe/serialization/analysis/FileSystemDependencies.java
+++ b/src/main/java/com/google/devtools/build/lib/skyframe/serialization/analysis/FileSystemDependencies.java
@@ -14,12 +14,14 @@
 package com.google.devtools.build.lib.skyframe.serialization.analysis;
 
 /**
- * Union type for {@link FileDependencies} and {@link DirectoryListingDependencies}.
+ * Union type for {@link FileDependencies}, {@link ListingDependencies} and {@link
+ * NestedDependencies}.
  *
- * <p>At a structural level these two classes are very similar and {@link
- * DirectoryListingDependencies} could be modeled plainly as {@link FileDependencies}. The crucial
- * difference is that {@link FileDependencies#containsMatch(ImmutableSet<String>)} takes a set of
- * files and {@link DirectoryListingDependencies#matchesAnyDirectories(ImmutableSet<String>)} takes
- * a set of directory names so the two types are deliberately separated.
+ * <p>At a structural level {@link FileDependencies} and {@link ListingDependencies} are very
+ * similar. {@link ListingDependencies} could be modeled plainly as {@link FileDependencies}. The
+ * crucial difference is that {@link FileDependencies#containsMatch(ImmutableSet<String>)} takes a
+ * set of files and {@link ListingDependencies#matchesAnyDirectories(ImmutableSet<String>)} takes a
+ * set of directory names so the two types are deliberately separated.
  */
-sealed interface FileSystemDependencies permits FileDependencies, DirectoryListingDependencies {}
+sealed interface FileSystemDependencies
+    permits FileDependencies, ListingDependencies, NestedDependencies {}
diff --git a/src/main/java/com/google/devtools/build/lib/skyframe/serialization/analysis/InvalidationDataReference.java b/src/main/java/com/google/devtools/build/lib/skyframe/serialization/analysis/InvalidationDataReference.java
index 51302b2..ab088fd 100644
--- a/src/main/java/com/google/devtools/build/lib/skyframe/serialization/analysis/InvalidationDataReference.java
+++ b/src/main/java/com/google/devtools/build/lib/skyframe/serialization/analysis/InvalidationDataReference.java
@@ -13,35 +13,42 @@
 // limitations under the License.
 package com.google.devtools.build.lib.skyframe.serialization.analysis;
 
+import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.base.Preconditions.checkState;
 
 import com.google.common.util.concurrent.AbstractFuture;
 import com.google.common.util.concurrent.ListenableFuture;
+import com.google.devtools.build.lib.skyframe.serialization.PackedFingerprint;
 import com.google.devtools.build.lib.vfs.RootedPath;
 import javax.annotation.Nullable;
 
 /**
- * Future representing transitive write status for file or directory invalidation data.
+ * Future representing the transitive write status of invalidation data.
  *
- * <p>The client should perform double-checked locking to populate some of the metadata by testing
- * {@link #getCacheKey} for nullness. If it is null outside of the lock, and null again inside the
- * lock, the caller must call either one of the {@code populate} methods depending on the subclass.
+ * <p>In the case of {@link FileInvalidationDataReference} and {@link
+ * ListingInvalidationDataReference}, the client should perform double-checked locking to populate
+ * the metadata by testing {@link #getCacheKey} for nullness. If it is null outside of the lock, and
+ * null again inside the lock, the caller must call one of the {@code populate} methods depending on
+ * the subclass.
+ *
+ * <p>For {@link NodeInvalidationDataReference}, the metadata is assigned at construction.
  */
-sealed class InvalidationDataReference extends AbstractFuture<Void>
+sealed class InvalidationDataReference<T> extends AbstractFuture<Void>
     permits InvalidationDataReference.FileInvalidationDataReference,
-        InvalidationDataReference.DirectoryInvalidationDataReference {
-  private volatile String cacheKey;
+        InvalidationDataReference.ListingInvalidationDataReference,
+        InvalidationDataReference.NodeInvalidationDataReference {
+  private volatile T cacheKey;
 
   @Nullable // null when uninitialized
-  String getCacheKey() {
+  final T getCacheKey() {
     return cacheKey;
   }
 
-  void setWriteStatus(ListenableFuture<Void> writeStatus) {
+  final void setWriteStatus(ListenableFuture<Void> writeStatus) {
     checkState(setFuture(writeStatus), "already set %s", this);
   }
 
-  void setUnexpectedlyUnsetError() {
+  final void setUnexpectedlyUnsetError() {
     checkState(
         setException(
             new IllegalStateException(
@@ -49,11 +56,12 @@
                     + " FileDependencySerializer")));
   }
 
-  void setCacheKeyForSubclasses(String cacheKey) {
+  final void setCacheKeyForSubclasses(T cacheKey) {
     this.cacheKey = cacheKey;
   }
 
-  static final class FileInvalidationDataReference extends InvalidationDataReference {
+  /** Future transitive write status for {@link com.google.devtools.build.lib.skyframe.FileKey}. */
+  static final class FileInvalidationDataReference extends InvalidationDataReference<String> {
     private long mtsv;
     private RootedPath realPath;
 
@@ -89,9 +97,41 @@
     }
   }
 
-  static final class DirectoryInvalidationDataReference extends InvalidationDataReference {
+  /**
+   * Future transitive write status for {@link
+   * com.google.devtools.build.lib.skyframe.DirectoryListingKey}.
+   */
+  static final class ListingInvalidationDataReference extends InvalidationDataReference<String> {
     void populate(String cacheKey) {
       setCacheKeyForSubclasses(cacheKey);
     }
   }
+
+  /**
+   * Future transitive write status of {@link
+   * com.google.devtools.build.lib.skyframe.NestedFileSystemOperationNodes}.
+   *
+   * <p>{@link #getCacheKey} is null only for {@link #EMPTY}.
+   */
+  static final class NodeInvalidationDataReference
+      extends InvalidationDataReference<PackedFingerprint> {
+    /* In contrast to the above two implementations, there's no distinct populate phase because
+     * this class does not need to be constructed inside a computeIfAbsent callback.
+     */
+
+    /**
+     * A no-data sentinel value.
+     *
+     * <p>Occurs if none of the transitive node inputs are relevant to invalidation.
+     */
+    static final NodeInvalidationDataReference EMPTY = new NodeInvalidationDataReference(null);
+
+    static NodeInvalidationDataReference create(PackedFingerprint key) {
+      return new NodeInvalidationDataReference(checkNotNull(key));
+    }
+
+    private NodeInvalidationDataReference(PackedFingerprint key) {
+      setCacheKeyForSubclasses(key);
+    }
+  }
 }
diff --git a/src/main/java/com/google/devtools/build/lib/skyframe/serialization/analysis/DirectoryListingDependencies.java b/src/main/java/com/google/devtools/build/lib/skyframe/serialization/analysis/ListingDependencies.java
similarity index 86%
rename from src/main/java/com/google/devtools/build/lib/skyframe/serialization/analysis/DirectoryListingDependencies.java
rename to src/main/java/com/google/devtools/build/lib/skyframe/serialization/analysis/ListingDependencies.java
index 402694b..a1c0b09 100644
--- a/src/main/java/com/google/devtools/build/lib/skyframe/serialization/analysis/DirectoryListingDependencies.java
+++ b/src/main/java/com/google/devtools/build/lib/skyframe/serialization/analysis/ListingDependencies.java
@@ -18,12 +18,11 @@
 import com.google.common.collect.ImmutableSet;
 
 /** Type representing a directory listing operation. */
-final class DirectoryListingDependencies
-    implements FileDependencyDeserializer.GetDirectoryListingDependenciesResult,
-        FileSystemDependencies {
+final class ListingDependencies
+    implements FileDependencyDeserializer.GetListingDependenciesResult, FileSystemDependencies {
   private final FileDependencies realDirectory;
 
-  DirectoryListingDependencies(FileDependencies realDirectory) {
+  ListingDependencies(FileDependencies realDirectory) {
     this.realDirectory = realDirectory;
   }
 
diff --git a/src/main/java/com/google/devtools/build/lib/skyframe/serialization/analysis/NestedDependencies.java b/src/main/java/com/google/devtools/build/lib/skyframe/serialization/analysis/NestedDependencies.java
new file mode 100644
index 0000000..d054632
--- /dev/null
+++ b/src/main/java/com/google/devtools/build/lib/skyframe/serialization/analysis/NestedDependencies.java
@@ -0,0 +1,49 @@
+// Copyright 2024 The Bazel Authors. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+package com.google.devtools.build.lib.skyframe.serialization.analysis;
+
+import static com.google.common.base.MoreObjects.toStringHelper;
+import static com.google.common.base.Preconditions.checkArgument;
+
+import java.util.Arrays;
+
+/**
+ * A representation of a recursively composable set of {@link FileSystemDependencies}.
+ *
+ * <p>This corresponds to a previously serialized {@link
+ * com.google.devtools.build.lib.skyframe.NestedFileSystemOperationNodes} instance, but this
+ * implementation is mostly decoupled from Bazel code.
+ */
+final class NestedDependencies
+    implements FileSystemDependencies, FileDependencyDeserializer.GetNestedDependenciesResult {
+  private final FileSystemDependencies[] elements;
+
+  NestedDependencies(FileSystemDependencies[] elements) {
+    checkArgument(elements.length > 1, "expected at least length 2, was %s", elements.length);
+    this.elements = elements;
+  }
+
+  int count() {
+    return elements.length;
+  }
+
+  FileSystemDependencies getElement(int index) {
+    return elements[index];
+  }
+
+  @Override
+  public String toString() {
+    return toStringHelper(this).add("elements", Arrays.asList(elements)).toString();
+  }
+}