Correctly handle missing data in FileDependencyDeserializer.

This change introduces explicit representations for missing data for the
different FileSystemsDependencies implementations. It adds a corresponding
type, AlwaysMatch to serve as a match type when there is missing data.

PiperOrigin-RevId: 775236289
Change-Id: Id20011c1971eee789b3817537c2d7569583e4107
diff --git a/src/main/java/com/google/devtools/build/lib/skyframe/serialization/analysis/AlwaysMatch.java b/src/main/java/com/google/devtools/build/lib/skyframe/serialization/analysis/AlwaysMatch.java
new file mode 100644
index 0000000..11f3bdf
--- /dev/null
+++ b/src/main/java/com/google/devtools/build/lib/skyframe/serialization/analysis/AlwaysMatch.java
@@ -0,0 +1,37 @@
+// Copyright 2025 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 com.google.devtools.build.lib.skyframe.serialization.analysis.FileOpMatchResultTypes.FileOpMatchResult;
+import com.google.devtools.build.lib.skyframe.serialization.analysis.NestedMatchResultTypes.NestedMatchResult;
+
+/**
+ * The delta matches any set of dependencies, meaning a <b>cache miss</b>.
+ *
+ * <p>This occurs when there is missing data, and thus no guarantee can be made about the cache
+ * entry's validity.
+ */
+enum AlwaysMatch implements FileOpMatchResult, NestedMatchResult, MatchIndicator {
+  ALWAYS_MATCH_RESULT;
+
+  @Override
+  public boolean isMatch() {
+    return true;
+  }
+
+  @Override
+  public final int version() {
+    return VersionedChanges.ALWAYS_MATCH;
+  }
+}
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 0b3968c..9d4d902 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
@@ -165,8 +165,10 @@
 java_library(
     name = "versioned_changes_validator",
     srcs = [
+        "AlwaysMatch.java",
         "FileOpMatchMemoizingLookup.java",
         "FileOpMatchResultTypes.java",
+        "MatchIndicator.java",
         "NestedMatchMemoizingLookup.java",
         "NestedMatchResultTypes.java",
         "NoMatch.java",
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 c7595c2..c8c41e9 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
@@ -74,6 +74,10 @@
     return new Builder(firstResolvedPath);
   }
 
+  static FileDependencies newMissingInstance() {
+    return new MissingFileDependencies();
+  }
+
   static final class Builder {
     private final ArrayList<String> paths = new ArrayList<>();
     private final ArrayList<FileDependencies> dependencies = new ArrayList<>();
@@ -124,6 +128,11 @@
     }
 
     @Override
+    public boolean isMissingData() {
+      return false;
+    }
+
+    @Override
     int findEarliestMatch(VersionedChanges changes, int validityHorizon) {
       return changes.matchFileChange(resolvedPath, validityHorizon);
     }
@@ -164,6 +173,11 @@
     }
 
     @Override
+    public boolean isMissingData() {
+      return false;
+    }
+
+    @Override
     int findEarliestMatch(VersionedChanges changes, int validityHorizon) {
       return changes.matchFileChange(resolvedPath, validityHorizon);
     }
@@ -211,6 +225,11 @@
     }
 
     @Override
+    public boolean isMissingData() {
+      return false;
+    }
+
+    @Override
     int findEarliestMatch(VersionedChanges changes, int validityHorizon) {
       int minMatch = VersionedChanges.NO_MATCH;
       for (String element : resolvedPaths) {
@@ -250,4 +269,44 @@
           .toString();
     }
   }
+
+  /**
+   * Signals missing data in the nested set of dependencies.
+   *
+   * <p>This is deliberately not a singleton to avoid a memory leak in the weak-value caches in
+   * {@link FileDependencyDeserializer}.
+   */
+  private static final class MissingFileDependencies extends FileDependencies {
+    private MissingFileDependencies() {}
+
+    @Override
+    public boolean isMissingData() {
+      return true;
+    }
+
+    @Override
+    int findEarliestMatch(VersionedChanges changes, int validityHorizon) {
+      return VersionedChanges.ALWAYS_MATCH;
+    }
+
+    @Override
+    int getDependencyCount() {
+      return 0;
+    }
+
+    @Override
+    FileDependencies getDependency(int index) {
+      throw new UnsupportedOperationException();
+    }
+
+    @Override
+    String resolvedPath() {
+      throw new UnsupportedOperationException();
+    }
+
+    @Override
+    ImmutableList<String> getAllResolvedPathsForTesting() {
+      throw new UnsupportedOperationException();
+    }
+  }
 }
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 cb1fd26..1aa3f42 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
@@ -261,6 +261,10 @@
     @Override
     public ListenableFuture<FileDependencies> apply(byte[] bytes)
         throws InvalidProtocolBufferException {
+      if (bytes == null) {
+        return immediateFuture(FileDependencies.newMissingInstance());
+      }
+
       var data = FileInvalidationData.parseFrom(bytes, getEmptyRegistry());
       if (data.hasOverflowKey() && !data.getOverflowKey().equals(key)) {
         return immediateFailedFuture(
@@ -485,6 +489,10 @@
     @Override
     public ListenableFuture<ListingDependencies> apply(byte[] bytes)
         throws InvalidProtocolBufferException {
+      if (bytes == null) {
+        return immediateFuture(ListingDependencies.newMissingInstance());
+      }
+
       var data = DirectoryListingInvalidationData.parseFrom(bytes, getEmptyRegistry());
       if (data.hasOverflowKey() && !data.getOverflowKey().equals(key)) {
         return immediateFailedFuture(
@@ -499,7 +507,7 @@
 
       String path = key.substring(pathBegin);
       if (path.isEmpty()) {
-        return immediateFuture(new ListingDependencies(ROOT_FILE));
+        return immediateFuture(ListingDependencies.from(ROOT_FILE));
       }
 
       String fileKey =
@@ -509,9 +517,9 @@
               FILE_KEY_DELIMITER);
       switch (getFileDependencies(fileKey)) {
         case FileDependencies dependencies:
-          return immediateFuture(new ListingDependencies(dependencies));
+          return immediateFuture(ListingDependencies.from(dependencies));
         case FutureFileDependencies future:
-          return Futures.transform(future, ListingDependencies::new, directExecutor());
+          return Futures.transform(future, ListingDependencies::from, directExecutor());
       }
     }
   }
@@ -529,6 +537,10 @@
      */
     @Override
     public ListenableFuture<NestedDependencies> apply(byte[] bytes) {
+      if (bytes == null) {
+        return immediateFuture(NestedDependencies.newMissingInstance());
+      }
+
       try {
         boolean usesZstdCompression = MagicBytes.hasMagicBytes(bytes);
         CodedInputStream codedIn;
@@ -649,7 +661,7 @@
 
     @Override
     protected NestedDependencies getValue() {
-      return new NestedDependencies(elements, sources);
+      return NestedDependencies.from(elements, sources);
     }
   }
 
diff --git a/src/main/java/com/google/devtools/build/lib/skyframe/serialization/analysis/FileOpMatchMemoizingLookup.java b/src/main/java/com/google/devtools/build/lib/skyframe/serialization/analysis/FileOpMatchMemoizingLookup.java
index bc8a216..f6914a2 100644
--- a/src/main/java/com/google/devtools/build/lib/skyframe/serialization/analysis/FileOpMatchMemoizingLookup.java
+++ b/src/main/java/com/google/devtools/build/lib/skyframe/serialization/analysis/FileOpMatchMemoizingLookup.java
@@ -14,6 +14,7 @@
 package com.google.devtools.build.lib.skyframe.serialization.analysis;
 
 import static com.google.common.util.concurrent.MoreExecutors.directExecutor;
+import static com.google.devtools.build.lib.skyframe.serialization.analysis.AlwaysMatch.ALWAYS_MATCH_RESULT;
 import static java.lang.Math.min;
 
 import com.google.common.util.concurrent.FutureCallback;
@@ -64,21 +65,23 @@
 
   private FileOpMatchResultOrFuture populateFutureFileOpMatchResult(
       int validityHorizon, FutureFileOpMatchResult ownedFuture) {
-    switch (ownedFuture.key()) {
-      case FileDependencies file:
-        return aggregateAnyAdditionalFileDependencies(
-            file.findEarliestMatch(changes, validityHorizon), file, validityHorizon, ownedFuture);
-      case ListingDependencies listing:
+    return switch (ownedFuture.key()) {
+      case FileDependencies file ->
+          aggregateAnyAdditionalFileDependencies(
+              file.findEarliestMatch(changes, validityHorizon), file, validityHorizon, ownedFuture);
+      case ListingDependencies.AvailableListingDependencies listing -> {
         // Matches the listing (files inside the directory changed).
         int version = listing.findEarliestMatch(changes, validityHorizon);
         // Then matches the directory itself.
         FileDependencies realDirectory = listing.realDirectory();
-        return aggregateAnyAdditionalFileDependencies(
+        yield aggregateAnyAdditionalFileDependencies(
             min(version, realDirectory.findEarliestMatch(changes, validityHorizon)),
             realDirectory,
             validityHorizon,
             ownedFuture);
-    }
+      }
+      case ListingDependencies.MissingListingDependencies missingListing -> ALWAYS_MATCH_RESULT;
+    };
   }
 
   private FileOpMatchResultOrFuture aggregateAnyAdditionalFileDependencies(
diff --git a/src/main/java/com/google/devtools/build/lib/skyframe/serialization/analysis/FileOpMatchResultTypes.java b/src/main/java/com/google/devtools/build/lib/skyframe/serialization/analysis/FileOpMatchResultTypes.java
index e1d0f22..1816b67 100644
--- a/src/main/java/com/google/devtools/build/lib/skyframe/serialization/analysis/FileOpMatchResultTypes.java
+++ b/src/main/java/com/google/devtools/build/lib/skyframe/serialization/analysis/FileOpMatchResultTypes.java
@@ -13,6 +13,7 @@
 // limitations under the License.
 package com.google.devtools.build.lib.skyframe.serialization.analysis;
 
+import static com.google.devtools.build.lib.skyframe.serialization.analysis.AlwaysMatch.ALWAYS_MATCH_RESULT;
 import static com.google.devtools.build.lib.skyframe.serialization.analysis.NoMatch.NO_MATCH_RESULT;
 
 import com.google.devtools.build.lib.concurrent.SettableFutureKeyedValue;
@@ -26,17 +27,26 @@
   sealed interface FileOpMatchResultOrFuture permits FileOpMatchResult, FutureFileOpMatchResult {}
 
   /** An immediate result. */
-  sealed interface FileOpMatchResult extends FileOpMatchResultOrFuture
-      permits NoMatch, FileOpMatch {
+  sealed interface FileOpMatchResult extends FileOpMatchResultOrFuture, MatchIndicator
+      permits FileOpMatch, NoMatch, AlwaysMatch {
     static FileOpMatchResult create(int version) {
-      return version == VersionedChanges.NO_MATCH ? NO_MATCH_RESULT : new FileOpMatch(version);
+      return switch (version) {
+        case VersionedChanges.NO_MATCH -> NO_MATCH_RESULT;
+        case VersionedChanges.ALWAYS_MATCH -> ALWAYS_MATCH_RESULT;
+        default -> new FileOpMatch(version);
+      };
     }
 
     int version();
   }
 
-  /** A result signalling a match. */
-  record FileOpMatch(int version) implements FileOpMatchResult {}
+  /** A result signaling a match. */
+  record FileOpMatch(int version) implements FileOpMatchResult {
+    @Override
+    public boolean isMatch() {
+      return true;
+    }
+  }
 
   /** A future result. */
   static final class FutureFileOpMatchResult
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 f806051..27f88fc 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
@@ -28,4 +28,14 @@
   /** Dependencies, excluding nested dependencies. */
   sealed interface FileOpDependency extends FileSystemDependencies
       permits FileDependencies, ListingDependencies {}
+
+  /**
+   * True if data was missing for this dependency.
+   *
+   * <p>Dependencies are fetched from a remote cache without durability guarantees. It's possible
+   * for the corresponding data to be missing. Any missing data induces missing data on anything
+   * that references it. From an invalidation perspective, if {@link isMissingData} is true, the
+   * dependency should never allow a cache hit and always signal matching everything.
+   */
+  boolean isMissingData();
 }
diff --git a/src/main/java/com/google/devtools/build/lib/skyframe/serialization/analysis/ListingDependencies.java b/src/main/java/com/google/devtools/build/lib/skyframe/serialization/analysis/ListingDependencies.java
index d53a7a6..d930bf8 100644
--- a/src/main/java/com/google/devtools/build/lib/skyframe/serialization/analysis/ListingDependencies.java
+++ b/src/main/java/com/google/devtools/build/lib/skyframe/serialization/analysis/ListingDependencies.java
@@ -16,41 +16,77 @@
 import static com.google.common.base.MoreObjects.toStringHelper;
 
 /** Type representing a directory listing operation. */
-final class ListingDependencies
+abstract sealed class ListingDependencies
     implements FileSystemDependencies.FileOpDependency,
-        FileDependencyDeserializer.ListingDependenciesOrFuture {
-  private final FileDependencies realDirectory;
+        FileDependencyDeserializer.ListingDependenciesOrFuture
+    permits ListingDependencies.AvailableListingDependencies,
+        ListingDependencies.MissingListingDependencies {
 
-  ListingDependencies(FileDependencies realDirectory) {
-    this.realDirectory = realDirectory;
+  static ListingDependencies from(FileDependencies realDirectory) {
+    if (realDirectory.isMissingData()) {
+      return newMissingInstance();
+    }
+    return new AvailableListingDependencies(realDirectory);
+  }
+
+  static ListingDependencies newMissingInstance() {
+    return new MissingListingDependencies();
+  }
+
+  static final class AvailableListingDependencies extends ListingDependencies {
+    private final FileDependencies realDirectory;
+
+    private AvailableListingDependencies(FileDependencies realDirectory) {
+      this.realDirectory = realDirectory;
+    }
+
+    @Override
+    public boolean isMissingData() {
+      return false;
+    }
+
+    /**
+     * Determines if this listing is invalidated by anything in {@code changes}.
+     *
+     * <p>The caller should ensure the following.
+     *
+     * <ul>
+     *   <li>This listing is known to be valid at {@code validityHorizon} (VH).
+     *   <li>All changes over the range {@code (VH, VC])} are registered with {@code changes} before
+     *       calling this method. (VC is the synced version of the cache reader.)
+     * </ul>
+     *
+     * <p>See description of {@link VersionedChanges} for more details.
+     *
+     * @return the earliest version where a matching (invalidating) change is identified, otherwise
+     *     {@link VersionedChanges#NO_MATCH}.
+     */
+    int findEarliestMatch(VersionedChanges changes, int validityHorizon) {
+      return changes.matchListingChange(realDirectory.resolvedPath(), validityHorizon);
+    }
+
+    FileDependencies realDirectory() {
+      return realDirectory;
+    }
+
+    @Override
+    public String toString() {
+      return toStringHelper(this).add("realDirectory", realDirectory).toString();
+    }
   }
 
   /**
-   * Determines if this listing is invalidated by anything in {@code changes}.
+   * Signals missing listing data.
    *
-   * <p>The caller should ensure the following.
-   *
-   * <ul>
-   *   <li>This listing is known to be valid at {@code validityHorizon} (VH).
-   *   <li>All changes over the range {@code (VH, VC])} are registered with {@code changes} before
-   *       calling this method. (VC is the synced version of the cache reader.)
-   * </ul>
-   *
-   * <p>See description of {@link VersionedChanges} for more details.
-   *
-   * @return the earliest version where a matching (invalidating) change is identified, otherwise
-   *     {@link VersionedChanges#NO_MATCH}.
+   * <p>This is deliberately not a singleton to avoid a memory leak in the weak-value caches in
+   * {@link FileDependencyDeserializer}.
    */
-  int findEarliestMatch(VersionedChanges changes, int validityHorizon) {
-    return changes.matchListingChange(realDirectory.resolvedPath(), validityHorizon);
-  }
+  static final class MissingListingDependencies extends ListingDependencies {
+    private MissingListingDependencies() {}
 
-  FileDependencies realDirectory() {
-    return realDirectory;
-  }
-
-  @Override
-  public String toString() {
-    return toStringHelper(this).add("realDirectory", realDirectory).toString();
+    @Override
+    public boolean isMissingData() {
+      return true;
+    }
   }
 }
diff --git a/src/main/java/com/google/devtools/build/lib/skyframe/serialization/analysis/MatchIndicator.java b/src/main/java/com/google/devtools/build/lib/skyframe/serialization/analysis/MatchIndicator.java
new file mode 100644
index 0000000..4f2d18b
--- /dev/null
+++ b/src/main/java/com/google/devtools/build/lib/skyframe/serialization/analysis/MatchIndicator.java
@@ -0,0 +1,19 @@
+// Copyright 2025 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;
+
+/** Specifies whether a certain match result indicates a match or not. */
+interface MatchIndicator {
+  boolean isMatch();
+}
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
index affd209..1f5bf15 100644
--- 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
@@ -16,6 +16,7 @@
 import static com.google.common.base.MoreObjects.toStringHelper;
 import static com.google.common.base.Preconditions.checkArgument;
 
+import com.google.common.annotations.VisibleForTesting;
 import java.util.Arrays;
 import java.util.Collection;
 
@@ -26,52 +27,97 @@
  * com.google.devtools.build.lib.skyframe.AbstractNestedFileOpNodes} instance, but this
  * implementation is mostly decoupled from Bazel code.
  */
-final class NestedDependencies
-    implements FileSystemDependencies, FileDependencyDeserializer.NestedDependenciesOrFuture {
-  // While formally possible, we don't anticipate analysisDependencies being empty often. `sources`
-  // could be frequently empty.
+abstract sealed class NestedDependencies
+    implements FileSystemDependencies, FileDependencyDeserializer.NestedDependenciesOrFuture
+    permits NestedDependencies.AvailableNestedDependencies,
+        NestedDependencies.MissingNestedDependencies {
+  // While formally possible, we don't anticipate analysisDependencies being empty often.
+  // `sources` could be frequently empty.
   static final FileDependencies[] EMPTY_SOURCES = new FileDependencies[0];
 
-  private final FileSystemDependencies[] analysisDependencies;
-  private final FileDependencies[] sources;
-
-  NestedDependencies(FileSystemDependencies[] analysisDependencies, FileDependencies[] sources) {
-    checkArgument(
-        analysisDependencies.length >= 1 || sources.length >= 1,
-        "analysisDependencies and sources both empty");
-    this.analysisDependencies = analysisDependencies;
-    this.sources = sources;
+  static NestedDependencies from(
+      FileSystemDependencies[] analysisDependencies, FileDependencies[] sources) {
+    for (FileSystemDependencies dep : analysisDependencies) {
+      if (dep.isMissingData()) {
+        return new MissingNestedDependencies();
+      }
+    }
+    for (FileDependencies dep : sources) {
+      if (dep.isMissingData()) {
+        return new MissingNestedDependencies();
+      }
+    }
+    return new AvailableNestedDependencies(analysisDependencies, sources);
   }
 
-  NestedDependencies(
+  @VisibleForTesting
+  static NestedDependencies from(
       Collection<? extends FileSystemDependencies> analysisDependencies,
       Collection<FileDependencies> sources) {
-    this(
+    return from(
         analysisDependencies.toArray(FileSystemDependencies[]::new),
         sources.toArray(FileDependencies[]::new));
   }
 
-  int analysisDependenciesCount() {
-    return analysisDependencies.length;
+  static NestedDependencies newMissingInstance() {
+    return new MissingNestedDependencies();
   }
 
-  FileSystemDependencies getAnalysisDependency(int index) {
-    return analysisDependencies[index];
+  static final class AvailableNestedDependencies extends NestedDependencies {
+    private final FileSystemDependencies[] analysisDependencies;
+    private final FileDependencies[] sources;
+
+    AvailableNestedDependencies(
+        FileSystemDependencies[] analysisDependencies, FileDependencies[] sources) {
+      checkArgument(
+          analysisDependencies.length >= 1 || sources.length >= 1,
+          "analysisDependencies and sources both empty");
+      this.analysisDependencies = analysisDependencies;
+      this.sources = sources;
+    }
+
+    @Override
+    public boolean isMissingData() {
+      return false;
+    }
+
+    int analysisDependenciesCount() {
+      return analysisDependencies.length;
+    }
+
+    FileSystemDependencies getAnalysisDependency(int index) {
+      return analysisDependencies[index];
+    }
+
+    int sourcesCount() {
+      return sources.length;
+    }
+
+    FileDependencies getSource(int index) {
+      return sources[index];
+    }
+
+    @Override
+    public String toString() {
+      return toStringHelper(this)
+          .add("analysisDependencies", Arrays.asList(analysisDependencies))
+          .add("sources", Arrays.asList(sources))
+          .toString();
+    }
   }
 
-  int sourcesCount() {
-    return sources.length;
-  }
+  /**
+   * Signals missing data in the nested set of dependencies.
+   *
+   * <p>This is deliberately not a singleton to avoid a memory leak in the weak-value caches in
+   * {@link FileDependencyDeserializer}.
+   */
+  static final class MissingNestedDependencies extends NestedDependencies {
+    private MissingNestedDependencies() {}
 
-  FileDependencies getSource(int index) {
-    return sources[index];
-  }
-
-  @Override
-  public String toString() {
-    return toStringHelper(this)
-        .add("analysisDependencies", Arrays.asList(analysisDependencies))
-        .add("sources", Arrays.asList(sources))
-        .toString();
+    @Override
+    public boolean isMissingData() {
+      return true;
+    }
   }
 }
diff --git a/src/main/java/com/google/devtools/build/lib/skyframe/serialization/analysis/NestedMatchMemoizingLookup.java b/src/main/java/com/google/devtools/build/lib/skyframe/serialization/analysis/NestedMatchMemoizingLookup.java
index 14133fa..1f187aa 100644
--- a/src/main/java/com/google/devtools/build/lib/skyframe/serialization/analysis/NestedMatchMemoizingLookup.java
+++ b/src/main/java/com/google/devtools/build/lib/skyframe/serialization/analysis/NestedMatchMemoizingLookup.java
@@ -14,6 +14,7 @@
 package com.google.devtools.build.lib.skyframe.serialization.analysis;
 
 import static com.google.common.util.concurrent.MoreExecutors.directExecutor;
+import static com.google.devtools.build.lib.skyframe.serialization.analysis.AlwaysMatch.ALWAYS_MATCH_RESULT;
 import static com.google.devtools.build.lib.skyframe.serialization.analysis.NestedMatchResultTypes.createNestedMatchResult;
 import static com.google.devtools.build.lib.skyframe.serialization.analysis.NoMatch.NO_MATCH_RESULT;
 
@@ -74,41 +75,46 @@
 
   private NestedMatchResultOrFuture populateFutureNestedMatchResult(
       int validityHorizon, FutureNestedMatchResult ownedFuture) {
-    NestedDependencies nested = ownedFuture.key();
-    var aggregator = new NestedFutureResultAggregator();
-    for (int i = 0; i < nested.analysisDependenciesCount(); i++) {
-      switch (nested.getAnalysisDependency(i)) {
-        case FileOpDependency dependency:
-          aggregator.addAnalysisResultOrFuture(
-              fileOpMatches.getValueOrFuture(dependency, validityHorizon));
-          break;
-        case NestedDependencies child:
-          // In a common case, the cache reader sends a single top-level request that traverses the
-          // full set of dependencies and waits for that request to complete. Parallelizes
-          // recursive traversal of child nodes to avoid being singly-threaded in this scenario.
-          aggregator.signalNestedTaskAdded();
-          executor.execute(
-              () -> {
-                switch (getValueOrFuture(child, validityHorizon)) {
-                  case NestedMatchResult result:
-                    aggregator.addNestedResult(result);
-                    aggregator.signalNestedTaskComplete();
-                    break;
-                  case FutureNestedMatchResult future:
-                    // The aggregator decrements when the future completes.
-                    aggregator.addFutureNestedMatchResult(future);
-                    break;
-                }
-              });
-          break;
+    return switch (ownedFuture.key()) {
+      case NestedDependencies.AvailableNestedDependencies nested -> {
+        var aggregator = new NestedFutureResultAggregator();
+        for (int i = 0; i < nested.analysisDependenciesCount(); i++) {
+          switch (nested.getAnalysisDependency(i)) {
+            case FileOpDependency dependency:
+              aggregator.addAnalysisResultOrFuture(
+                  fileOpMatches.getValueOrFuture(dependency, validityHorizon));
+              break;
+            case NestedDependencies child:
+              // In a common case, the cache reader sends a single top-level request that traverses
+              // the full set of dependencies and waits for that request to complete. Parallelizes
+              // recursive traversal of child nodes to avoid being singly-threaded in this scenario.
+              aggregator.signalNestedTaskAdded();
+              executor.execute(
+                  () -> {
+                    switch (getValueOrFuture(child, validityHorizon)) {
+                      case NestedMatchResult result:
+                        aggregator.addNestedResult(result);
+                        aggregator.signalNestedTaskComplete();
+                        break;
+                      case FutureNestedMatchResult future:
+                        // The aggregator decrements when the future completes.
+                        aggregator.addFutureNestedMatchResult(future);
+                        break;
+                    }
+                  });
+              break;
+          }
+        }
+        for (int i = 0; i < nested.sourcesCount(); i++) {
+          aggregator.addSourceResultOrFuture(
+              fileOpMatches.getValueOrFuture(nested.getSource(i), validityHorizon));
+        }
+        aggregator.notifyAllDependenciesAdded();
+        yield ownedFuture.completeWith(aggregator);
       }
-    }
-    for (int i = 0; i < nested.sourcesCount(); i++) {
-      aggregator.addSourceResultOrFuture(
-          fileOpMatches.getValueOrFuture(nested.getSource(i), validityHorizon));
-    }
-    aggregator.notifyAllDependenciesAdded();
-    return ownedFuture.completeWith(aggregator);
+      case NestedDependencies.MissingNestedDependencies missing ->
+          ownedFuture.completeWith(ALWAYS_MATCH_RESULT);
+    };
   }
 
   private static final class NestedFutureResultAggregator
@@ -160,6 +166,9 @@
       switch (result) {
         case NO_MATCH_RESULT:
           break;
+        case ALWAYS_MATCH_RESULT:
+          earliestAnalysisMatch = VersionedChanges.ALWAYS_MATCH;
+          break;
         case AnalysisMatch(int version):
           updateAnalysisVersionIfEarlier(version);
           break;
diff --git a/src/main/java/com/google/devtools/build/lib/skyframe/serialization/analysis/NestedMatchResultTypes.java b/src/main/java/com/google/devtools/build/lib/skyframe/serialization/analysis/NestedMatchResultTypes.java
index 229e813..c9ae5e5 100644
--- a/src/main/java/com/google/devtools/build/lib/skyframe/serialization/analysis/NestedMatchResultTypes.java
+++ b/src/main/java/com/google/devtools/build/lib/skyframe/serialization/analysis/NestedMatchResultTypes.java
@@ -26,7 +26,7 @@
 
   /** An immediate match result. */
   sealed interface NestedMatchResult extends NestedMatchResultOrFuture
-      permits AnalysisMatch, SourceMatch, AnalysisAndSourceMatch, NoMatch {}
+      permits AnalysisMatch, SourceMatch, AnalysisAndSourceMatch, NoMatch, AlwaysMatch {}
 
   static NestedMatchResult createNestedMatchResult(int analysisVersion, int sourceVersion) {
     if (analysisVersion <= sourceVersion) {
@@ -47,7 +47,12 @@
    *
    * @param version the minimum version where the analysis match was observed
    */
-  static record AnalysisMatch(int version) implements NestedMatchResult {}
+  static record AnalysisMatch(int version) implements NestedMatchResult, MatchIndicator {
+    @Override
+    public boolean isMatch() {
+      return true;
+    }
+  }
 
   /**
    * The delta didn't match analysis dependencies, but matched source dependencies, indicating a
@@ -68,7 +73,7 @@
    * @param sourceVersion the minimum version where the source match was observed.
    */
   record AnalysisAndSourceMatch(int analysisVersion, int sourceVersion)
-      implements NestedMatchResult {
+      implements NestedMatchResult, MatchIndicator {
     public AnalysisAndSourceMatch {
       checkArgument(
           sourceVersion < analysisVersion,
@@ -76,6 +81,11 @@
           sourceVersion,
           analysisVersion);
     }
+
+    @Override
+    public boolean isMatch() {
+      return true;
+    }
   }
 
   /** A future match result. */
diff --git a/src/main/java/com/google/devtools/build/lib/skyframe/serialization/analysis/NoMatch.java b/src/main/java/com/google/devtools/build/lib/skyframe/serialization/analysis/NoMatch.java
index 979adda..67de8a7 100644
--- a/src/main/java/com/google/devtools/build/lib/skyframe/serialization/analysis/NoMatch.java
+++ b/src/main/java/com/google/devtools/build/lib/skyframe/serialization/analysis/NoMatch.java
@@ -17,10 +17,15 @@
 import com.google.devtools.build.lib.skyframe.serialization.analysis.NestedMatchResultTypes.NestedMatchResult;
 
 /** The delta didn't match the set of dependencies, meaning a <b>cache hit</b>. */
-enum NoMatch implements FileOpMatchResult, NestedMatchResult {
+enum NoMatch implements FileOpMatchResult, NestedMatchResult, MatchIndicator {
   NO_MATCH_RESULT;
 
   @Override
+  public boolean isMatch() {
+    return false;
+  }
+
+  @Override
   public final int version() {
     return VersionedChanges.NO_MATCH;
   }
diff --git a/src/main/java/com/google/devtools/build/lib/skyframe/serialization/analysis/VersionedChanges.java b/src/main/java/com/google/devtools/build/lib/skyframe/serialization/analysis/VersionedChanges.java
index 1de695c..8f3c756 100644
--- a/src/main/java/com/google/devtools/build/lib/skyframe/serialization/analysis/VersionedChanges.java
+++ b/src/main/java/com/google/devtools/build/lib/skyframe/serialization/analysis/VersionedChanges.java
@@ -79,6 +79,13 @@
    */
   static final int CLIENT_CHANGE = Integer.MAX_VALUE - 1;
 
+  /**
+   * Version indicating a match at any change.
+   *
+   * <p>Used when there is missing data and correct invalidation is impossible.
+   */
+  static final int ALWAYS_MATCH = -1;
+
   // TODO: b/364831651 - if sorted int[] does not scale, it can be replaced with TreeSet<Integer>
   // but we expect the number of changes per entry to be small.
   private final ConcurrentHashMap<String, int[]> fileChanges = new ConcurrentHashMap<>();
diff --git a/src/test/java/com/google/devtools/build/lib/skyframe/serialization/analysis/ControllableFileDependencies.java b/src/test/java/com/google/devtools/build/lib/skyframe/serialization/analysis/ControllableFileDependencies.java
index 8c487f9..e921fb9 100644
--- a/src/test/java/com/google/devtools/build/lib/skyframe/serialization/analysis/ControllableFileDependencies.java
+++ b/src/test/java/com/google/devtools/build/lib/skyframe/serialization/analysis/ControllableFileDependencies.java
@@ -36,6 +36,11 @@
     this.dependencies = dependencies;
   }
 
+  @Override
+  public boolean isMissingData() {
+    return false;
+  }
+
   void awaitEarliestMatchEntered() throws InterruptedException {
     findEarliestMatchEntered.await();
   }
diff --git a/src/test/java/com/google/devtools/build/lib/skyframe/serialization/analysis/FileOpMatchMemoizingLookupTest.java b/src/test/java/com/google/devtools/build/lib/skyframe/serialization/analysis/FileOpMatchMemoizingLookupTest.java
index c043dda..92166cb 100644
--- a/src/test/java/com/google/devtools/build/lib/skyframe/serialization/analysis/FileOpMatchMemoizingLookupTest.java
+++ b/src/test/java/com/google/devtools/build/lib/skyframe/serialization/analysis/FileOpMatchMemoizingLookupTest.java
@@ -14,6 +14,7 @@
 package com.google.devtools.build.lib.skyframe.serialization.analysis;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.devtools.build.lib.skyframe.serialization.analysis.AlwaysMatch.ALWAYS_MATCH_RESULT;
 import static com.google.devtools.build.lib.skyframe.serialization.analysis.NoMatch.NO_MATCH_RESULT;
 
 import com.google.common.collect.ImmutableList;
@@ -143,7 +144,7 @@
   public void matchingListing_matchesContainedFileChange() {
     changes.registerFileChange("dir/a", 100);
 
-    var key = new ListingDependencies(FileDependencies.builder("dir").build());
+    var key = ListingDependencies.from(FileDependencies.builder("dir").build());
     assertThat(getLookupResult(key, 99)).isEqualTo(new FileOpMatch(100));
   }
 
@@ -151,7 +152,7 @@
   public void matchListingChange_matchesDirectoryChange() {
     changes.registerFileChange("dir", 100);
 
-    var key = new ListingDependencies(FileDependencies.builder("dir").build());
+    var key = ListingDependencies.from(FileDependencies.builder("dir").build());
     assertThat(getLookupResult(key, 99)).isEqualTo(new FileOpMatch(100));
   }
 
@@ -160,7 +161,7 @@
     changes.registerFileChange("dep/a", 100);
 
     var key =
-        new ListingDependencies(
+        ListingDependencies.from(
             FileDependencies.builder("dir")
                 .addDependency(FileDependencies.builder("dep/a").build())
                 .build());
@@ -168,6 +169,24 @@
     assertThat(getLookupResult(key, 99)).isEqualTo(new FileOpMatch(100));
   }
 
+  @Test
+  public void invalidation_missingFile() {
+    FileDependencies missingFile = FileDependencies.newMissingInstance();
+
+    var result = lookup.getValueOrFuture(missingFile, 99);
+
+    assertThat(result).isEqualTo(ALWAYS_MATCH_RESULT);
+  }
+
+  @Test
+  public void invalidation_missingListing() {
+    ListingDependencies missingListing = ListingDependencies.newMissingInstance();
+
+    var result = lookup.getValueOrFuture(missingListing, 99);
+
+    assertThat(result).isEqualTo(ALWAYS_MATCH_RESULT);
+  }
+
   private FileOpMatchResult getLookupResult(FileOpDependency key, int validityHorizon) {
     try {
       switch (lookup.getValueOrFuture(key, validityHorizon)) {
diff --git a/src/test/java/com/google/devtools/build/lib/skyframe/serialization/analysis/FileSystemDependenciesTest.java b/src/test/java/com/google/devtools/build/lib/skyframe/serialization/analysis/FileSystemDependenciesTest.java
index 76798b5..6c39933 100644
--- a/src/test/java/com/google/devtools/build/lib/skyframe/serialization/analysis/FileSystemDependenciesTest.java
+++ b/src/test/java/com/google/devtools/build/lib/skyframe/serialization/analysis/FileSystemDependenciesTest.java
@@ -18,6 +18,7 @@
 import static com.google.devtools.build.lib.skyframe.serialization.analysis.VersionedChanges.NO_MATCH;
 
 import com.google.common.collect.ImmutableList;
+import com.google.devtools.build.lib.skyframe.serialization.analysis.ListingDependencies.AvailableListingDependencies;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.junit.runners.JUnit4;
@@ -60,7 +61,9 @@
   @Test
   public void listingDependencies_findEarliestMatch_matchesClientChange() {
     var changes = new VersionedChanges(ImmutableList.of("abc/def"));
-    var dependencies = new ListingDependencies(FileDependencies.builder("abc").build());
+    var dependencies =
+        (AvailableListingDependencies)
+            ListingDependencies.from(FileDependencies.builder("abc").build());
 
     assertThat(dependencies.findEarliestMatch(changes, 0)).isEqualTo(CLIENT_CHANGE);
   }
@@ -71,7 +74,9 @@
     changes.registerFileChange("abc/def", 99);
     changes.registerFileChange("abc/ghi", 100);
 
-    var dependencies = new ListingDependencies(FileDependencies.builder("abc").build());
+    var dependencies =
+        (AvailableListingDependencies)
+            ListingDependencies.from(FileDependencies.builder("abc").build());
 
     assertThat(dependencies.findEarliestMatch(changes, 100)).isEqualTo(NO_MATCH);
     assertThat(dependencies.findEarliestMatch(changes, 99)).isEqualTo(100);
diff --git a/src/test/java/com/google/devtools/build/lib/skyframe/serialization/analysis/NestedMatchMemoizingLookupTest.java b/src/test/java/com/google/devtools/build/lib/skyframe/serialization/analysis/NestedMatchMemoizingLookupTest.java
index 9e3f9cf..ce787e1 100644
--- a/src/test/java/com/google/devtools/build/lib/skyframe/serialization/analysis/NestedMatchMemoizingLookupTest.java
+++ b/src/test/java/com/google/devtools/build/lib/skyframe/serialization/analysis/NestedMatchMemoizingLookupTest.java
@@ -14,6 +14,7 @@
 package com.google.devtools.build.lib.skyframe.serialization.analysis;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.devtools.build.lib.skyframe.serialization.analysis.AlwaysMatch.ALWAYS_MATCH_RESULT;
 import static com.google.devtools.build.lib.skyframe.serialization.analysis.NestedMatchResultTypes.createNestedMatchResult;
 import static com.google.devtools.build.lib.skyframe.serialization.analysis.NoMatch.NO_MATCH_RESULT;
 import static com.google.devtools.build.lib.skyframe.serialization.analysis.VersionedChanges.NO_MATCH;
@@ -80,7 +81,7 @@
     changes.registerFileChange("src/a", 98);
 
     var key =
-        new NestedDependencies(
+        NestedDependencies.from(
             ImmutableList.of(
                 FileDependencies.builder("dep/a").build(),
                 FileDependencies.builder("dep/b").build(),
@@ -115,7 +116,7 @@
     var depB = new ControllableFileDependencies(ImmutableList.of("dep/b"), ImmutableList.of());
     var depC = new ControllableFileDependencies(ImmutableList.of("dep/c"), ImmutableList.of());
     var srcA = new ControllableFileDependencies(ImmutableList.of("src/a"), ImmutableList.of());
-    var key = new NestedDependencies(ImmutableList.of(depA, depB, depC), ImmutableList.of(srcA));
+    var key = NestedDependencies.from(ImmutableList.of(depA, depB, depC), ImmutableList.of(srcA));
 
     var pool = new ForkJoinPool(4); // one for each dependency and source
     pool.execute(
@@ -173,14 +174,14 @@
     changes.registerFileChange("src/a", 102);
 
     var nestedDep =
-        new NestedDependencies(
+        NestedDependencies.from(
             ImmutableList.of(
                 FileDependencies.builder("dep/b").build(),
                 FileDependencies.builder("dep/c").build()),
             ImmutableList.of(FileDependencies.builder("src/a").build()));
 
     var key =
-        new NestedDependencies(
+        NestedDependencies.from(
             ImmutableList.of(FileDependencies.builder("dep/a").build(), nestedDep),
             ImmutableList.of());
 
@@ -208,14 +209,14 @@
     changes.registerFileChange("src/a", 102);
 
     var nestedDep =
-        new NestedDependencies(
+        NestedDependencies.from(
             ImmutableList.of(
                 FileDependencies.builder("dep/b").build(),
                 FileDependencies.builder("dep/c").build()),
             ImmutableList.of(FileDependencies.builder("src/a").build()));
 
     var key =
-        new NestedDependencies(
+        NestedDependencies.from(
             ImmutableList.of(FileDependencies.builder("dep/a").build(), nestedDep),
             ImmutableList.of());
 
@@ -284,6 +285,15 @@
     assertThat(result).isEqualTo(NO_MATCH_RESULT);
   }
 
+  @Test
+  public void invalidation_missingNested() throws Exception {
+    NestedDependencies missingNested = NestedDependencies.newMissingInstance();
+
+    var lookupResult = lookup.getValueOrFuture(missingNested, 99);
+
+    assertThat(lookupResult).isEqualTo(ALWAYS_MATCH_RESULT);
+  }
+
   private NestedMatchResult getLookupResult(NestedDependencies key, int validityHorizon) {
     try {
       switch (lookup.getValueOrFuture(key, validityHorizon)) {
@@ -317,7 +327,7 @@
   }
 
   private static NestedDependencies createNestedDependencies(FileDependencies fileDependency) {
-    return new NestedDependencies(
+    return NestedDependencies.from(
         new FileSystemDependencies[] {fileDependency}, NestedDependencies.EMPTY_SOURCES);
   }
 }
diff --git a/src/test/java/com/google/devtools/build/lib/skyframe/serialization/analysis/VersionedChangesValidatorTest.java b/src/test/java/com/google/devtools/build/lib/skyframe/serialization/analysis/VersionedChangesValidatorTest.java
index 879ac25..770b4fb 100644
--- a/src/test/java/com/google/devtools/build/lib/skyframe/serialization/analysis/VersionedChangesValidatorTest.java
+++ b/src/test/java/com/google/devtools/build/lib/skyframe/serialization/analysis/VersionedChangesValidatorTest.java
@@ -70,7 +70,7 @@
     changes.registerFileChange("src/a", 98);
 
     var key =
-        new NestedDependencies(
+        NestedDependencies.from(
             ImmutableList.of(
                 FileDependencies.builder("dep/a").build(),
                 FileDependencies.builder("dep/b").build(),
@@ -91,14 +91,14 @@
     changes.registerFileChange("dep/b", 102);
 
     var keyA =
-        new NestedDependencies(
+        NestedDependencies.from(
             ImmutableList.of(
                 FileDependencies.builder("shared").build(),
                 FileDependencies.builder("dep/a").build()),
             ImmutableList.of());
 
     var keyB =
-        new NestedDependencies(
+        NestedDependencies.from(
             ImmutableList.of(
                 FileDependencies.builder("shared").build(),
                 FileDependencies.builder("dep/b").build()),