This is an automated email from the ASF dual-hosted git repository.

voonhous pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/hudi.git


The following commit(s) were added to refs/heads/master by this push:
     new 7f35e7eedcc0 test(spark): guard against executor .hoodie access and 
task payload growth on the read path (#20112)
7f35e7eedcc0 is described below

commit 7f35e7eedcc002a5a1dd5f68b530a0911dc74d90
Author: Y Ethan Guo <[email protected]>
AuthorDate: Tue Oct 6 23:07:45 2026 -0700

    test(spark): guard against executor .hoodie access and task payload growth 
on the read path (#20112)
    
    * test(spark): guard against executor .hoodie access and task payload 
growth on the read path
    
    * Replace the meta folder access recorder with a general file system call 
recorder
    
    * Enable the partition listing rows now that #20080 is merged
    
    * Move the guards that wait on open fixes out of this change and read every 
query in one meta folder test now that #20080 is merged
    
    * Guard what every read task deserializes with per-read-shape size budgets 
and against per-file Hadoop default resource parsing
    
    * Check the task size budget on version 6 COPY_ON_WRITE reads too
    
    * Read tables with a history of 50 commits and check the task size budget 
on COPY_ON_WRITE incremental and time travel reads
    
    * Keep MERGE_ON_READ log files uncompacted with the history, forbid any 
timeline per task, check that data skipping reads the column stats index, and 
drop unused recorder methods
    
    * Key the task size budgets on columnar and row reads measured on Spark 3.5 
and Spark 4.0
    
    * Set the task size budgets per Spark and Scala binary version at about 
1.1x the sizes measured on each
    
    * Prune files with data skipping in strict mode, time travel to the first 
commit, and check the task size budget on every query of both table versions 
and types
    
    * Check MERGE_ON_READ time travel against the row read budget and that time 
travel sees only the first commit
    
    * Keep the once per executor scan state apart from per-task streams, forbid 
timeline state per task, and record file system calls of cached file systems 
and task-started threads
---
 .../testutils/TaskDeserializationRecorder.java     | 340 ++++++++++++
 .../apache/hudi/testutils/SparkExecutorGuards.java | 515 ++++++++++++++++++
 .../functional/TestSparkReadExecutorFootprint.java | 597 +++++++++++++++++++++
 3 files changed, 1452 insertions(+)

diff --git 
a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/testutils/TaskDeserializationRecorder.java
 
b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/testutils/TaskDeserializationRecorder.java
new file mode 100644
index 000000000000..8b24d4753e76
--- /dev/null
+++ 
b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/testutils/TaskDeserializationRecorder.java
@@ -0,0 +1,340 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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 org.apache.hudi.testutils;
+
+import java.io.ObjectInputFilter;
+import java.io.ObjectInputStream;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.TreeSet;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.function.Function;
+import java.util.function.Predicate;
+import java.util.function.Supplier;
+import java.util.stream.Collectors;
+
+/**
+ * Records what engine tasks Java-deserialize, through a JVM-wide {@link 
ObjectInputFilter}.
+ *
+ * <p>An engine ships each task its closure (for Spark, the task binary 
holding the RDD and the
+ * function to run) as a Java-serialized stream that every task deserializes 
again. Whatever a
+ * closure captures therefore costs deserialization CPU and memory per task, 
which is why heavy
+ * driver-side objects (a table meta client, its timeline, a full Hadoop 
configuration) must not be
+ * captured. A functional test uses this recorder to assert on the classes 
tasks deserialize and on
+ * the size of the largest task stream.
+ *
+ * <p>Streams read under an exempt stack frame (for Spark, a broadcast 
variable fetch, which is
+ * deserialized once per executor rather than once per task) are recorded 
separately and do not
+ * count towards {@link Result#getMaxStreamBytes()}.
+ *
+ * <p>The filter never rejects anything: it always answers {@link 
ObjectInputFilter.Status#UNDECIDED},
+ * so installing it does not change what deserializes. It is installed once 
per JVM and stays
+ * installed; outside {@link #start}/{@link #stop} it only reads a volatile 
field.
+ */
+public final class TaskDeserializationRecorder implements ObjectInputFilter {
+
+  private static final TaskDeserializationRecorder INSTANCE = new 
TaskDeserializationRecorder();
+  private static final int MAX_STACK_FRAMES = 30;
+  private static final Set<String> CLASS_DESCRIPTOR_METHODS = new 
HashSet<>(Arrays.asList(
+      "filterCheck", "readClassDesc", "readNonProxyDesc", "readProxyDesc", 
"readClassDescriptor"));
+
+  private volatile Session session;
+
+  private TaskDeserializationRecorder() {
+  }
+
+  /**
+   * Installs the recorder as the JVM-wide serialization filter if it is not 
installed yet.
+   *
+   * @throws IllegalStateException if another JVM-wide filter is already 
installed; the JDK accepts
+   *                               only one per JVM, so this recorder cannot 
be chained after it
+   */
+  public static synchronized TaskDeserializationRecorder install() {
+    ObjectInputFilter current = ObjectInputFilter.Config.getSerialFilter();
+    if (current == INSTANCE) {
+      return INSTANCE;
+    }
+    if (current != null) {
+      throw new IllegalStateException("A JVM-wide serialization filter is 
already installed (" + current
+          + ", e.g. from -Djdk.serialFilter), and the JDK allows only one per 
JVM, so "
+          + TaskDeserializationRecorder.class.getSimpleName() + " cannot 
record task deserialization. "
+          + "Run this test without that filter.");
+    }
+    ObjectInputFilter.Config.setSerialFilter(INSTANCE);
+    return INSTANCE;
+  }
+
+  /**
+   * Starts recording, keeping what each task scope deserializes apart so that 
{@link #stop(Predicate)}
+   * can keep only the scopes of the work under test, for example the stages 
of one job.
+   *
+   * @param taskScope     the scope of the task the current thread runs, or 
null outside tasks
+   * @param exemptFrameKind the kind of deserialization that is not per task, 
such as a broadcast
+   *                      variable fetch, that a stack frame of the given 
class marks, or null
+   */
+  public synchronized void start(Supplier<String> taskScope, Function<String, 
String> exemptFrameKind) {
+    if (session != null) {
+      throw new IllegalStateException("A recording is already in progress");
+    }
+    session = new Session(taskScope, exemptFrameKind);
+  }
+
+  /**
+   * Stops recording and returns what the task scopes accepted by {@code 
keepScope} deserialized
+   * since {@link #start}.
+   */
+  public synchronized Result stop(Predicate<String> keepScope) {
+    Session current = session;
+    if (current == null) {
+      throw new IllegalStateException("No recording in progress");
+    }
+    session = null;
+    return new Result(current, keepScope);
+  }
+
+  @Override
+  public Status checkInput(FilterInfo filterInfo) {
+    Session current = session;
+    if (current == null) {
+      return Status.UNDECIDED;
+    }
+    try {
+      String scope = current.taskScope.get();
+      if (scope != null) {
+        ScopeRecord record = current.scopes.computeIfAbsent(scope, s -> new 
ScopeRecord());
+        Class<?> clazz = filterInfo.serialClass();
+        String exemptKind = StackWalker.getInstance().walk(frames -> frames
+            .map(frame -> current.exemptFrameKind.apply(frame.getClassName()))
+            .filter(Objects::nonNull)
+            .findFirst()
+            .orElse(null));
+        // A Class object in the stream, such as the runtime class of a 
ClassTag, is not an instance.
+        boolean instance = clazz != null && !isClassReference();
+        if (exemptKind != null) {
+          // A stream reports a class once, so this counts the exempt 
deserializations reading the class.
+          if (instance) {
+            record.exemptDeserializations.computeIfAbsent(exemptKind, kind -> 
new ConcurrentHashMap<>())
+                .computeIfAbsent(clazz, c -> new 
AtomicLong()).incrementAndGet();
+          }
+        } else {
+          if (instance && record.classes.add(clazz)) {
+            record.firstStacks.putIfAbsent(clazz, stackOfCaller());
+          }
+          record.maxStreamBytes.accumulateAndGet(filterInfo.streamBytes(), 
Math::max);
+        }
+      }
+    } catch (Throwable t) {
+      // Recording must never interfere with deserialization.
+      current.errors.incrementAndGet();
+    }
+    return Status.UNDECIDED;
+  }
+
+  /**
+   * Whether the class descriptor being checked belongs to a {@code Class} 
object in the stream, for
+   * example the runtime class of a {@code ClassTag}, rather than to an 
instance being deserialized.
+   */
+  private static boolean isClassReference() {
+    return StackWalker.getInstance().walk(frames -> frames
+        .filter(frame -> 
frame.getClassName().equals(ObjectInputStream.class.getName()))
+        .map(StackWalker.StackFrame::getMethodName)
+        .filter(method -> !CLASS_DESCRIPTOR_METHODS.contains(method))
+        .findFirst()
+        .map("readClass"::equals)
+        .orElse(false));
+  }
+
+  private static String stackOfCaller() {
+    return StackWalker.getInstance().walk(frames -> frames
+        .skip(1)
+        .filter(frame -> 
!frame.getClassName().startsWith("java.io.ObjectInputStream")
+            && 
!frame.getClassName().equals(TaskDeserializationRecorder.class.getName()))
+        .limit(MAX_STACK_FRAMES)
+        .map(frame -> "\tat " + frame)
+        .collect(Collectors.joining("\n")));
+  }
+
+  /**
+   * Whether {@code clazz} is the class named {@code className} or extends or 
implements it.
+   */
+  public static boolean isSubtypeOf(Class<?> clazz, String className) {
+    if (clazz == null) {
+      return false;
+    }
+    if (clazz.getName().equals(className) || 
isSubtypeOf(clazz.getSuperclass(), className)) {
+      return true;
+    }
+    for (Class<?> iface : clazz.getInterfaces()) {
+      if (isSubtypeOf(iface, className)) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  private static final class Session {
+    private final Supplier<String> taskScope;
+    private final Function<String, String> exemptFrameKind;
+    private final Map<String, ScopeRecord> scopes = new ConcurrentHashMap<>();
+    private final AtomicLong errors = new AtomicLong();
+
+    private Session(Supplier<String> taskScope, Function<String, String> 
exemptFrameKind) {
+      this.taskScope = taskScope;
+      this.exemptFrameKind = exemptFrameKind;
+    }
+  }
+
+  private static final class ScopeRecord {
+    private final Set<Class<?>> classes = ConcurrentHashMap.newKeySet();
+    private final Map<Class<?>, String> firstStacks = new 
ConcurrentHashMap<>();
+    private final Map<String, Map<Class<?>, AtomicLong>> 
exemptDeserializations = new ConcurrentHashMap<>();
+    private final AtomicLong maxStreamBytes = new AtomicLong();
+  }
+
+  /**
+   * What tasks deserialized during one recording.
+   */
+  public static final class Result {
+    private final Set<Class<?>> classes;
+    private final Map<Class<?>, String> firstStacks;
+    private final Map<String, Map<Class<?>, Long>> exemptDeserializations;
+    private final Set<String> keptScopes;
+    private final Set<String> ignoredScopes;
+    private final long maxStreamBytes;
+    private final long errors;
+
+    private Result(Session session, Predicate<String> keepScope) {
+      Set<Class<?>> keptClasses = new HashSet<>();
+      Map<Class<?>, String> keptFirstStacks = new HashMap<>();
+      Map<String, Map<Class<?>, Long>> keptExemptDeserializations = new 
HashMap<>();
+      Set<String> kept = new TreeSet<>();
+      Set<String> ignored = new TreeSet<>();
+      long maxBytes = 0;
+      for (Map.Entry<String, ScopeRecord> entry : session.scopes.entrySet()) {
+        if (keepScope.test(entry.getKey())) {
+          kept.add(entry.getKey());
+          keptClasses.addAll(entry.getValue().classes);
+          entry.getValue().firstStacks.forEach(keptFirstStacks::putIfAbsent);
+          entry.getValue().exemptDeserializations.forEach((kind, counts) -> 
counts.forEach((clazz, count) ->
+              keptExemptDeserializations.computeIfAbsent(kind, k -> new 
HashMap<>()).merge(clazz, count.get(), Long::sum)));
+          maxBytes = Math.max(maxBytes, entry.getValue().maxStreamBytes.get());
+        } else {
+          ignored.add(entry.getKey());
+        }
+      }
+      this.classes = Collections.unmodifiableSet(keptClasses);
+      this.firstStacks = Collections.unmodifiableMap(keptFirstStacks);
+      this.exemptDeserializations = 
Collections.unmodifiableMap(keptExemptDeserializations);
+      this.keptScopes = Collections.unmodifiableSet(kept);
+      this.ignoredScopes = Collections.unmodifiableSet(ignored);
+      this.maxStreamBytes = maxBytes;
+      this.errors = session.errors.get();
+    }
+
+    /**
+     * How many deserializations of the given exempt kind read an instance of 
the class named
+     * {@code className}.
+     */
+    public long getExemptDeserializations(String kind, String className) {
+      return exemptDeserializations.getOrDefault(kind, 
Collections.emptyMap()).entrySet().stream()
+          .filter(entry -> entry.getKey().getName().equals(className))
+          .mapToLong(Map.Entry::getValue)
+          .sum();
+    }
+
+    /**
+     * Names of the classes that are or extend {@code className} and that 
deserializations of the
+     * given exempt kind read an instance of.
+     */
+    public List<String> exemptNamesOfSubtypesOf(String kind, String className) 
{
+      return exemptDeserializations.getOrDefault(kind, 
Collections.emptyMap()).keySet().stream()
+          .filter(clazz -> isSubtypeOf(clazz, className))
+          .map(Class::getName)
+          .sorted()
+          .collect(Collectors.toList());
+    }
+
+    /**
+     * Where a task first deserialized the class named {@code className} 
outside exempt frames, or
+     * null if no task did.
+     */
+    public String getFirstStack(String className) {
+      return firstStacks.entrySet().stream()
+          .filter(entry -> entry.getKey().getName().equals(className))
+          .map(Map.Entry::getValue)
+          .findFirst().orElse(null);
+    }
+
+    /**
+     * The task scopes whose deserialization this result holds.
+     */
+    public Set<String> getKeptScopes() {
+      return keptScopes;
+    }
+
+    /**
+     * The task scopes recorded but left out of this result.
+     */
+    public Set<String> getIgnoredScopes() {
+      return ignoredScopes;
+    }
+
+    /**
+     * Classes deserialized by tasks outside exempt frames.
+     */
+    public Set<Class<?>> getClasses() {
+      return classes;
+    }
+
+    /**
+     * The largest byte count any task stream outside exempt frames had 
consumed when the filter
+     * was consulted, a close lower bound of the largest per-task stream.
+     */
+    public long getMaxStreamBytes() {
+      return maxStreamBytes;
+    }
+
+    /**
+     * How many filter callbacks failed to record; non-zero means the result 
is incomplete.
+     */
+    public long getErrors() {
+      return errors;
+    }
+
+    /**
+     * Names of the classes deserialized outside exempt frames that are {@code 
className} or extend
+     * or implement it. Matching by name lets a caller check for classes it 
cannot compile against.
+     */
+    public List<String> namesOfSubtypesOf(String className) {
+      return classes.stream()
+          .filter(clazz -> isSubtypeOf(clazz, className))
+          .map(Class::getName)
+          .sorted()
+          .collect(Collectors.toList());
+    }
+  }
+}
diff --git 
a/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/testutils/SparkExecutorGuards.java
 
b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/testutils/SparkExecutorGuards.java
new file mode 100644
index 000000000000..1caf74b3411c
--- /dev/null
+++ 
b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/testutils/SparkExecutorGuards.java
@@ -0,0 +1,515 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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 org.apache.hudi.testutils;
+
+import org.apache.hudi.hadoop.fs.RecordingLocalFileSystem;
+import org.apache.hudi.hadoop.fs.RecordingLocalFileSystem.Call;
+
+import org.apache.hadoop.conf.Configuration;
+import org.apache.hadoop.fs.FileSystem;
+import org.apache.spark.SparkContext;
+import org.apache.spark.SparkEnv;
+import org.apache.spark.TaskContext;
+import org.apache.spark.TaskFailedReason;
+import org.apache.spark.api.java.JavaSparkContext;
+import org.apache.spark.api.plugin.DriverPlugin;
+import org.apache.spark.api.plugin.ExecutorPlugin;
+import org.apache.spark.api.plugin.SparkPlugin;
+import org.apache.spark.rdd.RDD;
+import org.apache.spark.sql.Dataset;
+
+import java.io.IOException;
+import java.io.ObjectOutputStream;
+import java.io.OutputStream;
+import java.io.UncheckedIOException;
+import java.net.URI;
+import java.net.URL;
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Deque;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Queue;
+import java.util.Set;
+import java.util.UUID;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentLinkedQueue;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.BooleanSupplier;
+import java.util.function.Predicate;
+import java.util.function.Supplier;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+
+import scala.reflect.ClassTag$;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+
+/**
+ * Guards for what Spark tasks do on executors: that they do not touch a 
table's {@code .hoodie}
+ * folder, that they do not deserialize heavy driver-side objects with the 
task closure, and that
+ * they do not parse the Hadoop default resources for every file they read.
+ *
+ * <p>The guards need the work under test to run as Spark tasks in this JVM, 
which a local session
+ * does.
+ */
+public final class SparkExecutorGuards {
+
+  /**
+   * The attempt id of the Spark task that is running on, or that started, the 
current thread, set
+   * by {@link TaskStartHookPlugin}. Threads a task creates inherit it.
+   */
+  private static final InheritableThreadLocal<Long> TASK_ATTEMPT = new 
InheritableThreadLocal<>();
+  private static final Set<Long> RUNNING_TASK_ATTEMPTS = 
ConcurrentHashMap.newKeySet();
+
+  /**
+   * Whether the current thread runs a Spark task, or was started by a Spark 
task that is still
+   * running (needs {@link TaskStartHookPlugin}). The thread name also covers 
the part of a task run
+   * that precedes its {@link TaskContext}, namely deserializing the task and 
its partition.
+   */
+  public static final BooleanSupplier IN_SPARK_TASK = () -> {
+    if (TaskContext.get() != null || 
Thread.currentThread().getName().startsWith("Executor task launch worker")) {
+      return true;
+    }
+    Long startedBy = TASK_ATTEMPT.get();
+    return startedBy != null && RUNNING_TASK_ATTEMPTS.contains(startedBy);
+  };
+
+  /**
+   * The kind of deserialization a broadcast variable fetch is, which happens 
once per executor rather
+   * than once per task.
+   */
+  public static final String BROADCAST_FETCH = "broadcast fetch";
+
+  /**
+   * The kind of deserialization the value of the file group reader's broadcast
+   * {@code JavaSerializedValue} is: the scan state, deserialized once per JVM 
instance of the holder.
+   */
+  public static final String SCAN_STATE = "scan state";
+
+  private static final String BROADCAST_CLASS = 
"org.apache.spark.broadcast.TorrentBroadcast";
+  private static final String SCAN_STATE_HOLDER_CLASS = 
"org.apache.spark.sql.execution.datasources.parquet.JavaSerializedValue";
+
+  /**
+   * The {@code spark.plugins} value that enables {@link #setTaskStartHook}.
+   */
+  public static final String TASK_START_HOOK_PLUGIN = 
TaskStartHookPlugin.class.getName();
+
+  private static final String SPARK_JOB_GROUP_ID = "spark.jobGroup.id";
+  private static final String SPARK_JOB_DESCRIPTION = "spark.job.description";
+  private static final String SPARK_JOB_INTERRUPT_ON_CANCEL = 
"spark.job.interruptOnCancel";
+  private static final Pattern TASK_THREAD_STAGE = Pattern.compile("^Executor 
task launch worker .* in stage (\\d+)\\.");
+
+  private static final int MAX_WRITTEN_BEFORE = 12;
+  private static final int MAX_LOAD_SITES = 3;
+  private static final int MAX_LOAD_SITE_FRAMES = 40;
+
+  private static final URI LOCAL_FILE_SYSTEM = URI.create("file:///");
+
+  private static volatile Runnable taskStartHook = () -> { };
+
+  private SparkExecutorGuards() {
+  }
+
+  /**
+   * Routes the {@code file} scheme through {@link RecordingLocalFileSystem} 
for every reader created
+   * from {@code hadoopConf} (use the Spark context's Hadoop configuration), 
with Spark tasks as the
+   * recording scope. Register it before writing the table too, so that all 
files are written and read
+   * by the same file system implementation.
+   *
+   * <p>The recording file system also replaces the cached {@code file} file 
system, so that a reader
+   * that resolves its file system from another configuration, such as a new 
one, is recorded too.
+   */
+  public static void enableFileSystemCallRecording(Configuration hadoopConf) {
+    RecordingLocalFileSystem.setScope(IN_SPARK_TASK);
+    RecordingLocalFileSystem.register(hadoopConf);
+    try {
+      FileSystem cached = FileSystem.get(LOCAL_FILE_SYSTEM, new 
Configuration());
+      if (!(cached instanceof RecordingLocalFileSystem)) {
+        // Closing removes it from the cache, so that the next lookup caches 
the recording file system.
+        cached.close();
+        Configuration recording = new Configuration();
+        recording.set(RecordingLocalFileSystem.FILE_IMPL_KEY, 
RecordingLocalFileSystem.class.getName());
+        FileSystem.get(LOCAL_FILE_SYSTEM, recording);
+      }
+      assertTrue(FileSystem.get(LOCAL_FILE_SYSTEM, new Configuration()) 
instanceof RecordingLocalFileSystem,
+          "The cached file system of the file scheme must be the recording 
one");
+    } catch (IOException e) {
+      throw new UncheckedIOException(e);
+    }
+  }
+
+  /**
+   * Reverts {@link #enableFileSystemCallRecording}.
+   */
+  public static void disableFileSystemCallRecording(Configuration hadoopConf) {
+    RecordingLocalFileSystem.unregister(hadoopConf);
+    RecordingLocalFileSystem.setScope(() -> false);
+    try {
+      FileSystem cached = FileSystem.get(LOCAL_FILE_SYSTEM, new 
Configuration());
+      if (cached instanceof RecordingLocalFileSystem) {
+        cached.close();
+      }
+    } catch (IOException e) {
+      throw new UncheckedIOException(e);
+    }
+  }
+
+  /**
+   * Runs {@code action} and fails if any Spark task accessed a path under 
{@code .hoodie} while it
+   * ran. Also fails if no Spark task call was recorded at all, which means 
the tasks do not use the
+   * recording file system and the guard would pass vacuously.
+   */
+  public static <T> T assertNoExecutorMetaFolderAccess(String description, 
Supplier<T> action) {
+    RecordingLocalFileSystem.reset();
+    T result = action.get();
+    assertTrue(RecordingLocalFileSystem.count(Call.inScope()) > 0,
+        description + ": no file system call of a Spark task was recorded, so 
the guard is not live. Register "
+            + RecordingLocalFileSystem.class.getSimpleName()
+            + " on the Hadoop configuration the read uses 
(enableFileSystemCallRecording).");
+    Predicate<Call> executorMetaFolderAccess = 
Call.inScope().and(Call.underMetaFolder());
+    if (RecordingLocalFileSystem.count(executorMetaFolderAccess) > 0) {
+      fail(description + ": Spark tasks accessed the .hoodie folder. Table 
config, timeline and schema"
+          + " history must be resolved once on the driver and handed to the 
tasks; every access below"
+          + " is repeated per task (per file group) on the executors.\n"
+          + RecordingLocalFileSystem.describe(executorMetaFolderAccess));
+    }
+    return result;
+  }
+
+  /**
+   * Runs {@code action} while recording the classes Spark tasks 
Java-deserialize, keeping only the
+   * tasks of the jobs {@code action} starts. Tasks of jobs that run 
concurrently, such as file
+   * listing jobs started by planning in another thread, are left out.
+   */
+  public static TaskDeserializationRecorder.Result 
recordTaskDeserialization(SparkContext sparkContext, Runnable action) {
+    String jobGroup = "task-deserialization-" + UUID.randomUUID();
+    Set<String> scopesOfJobGroup = ConcurrentHashMap.newKeySet();
+    Supplier<String> stageScope = () -> {
+      TaskContext taskContext = TaskContext.get();
+      if (taskContext != null) {
+        String scope = "stage " + taskContext.stageId();
+        if (jobGroup.equals(taskContext.getLocalProperty(SPARK_JOB_GROUP_ID))) 
{
+          scopesOfJobGroup.add(scope);
+        }
+        return scope;
+      }
+      Matcher matcher = 
TASK_THREAD_STAGE.matcher(Thread.currentThread().getName());
+      return matcher.find() ? "stage " + matcher.group(1) : null;
+    };
+    TaskDeserializationRecorder recorder = 
TaskDeserializationRecorder.install();
+    String previousJobGroup = 
sparkContext.getLocalProperty(SPARK_JOB_GROUP_ID);
+    String previousDescription = 
sparkContext.getLocalProperty(SPARK_JOB_DESCRIPTION);
+    String previousInterrupt = 
sparkContext.getLocalProperty(SPARK_JOB_INTERRUPT_ON_CANCEL);
+    sparkContext.setJobGroup(jobGroup, "task deserialization recording", 
false);
+    recorder.start(stageScope, SparkExecutorGuards::oncePerExecutorKind);
+    TaskDeserializationRecorder.Result result;
+    try {
+      action.run();
+    } finally {
+      result = recorder.stop(scopesOfJobGroup::contains);
+      sparkContext.setLocalProperty(SPARK_JOB_GROUP_ID, previousJobGroup);
+      sparkContext.setLocalProperty(SPARK_JOB_DESCRIPTION, 
previousDescription);
+      sparkContext.setLocalProperty(SPARK_JOB_INTERRUPT_ON_CANCEL, 
previousInterrupt);
+    }
+    return result;
+  }
+
+  private static String oncePerExecutorKind(String frameClass) {
+    if (frameClass.startsWith(BROADCAST_CLASS)) {
+      return BROADCAST_FETCH;
+    }
+    return frameClass.startsWith(SCAN_STATE_HOLDER_CLASS) ? SCAN_STATE : null;
+  }
+
+  /**
+   * Sets code to run on the task thread at the start of every Spark task, 
after the task and its
+   * partition are deserialized and before the task runs. It takes effect only 
in a session whose {@code spark.plugins} includes
+   * {@link TaskStartHookPlugin}, see {@link #TASK_START_HOOK_PLUGIN}.
+   */
+  public static void setTaskStartHook(Runnable hook) {
+    taskStartHook = hook;
+  }
+
+  /**
+   * A Spark plugin that runs the hook set by {@link #setTaskStartHook} when a 
task starts and tracks
+   * the running tasks for the threads they start, see {@link #IN_SPARK_TASK}.
+   */
+  public static final class TaskStartHookPlugin implements SparkPlugin {
+    @Override
+    public DriverPlugin driverPlugin() {
+      return null;
+    }
+
+    @Override
+    public ExecutorPlugin executorPlugin() {
+      return new ExecutorPlugin() {
+        @Override
+        public void onTaskStart() {
+          TaskContext context = TaskContext.get();
+          if (context != null) {
+            RUNNING_TASK_ATTEMPTS.add(context.taskAttemptId());
+            TASK_ATTEMPT.set(context.taskAttemptId());
+          }
+          taskStartHook.run();
+        }
+
+        @Override
+        public void onTaskSucceeded() {
+          taskEnded();
+        }
+
+        @Override
+        public void onTaskFailed(TaskFailedReason failureReason) {
+          taskEnded();
+        }
+
+        // Spark may unset the task context before these callbacks, so the 
attempt comes from the thread.
+        private void taskEnded() {
+          Long attempt = TASK_ATTEMPT.get();
+          if (attempt != null) {
+            RUNNING_TASK_ATTEMPTS.remove(attempt);
+          }
+          TASK_ATTEMPT.remove();
+        }
+      };
+    }
+  }
+
+  /**
+   * Runs {@code action} and fails if Spark tasks parsed the Hadoop default 
resources more than
+   * {@code maxLoads} times, which every {@link Configuration} created with 
defaults does on its first
+   * read. A task must reuse the configuration it was given instead, since the 
parse is repeated for
+   * every configuration created this way, typically once per file read.
+   *
+   * <p>Each task's context class loader is wrapped, once the task starts 
running, to count the lookups
+   * of {@code core-default.xml}. A new configuration takes the context class 
loader of the thread that
+   * creates it, so the count covers configurations the task creates while it 
runs; a copy keeps the
+   * class loader of the configuration it copies. Needs a session with {@link 
#TASK_START_HOOK_PLUGIN}.
+   * After {@code action}, a single task that creates a configuration checks 
that the count is live.
+   */
+  public static <T> T assertTaskHadoopDefaultResourceLoadsAtMost(String 
description, SparkContext sparkContext,
+                                                                 int maxLoads, 
Supplier<T> action) {
+    AtomicInteger tasks = new AtomicInteger();
+    AtomicInteger loads = new AtomicInteger();
+    Queue<Throwable> loadSites = new ConcurrentLinkedQueue<>();
+    T result;
+    setTaskStartHook(() -> {
+      tasks.incrementAndGet();
+      Thread thread = Thread.currentThread();
+      thread.setContextClassLoader(
+          new 
DefaultResourceCountingClassLoader(thread.getContextClassLoader(), loads, 
loadSites));
+    });
+    try {
+      result = action.get();
+      int actionTasks = tasks.get();
+      int actionLoads = loads.get();
+      List<Throwable> actionLoadSites = new ArrayList<>(loadSites);
+      
JavaSparkContext.fromSparkContext(sparkContext).parallelize(Collections.singletonList(1),
 1)
+          .foreach(i -> new Configuration().get("fs.defaultFS"));
+      assertTrue(actionTasks > 0 && loads.get() > actionLoads,
+          description + ": the Hadoop default resource count is not live 
(tasks of the action: " + actionTasks
+              + "); run the session with " + TASK_START_HOOK_PLUGIN + " in 
spark.plugins");
+      if (actionLoads > maxLoads) {
+        fail(description + ": Spark tasks parsed the Hadoop default resources 
" + actionLoads + " times in "
+            + actionTasks + " tasks, more than " + maxLoads + ". A reader must 
use the configuration of the task,"
+            + " not create one per file, which parses core-default.xml and 
core-site.xml each time. First parses:\n"
+            + 
actionLoadSites.stream().map(SparkExecutorGuards::describeLoadSite)
+                .collect(Collectors.joining("\n")));
+      }
+    } finally {
+      setTaskStartHook(() -> { });
+    }
+    return result;
+  }
+
+  private static String describeLoadSite(Throwable site) {
+    return Arrays.stream(site.getStackTrace())
+        .skip(1)
+        .limit(MAX_LOAD_SITE_FRAMES)
+        .map(frame -> "    at " + frame)
+        .collect(Collectors.joining("\n"));
+  }
+
+  /**
+   * Counts lookups of {@code core-default.xml}, which a {@link Configuration} 
makes once per parse of
+   * its default resources, and keeps the stack of the first few.
+   */
+  private static final class DefaultResourceCountingClassLoader extends 
ClassLoader {
+    private final AtomicInteger loads;
+    private final Queue<Throwable> loadSites;
+
+    private DefaultResourceCountingClassLoader(ClassLoader parent, 
AtomicInteger loads, Queue<Throwable> loadSites) {
+      super(parent);
+      this.loads = loads;
+      this.loadSites = loadSites;
+    }
+
+    @Override
+    public URL getResource(String name) {
+      if ("core-default.xml".equals(name) && loads.incrementAndGet() <= 
MAX_LOAD_SITES) {
+        loadSites.add(new Throwable());
+      }
+      return super.getResource(name);
+    }
+  }
+
+  /**
+   * The task binary Spark ships to every task that reads {@code df}: the RDD 
lineage of its physical
+   * plan, Java-serialized as the DAG scheduler does. Call it after the plan 
ran; it does not run a job.
+   */
+  public static TaskBinary inspectTaskBinary(Dataset<?> df) {
+    RDD<?> rdd = df.queryExecution().executedPlan().execute();
+    long bytes = SparkEnv.get().closureSerializer().newInstance()
+        .serialize(rdd, ClassTag$.MODULE$.apply(RDD.class)).remaining();
+    Map<Class<?>, List<String>> instances = new LinkedHashMap<>();
+    Deque<String> recentlyWritten = new ArrayDeque<>();
+    try (ObjectOutputStream out = new 
ObjectOutputStream(OutputStream.nullOutputStream()) {
+      {
+        enableReplaceObject(true);
+      }
+
+      @Override
+      protected Object replaceObject(Object obj) {
+        instances.computeIfAbsent(obj.getClass(), c -> new 
ArrayList<>(recentlyWritten));
+        recentlyWritten.addLast(obj.getClass().getName());
+        if (recentlyWritten.size() > MAX_WRITTEN_BEFORE) {
+          recentlyWritten.removeFirst();
+        }
+        return obj;
+      }
+    }) {
+      out.writeObject(rdd);
+    } catch (IOException e) {
+      throw new UncheckedIOException(e);
+    }
+    return new TaskBinary(bytes, instances);
+  }
+
+  /**
+   * The size of the task binary and the classes of the instances it holds.
+   */
+  public static final class TaskBinary {
+    private final long bytes;
+    private final Map<Class<?>, List<String>> instances;
+
+    private TaskBinary(long bytes, Map<Class<?>, List<String>> instances) {
+      this.bytes = bytes;
+      this.instances = instances;
+    }
+
+    public long getBytes() {
+      return bytes;
+    }
+
+    /**
+     * Names of the classes of instances in the task binary that are {@code 
className} or extend or
+     * implement it.
+     */
+    public List<String> namesOfSubtypesOf(String className) {
+      return instances.keySet().stream()
+          .filter(clazz -> TaskDeserializationRecorder.isSubtypeOf(clazz, 
className))
+          .map(Class::getName)
+          .sorted()
+          .collect(Collectors.toList());
+    }
+
+    /**
+     * The classes of the objects serialized right before the first instance 
of {@code className},
+     * which include the objects that hold it.
+     */
+    public List<String> writtenBefore(String className) {
+      return instances.entrySet().stream()
+          .filter(entry -> entry.getKey().getName().equals(className))
+          .map(Map.Entry::getValue)
+          .findFirst().orElse(Collections.emptyList());
+    }
+  }
+
+  /**
+   * Fails if the task binary holds, or tasks deserialized outside once per 
executor deserializations,
+   * an instance of any class that is or extends one of {@code 
forbiddenClassNames}, if the scan state
+   * holds an instance of any class that is or extends one of {@code 
forbiddenInScanState}, if the task
+   * binary is larger than {@code maxTaskBinaryBytes}, or if a task stream 
larger than
+   * {@code maxTaskStreamBytes} was deserialized. Also fails if no Spark RDD 
was recorded, which means
+   * the recorder did not see the task binary.
+   */
+  public static void assertTaskDeserializationFootprint(String description,
+                                                        
TaskDeserializationRecorder.Result result,
+                                                        TaskBinary taskBinary,
+                                                        Collection<String> 
forbiddenClassNames,
+                                                        Collection<String> 
forbiddenInScanState,
+                                                        long 
maxTaskBinaryBytes,
+                                                        long 
maxTaskStreamBytes) {
+    assertEquals(0, result.getErrors(), description + ": the deserialization 
recorder failed on some callbacks");
+    assertTrue(!result.namesOfSubtypesOf("org.apache.spark.rdd.RDD").isEmpty(),
+        description + ": no RDD was deserialized on a task thread, so the 
recorder did not see the task binary."
+            + " Recorded classes: " + sortedNames(result));
+    
assertTrue(!taskBinary.namesOfSubtypesOf("org.apache.spark.rdd.RDD").isEmpty(),
+        description + ": the task binary inspection saw no RDD instance, so it 
did not see the task binary");
+    List<String> violations = new ArrayList<>();
+    for (String forbidden : forbiddenClassNames) {
+      List<String> inBinary = taskBinary.namesOfSubtypesOf(forbidden);
+      if (!inBinary.isEmpty()) {
+        violations.add("in the task binary: " + inBinary + " (forbidden: " + 
forbidden + "), written right after "
+            + taskBinary.writtenBefore(inBinary.get(0)));
+      }
+      List<String> deserialized = result.namesOfSubtypesOf(forbidden);
+      if (!deserialized.isEmpty()) {
+        violations.add("deserialized per task: " + deserialized + " 
(forbidden: " + forbidden + "), first at:\n"
+            + result.getFirstStack(deserialized.get(0)));
+      }
+    }
+    for (String forbidden : forbiddenInScanState) {
+      List<String> inScanState = result.exemptNamesOfSubtypesOf(SCAN_STATE, 
forbidden);
+      if (!inScanState.isEmpty()) {
+        violations.add("in the broadcast scan state: " + inScanState + " 
(forbidden: " + forbidden + ")");
+      }
+    }
+    if (taskBinary.getBytes() > maxTaskBinaryBytes) {
+      violations.add("the task binary is " + taskBinary.getBytes() + " bytes, 
over the budget of " + maxTaskBinaryBytes + " bytes");
+    }
+    if (result.getMaxStreamBytes() > maxTaskStreamBytes) {
+      violations.add("a task stream of " + result.getMaxStreamBytes() + " 
bytes was deserialized, over the budget of "
+          + maxTaskStreamBytes + " bytes");
+    }
+    if (!violations.isEmpty()) {
+      fail(description + ": Spark tasks deserialize heavy driver-side state or 
more bytes than budgeted with"
+          + " every task; it is paid again by every task. Move it to a 
broadcast or out of the closure.\n  "
+          + String.join("\n  ", violations)
+          + "\nTask binary: " + taskBinary.getBytes() + " bytes (largest task 
stream seen by the filter: "
+          + result.getMaxStreamBytes() + " bytes)"
+          + "\nHudi classes deserialized per task: " + 
sortedNames(result).stream()
+              .filter(name -> name.startsWith("org.apache.hudi") || 
name.contains(".hudi."))
+              .collect(Collectors.toList()));
+    }
+  }
+
+  private static List<String> sortedNames(TaskDeserializationRecorder.Result 
result) {
+    return 
result.getClasses().stream().map(Class::getName).sorted().collect(Collectors.toList());
+  }
+}
diff --git 
a/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/functional/TestSparkReadExecutorFootprint.java
 
b/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/functional/TestSparkReadExecutorFootprint.java
new file mode 100644
index 000000000000..d85101822a9a
--- /dev/null
+++ 
b/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/functional/TestSparkReadExecutorFootprint.java
@@ -0,0 +1,597 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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 org.apache.hudi.functional;
+
+import org.apache.hudi.DataSourceReadOptions;
+import org.apache.hudi.DataSourceWriteOptions;
+import org.apache.hudi.SparkAdapterSupport$;
+import org.apache.hudi.common.config.HoodieMetadataConfig;
+import org.apache.hudi.common.config.HoodieStorageConfig;
+import org.apache.hudi.common.model.HoodieTableType;
+import org.apache.hudi.common.table.HoodieTableConfig;
+import org.apache.hudi.common.table.HoodieTableMetaClient;
+import org.apache.hudi.common.table.HoodieTableVersion;
+import org.apache.hudi.common.table.timeline.HoodieInstant;
+import org.apache.hudi.common.testutils.HoodieTestTable;
+import org.apache.hudi.common.testutils.InProcessTimeGenerator;
+import org.apache.hudi.config.HoodieArchivalConfig;
+import org.apache.hudi.config.HoodieCompactionConfig;
+import org.apache.hudi.config.HoodieWriteConfig;
+import org.apache.hudi.hadoop.fs.RecordingLocalFileSystem;
+import org.apache.hudi.hadoop.fs.RecordingLocalFileSystem.Call;
+import org.apache.hudi.keygen.SimpleKeyGenerator;
+import org.apache.hudi.testutils.SparkClientFunctionalTestHarness;
+import org.apache.hudi.testutils.SparkExecutorGuards;
+import org.apache.hudi.testutils.TaskDeserializationRecorder;
+
+import lombok.extern.slf4j.Slf4j;
+import org.apache.spark.SparkConf;
+import org.apache.spark.sql.DataFrameReader;
+import org.apache.spark.sql.Dataset;
+import org.apache.spark.sql.Row;
+import org.apache.spark.sql.RowFactory;
+import org.apache.spark.sql.SaveMode;
+import org.apache.spark.sql.types.DataTypes;
+import org.apache.spark.sql.types.StructField;
+import org.apache.spark.sql.types.StructType;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Predicate;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+import java.util.stream.Stream;
+
+import scala.util.Properties;
+
+import static org.apache.hudi.common.model.HoodieTableType.COPY_ON_WRITE;
+import static org.apache.hudi.common.model.HoodieTableType.MERGE_ON_READ;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Guards what Spark tasks do on the executors when reading a Hudi table 
through the file group
+ * reader: they must not access the table's {@code .hoodie} folder, they must 
not deserialize heavy
+ * driver-side objects (meta client, timeline, Hadoop configuration, the file 
format itself) with
+ * every task, what every task deserializes must stay within a size budget, 
and they must not parse
+ * the Hadoop default resources for every file they read. These costs scale 
with the number of tasks
+ * or files, not with the data.
+ *
+ * <p>Each table is written once per class and read by every case that needs 
it. The table has
+ * several partitions so that a read runs several tasks; on MERGE_ON_READ the 
second commit
+ * updates half of the keys so that every file group has log files to merge. 
The two commits
+ * follow a history of empty commits, so that every read plans against a 
timeline of tens of
+ * instants and per-task state that grows with the timeline shows in the 
guards.
+ */
+@Slf4j
+@Tag("functional")
+class TestSparkReadExecutorFootprint extends SparkClientFunctionalTestHarness {
+
+  private static final int CURRENT_VERSION = 
HoodieTableVersion.current().versionCode();
+  private static final int NUM_RECORDS = 200;
+  private static final int NUM_UPDATED_RECORDS = 100;
+  private static final int NUM_PARTITIONS = 4;
+  private static final int NUM_HISTORY_COMMITS = 48;
+
+  /**
+   * Driver-side classes that neither a read task nor the broadcast scan state 
may deserialize.
+   */
+  private static final List<String> HEAVY_DRIVER_CLASSES = Arrays.asList(
+      "org.apache.hudi.common.table.HoodieTableMetaClient",
+      "org.apache.hudi.common.table.timeline.HoodieTimeline",
+      "org.apache.hudi.storage.StorageConfiguration",
+      "org.apache.spark.util.SerializableConfiguration",
+      
"org.apache.spark.sql.execution.datasources.parquet.HoodieFileGroupReaderBasedFileFormat",
+      "org.apache.hudi.config.HoodieWriteConfig");
+
+  /**
+   * Classes that a read task must not deserialize with its closure or 
partition: the heavy driver-side
+   * ones, and the table config and committed instants, which reach the tasks 
in the broadcast scan
+   * state that every executor deserializes once. The instants grow with the 
timeline.
+   */
+  private static final List<String> CLASSES_NOT_DESERIALIZED_PER_TASK = 
Stream.concat(HEAVY_DRIVER_CLASSES.stream(), Stream.of(
+      "org.apache.hudi.common.table.timeline.HoodieInstant",
+      "org.apache.hudi.common.table.read.CommittedInstants",
+      
"org.apache.hudi.common.table.HoodieTableConfig")).collect(Collectors.toList());
+
+  /**
+   * Hadoop default resource parses allowed in the tasks of a read. A base 
file read converts the table
+   * schema in the scan state with a new configuration once per JVM instance 
of the state, so once per
+   * executor, and local mode runs one executor; a merging read does not parse 
them.
+   */
+  private static final int MAX_HADOOP_DEFAULT_RESOURCE_LOADS = 1;
+
+  private static final StructType SCHEMA = DataTypes.createStructType(new 
StructField[] {
+      DataTypes.createStructField("key", DataTypes.StringType, false),
+      DataTypes.createStructField("part", DataTypes.StringType, false),
+      DataTypes.createStructField("ts", DataTypes.LongType, false),
+      DataTypes.createStructField("value", DataTypes.StringType, true)});
+
+  private static final String SCAN_STATE_CLASS = 
"org.apache.spark.sql.execution.datasources.parquet.HoodieFileGroupReadState";
+  private static final String VECTORIZED_READER_ENABLED = 
"spark.sql.parquet.enableVectorizedReader";
+  private static final String DATA_SKIPPING_FAILURE_MODE = 
"hoodie.fileIndex.dataSkippingFailureMode";
+
+  private static final Map<String, TestTable> TABLES = new HashMap<>();
+
+  @TempDir
+  static Path tablesDir;
+
+  enum TableKind {
+    PLAIN, CDC, PARQUET_LOG_BLOCKS
+  }
+
+  /**
+   * The kind of read a task size budget applies to: a read that returns 
columnar batches of base
+   * files measures larger than a read that returns rows.
+   */
+  enum ReadShape {
+    COLUMNAR, ROW
+  }
+
+  /**
+   * What every task of a read may deserialize, by Spark and Scala binary 
version: the task binary,
+   * the Java-serialized closure, and the largest task stream, which is the 
larger of the binary and
+   * the task with its partition. Any state added to the closure or to a 
task's partition is paid
+   * again by every task, whatever the state is. Spark and Scala versions 
serialize the plan
+   * differently, so each budget is about 1.1x the largest size measured on 
that version, given as
+   * task binary / task stream bytes.
+   */
+  private static final Map<String, TaskBudgets> TASK_BUDGETS = new HashMap<>();
+
+  static {
+    // measured: columnar 15587 / 16679, row 9787 / 12233
+    TASK_BUDGETS.put("3.3_2.12", new TaskBudgets(17152, 18432, 11008, 13568));
+    // measured: columnar 15968 / 17060, row 10040 / 12367
+    TASK_BUDGETS.put("3.4_2.12", new TaskBudgets(17664, 18944, 11264, 13824));
+    // measured: columnar 17555 / 18647, row 11051 / 12488
+    TASK_BUDGETS.put("3.5_2.12", new TaskBudgets(19456, 20736, 12288, 13824));
+    // measured: columnar 17766 / 18989, row 11212 / 12793
+    TASK_BUDGETS.put("3.5_2.13", new TaskBudgets(19712, 20992, 12544, 14080));
+    // measured: columnar 18524 / 19457, row 12434 / 13618
+    TASK_BUDGETS.put("4.0_2.13", new TaskBudgets(20480, 21504, 13824, 15104));
+    // measured: columnar 18491 / 19424, row 12401 / 13618
+    TASK_BUDGETS.put("4.1_2.13", new TaskBudgets(20480, 21504, 13824, 15104));
+    // measured: columnar 18778 / 19712, row 12706 / 13812
+    TASK_BUDGETS.put("4.2_2.13", new TaskBudgets(20736, 21760, 14080, 15360));
+  }
+
+  enum ReadQuery {
+    SNAPSHOT, READ_OPTIMIZED, INCREMENTAL, TIME_TRAVEL, CDC
+  }
+
+  @Override
+  public SparkConf conf() {
+    return conf(Collections.singletonMap("spark.plugins", 
SparkExecutorGuards.TASK_START_HOOK_PLUGIN));
+  }
+
+  @BeforeEach
+  void enableRecording() {
+    // Until #20090, a read can leave the session's vectorized reader flag 
changed; reset it so that
+    // every case plans its scan the same way whatever ran before it.
+    spark().conf().unset(VECTORIZED_READER_ENABLED);
+    
SparkExecutorGuards.enableFileSystemCallRecording(jsc().hadoopConfiguration());
+  }
+
+  @AfterEach
+  void disableRecording() {
+    
SparkExecutorGuards.disableFileSystemCallRecording(jsc().hadoopConfiguration());
+  }
+
+  @AfterAll
+  static void forgetTables() {
+    TABLES.clear();
+  }
+
+  static Stream<Arguments> tableVersionsAndTypes() {
+    return Stream.of(6, CURRENT_VERSION).flatMap(version ->
+        Stream.of(COPY_ON_WRITE, MERGE_ON_READ).map(type -> 
Arguments.of(version, type)));
+  }
+
+  /**
+   * With the metadata table off, a query other than an incremental one lists 
partitions from the
+   * file system with a Spark job, whose tasks are guarded too.
+   */
+  static Stream<Arguments> readQueries() {
+    List<Arguments> args = new ArrayList<>();
+    for (int version : new int[] {6, CURRENT_VERSION}) {
+      for (HoodieTableType type : HoodieTableType.values()) {
+        for (ReadQuery query : Arrays.asList(ReadQuery.SNAPSHOT, 
ReadQuery.READ_OPTIMIZED, ReadQuery.INCREMENTAL, ReadQuery.TIME_TRAVEL)) {
+          if (query == ReadQuery.READ_OPTIMIZED && type == COPY_ON_WRITE) {
+            continue;
+          }
+          for (String metadataOnRead : new String[] {"default", "false"}) {
+            args.add(Arguments.of(version, type, query, metadataOnRead));
+          }
+        }
+      }
+    }
+    return args.stream();
+  }
+
+  /**
+   * A CDC read of a version 6 MERGE_ON_READ table fails on the driver, so it 
is left out.
+   */
+  static Stream<Arguments> cdcTableVersionsAndTypes() {
+    return tableVersionsAndTypes().filter(args -> 
!isVersion6MergeOnRead(args));
+  }
+
+  private static boolean isVersion6MergeOnRead(Arguments args) {
+    Object[] values = args.get();
+    return (int) values[0] == 6 && values[1] == MERGE_ON_READ;
+  }
+
+  static Stream<Arguments> readsForHadoopDefaultResources() {
+    return Stream.of(6, CURRENT_VERSION).flatMap(version -> Stream.of(
+        Arguments.of(version, COPY_ON_WRITE, TableKind.PLAIN),
+        Arguments.of(version, MERGE_ON_READ, TableKind.PLAIN),
+        Arguments.of(version, MERGE_ON_READ, TableKind.PARQUET_LOG_BLOCKS)));
+  }
+
+  /**
+   * Every query on both table versions and types, except the CDC read of a 
version 6 MERGE_ON_READ
+   * table, see {@link #cdcTableVersionsAndTypes}.
+   */
+  static Stream<Arguments> readsForDeserialization() {
+    List<Arguments> args = new ArrayList<>();
+    for (int version : new int[] {6, CURRENT_VERSION}) {
+      for (HoodieTableType type : HoodieTableType.values()) {
+        for (ReadQuery query : ReadQuery.values()) {
+          if ((query == ReadQuery.READ_OPTIMIZED && type == COPY_ON_WRITE)
+              || (query == ReadQuery.CDC && version == 6 && type == 
MERGE_ON_READ)) {
+            continue;
+          }
+          TableKind kind = query == ReadQuery.CDC ? TableKind.CDC : 
TableKind.PLAIN;
+          args.add(Arguments.of(version, type, kind, query, readShape(type, 
query)));
+        }
+      }
+    }
+    return args.stream();
+  }
+
+  /**
+   * A read returns columnar batches when it scans base files without merging: 
snapshot and time
+   * travel reads of COPY_ON_WRITE tables, and read optimized reads.
+   */
+  private static ReadShape readShape(HoodieTableType type, ReadQuery query) {
+    switch (query) {
+      case READ_OPTIMIZED:
+        return ReadShape.COLUMNAR;
+      case SNAPSHOT:
+      case TIME_TRAVEL:
+        return type == COPY_ON_WRITE ? ReadShape.COLUMNAR : ReadShape.ROW;
+      default:
+        return ReadShape.ROW;
+    }
+  }
+
+  @ParameterizedTest(name = "[{index}] version={0}, type={1}, query={2}, 
metadata={3}")
+  @MethodSource("readQueries")
+  void testNoExecutorMetaFolderAccess(int tableVersion, HoodieTableType 
tableType, ReadQuery query, String metadataOnRead) {
+    TestTable table = getOrWriteTable(tableVersion, tableType, 
TableKind.PLAIN);
+    Map<String, String> options = new HashMap<>();
+    if (!"default".equals(metadataOnRead)) {
+      options.put(HoodieMetadataConfig.ENABLE.key(), metadataOnRead);
+    }
+    List<Row> rows = SparkExecutorGuards.assertNoExecutorMetaFolderAccess(
+        table.name + " " + query + " read with metadata " + metadataOnRead,
+        () -> read(table, query, options).collectAsList());
+    assertFalse(rows.isEmpty(), "The read must return rows for the guard to be 
meaningful");
+    if (query == ReadQuery.TIME_TRAVEL) {
+      assertTrue(rows.stream().allMatch(row -> 
"v1".equals(row.getAs("value"))),
+          table.name + ": time travel to the first commit must not see the 
updates of the second");
+    }
+  }
+
+  @ParameterizedTest(name = "[{index}] version={0}, type={1}")
+  @MethodSource("cdcTableVersionsAndTypes")
+  void testNoExecutorMetaFolderAccessForCdcQuery(int tableVersion, 
HoodieTableType tableType) {
+    TestTable table = getOrWriteTable(tableVersion, tableType, TableKind.CDC);
+    List<Row> rows = SparkExecutorGuards.assertNoExecutorMetaFolderAccess(
+        table.name + " CDC read",
+        () -> read(table, ReadQuery.CDC, new HashMap<>()).collectAsList());
+    assertFalse(rows.isEmpty(), "The CDC read must return rows for the guard 
to be meaningful");
+  }
+
+  /**
+   * Tasks must not deserialize the meta client, timeline, Hadoop 
configuration, write config or the
+   * file format with their closure, and what every task deserializes must 
stay within budget.
+   */
+  @ParameterizedTest(name = "[{index}] version={0}, type={1}, table={2}, 
query={3}, shape={4}")
+  @MethodSource("readsForDeserialization")
+  void testTaskDeserializationFootprint(int tableVersion, HoodieTableType 
tableType, TableKind kind, ReadQuery query,
+                                        ReadShape shape) {
+    String sparkVersion = sparkAndScalaBinaryVersion();
+    TaskBudgets budgets = TASK_BUDGETS.get(sparkVersion);
+    assertNotNull(budgets, "No task size budget for Spark and Scala " + 
sparkVersion
+        + "; add one from the task sizes this test logs for each read");
+    TestTable table = getOrWriteTable(tableVersion, tableType, kind);
+    Dataset<Row> df = read(table, query, new HashMap<>());
+    // Plan and list files on the driver first, so that the recorded window 
holds only the scan.
+    df.queryExecution().executedPlan().execute();
+    List<Row> rows = new ArrayList<>();
+    TaskDeserializationRecorder.Result result = 
SparkExecutorGuards.recordTaskDeserialization(
+        spark().sparkContext(), () -> rows.addAll(df.collectAsList()));
+    assertFalse(rows.isEmpty(), "The read must return rows for the guard to be 
meaningful");
+    SparkExecutorGuards.TaskBinary taskBinary = 
SparkExecutorGuards.inspectTaskBinary(df);
+    log.info("{} read of {}: task binary {} bytes, largest task stream seen {} 
bytes, stages kept {}, ignored {}",
+        query, table.name, taskBinary.getBytes(), result.getMaxStreamBytes(), 
result.getKeptScopes(), result.getIgnoredScopes());
+    SparkExecutorGuards.assertTaskDeserializationFootprint(
+        table.name + " " + query + " read on Spark and Scala " + sparkVersion, 
result, taskBinary,
+        CLASSES_NOT_DESERIALIZED_PER_TASK, HEAVY_DRIVER_CLASSES, 
budgets.maxTaskBinaryBytes(shape), budgets.maxTaskStreamBytes(shape));
+    // Local mode runs one executor, so the broadcast scan state is 
deserialized at most once.
+    
assertTrue(result.getExemptDeserializations(SparkExecutorGuards.SCAN_STATE, 
SCAN_STATE_CLASS) <= 1, table.name + " " + query
+        + " read: the scan state must be deserialized once per executor, not 
per task or file, but was deserialized "
+        + result.getExemptDeserializations(SparkExecutorGuards.SCAN_STATE, 
SCAN_STATE_CLASS) + " times");
+  }
+
+  /**
+   * Tasks must read base files and log blocks with the configuration they are 
given rather than
+   * create one per file, which parses the Hadoop default resources every time.
+   */
+  @ParameterizedTest(name = "[{index}] version={0}, type={1}, table={2}")
+  @MethodSource("readsForHadoopDefaultResources")
+  void testNoHadoopDefaultResourceLoadsPerFile(int tableVersion, 
HoodieTableType tableType, TableKind kind) {
+    TestTable table = getOrWriteTable(tableVersion, tableType, kind);
+    Dataset<Row> df = read(table, ReadQuery.SNAPSHOT, new HashMap<>());
+    List<Row> rows = 
SparkExecutorGuards.assertTaskHadoopDefaultResourceLoadsAtMost(
+        table.name + " snapshot read", spark().sparkContext(), 
MAX_HADOOP_DEFAULT_RESOURCE_LOADS, df::collectAsList);
+    assertEquals(NUM_RECORDS, rows.size());
+  }
+
+  /**
+   * With data skipping, file pruning consults the column stats in the 
metadata table on the driver.
+   * The tasks of the scan must still not access {@code .hoodie} of either 
table. Every partition holds
+   * the keys {@code i} with the same {@code i % NUM_PARTITIONS}, so a lookup 
of the first key prunes the
+   * files of all partitions but the first by their key ranges; strict mode 
fails the read if data
+   * skipping cannot be applied. A column stats lookup forced onto the engine 
reads the metadata table
+   * from the executors by design and is not covered.
+   */
+  @ParameterizedTest(name = "[{index}] version={0}, type={1}")
+  @MethodSource("tableVersionsAndTypes")
+  void testNoExecutorMetaFolderAccessWithDataSkipping(int tableVersion, 
HoodieTableType tableType) {
+    TestTable table = getOrWriteTable(tableVersion, tableType, 
TableKind.PLAIN);
+    Map<String, String> options = new HashMap<>();
+    options.put(DataSourceReadOptions.ENABLE_DATA_SKIPPING().key(), "true");
+    List<Row> rows;
+    spark().conf().set(DATA_SKIPPING_FAILURE_MODE, "strict");
+    try {
+      rows = SparkExecutorGuards.assertNoExecutorMetaFolderAccess(
+          table.name + " snapshot read with data skipping",
+          () -> read(table, ReadQuery.SNAPSHOT, options).filter("key = '" + 
key(0) + "'").collectAsList());
+    } finally {
+      spark().conf().unset(DATA_SKIPPING_FAILURE_MODE);
+    }
+    assertEquals(1, rows.size());
+    
assertTrue(RecordingLocalFileSystem.count(Call.inScope().negate().and(Call.pathContains("/.hoodie/metadata/column_stats/")))
 > 0,
+        table.name + ": the driver must read the column stats index for the 
read to exercise data skipping");
+    
assertTrue(RecordingLocalFileSystem.count(Call.inScope().and(Call.pathContains("/p0/")))
 > 0,
+        table.name + ": the tasks must read the files of the partition that 
holds the key");
+    Predicate<Call> prunedFileAccess = Call.inScope().and(
+        IntStream.range(1, NUM_PARTITIONS).mapToObj(i -> 
Call.pathContains("/p" + i + "/")).reduce(call -> false, Predicate::or));
+    assertEquals(0, RecordingLocalFileSystem.count(prunedFileAccess),
+        table.name + ": data skipping must prune the files of the partitions 
without the key, but the tasks read:\n"
+            + RecordingLocalFileSystem.describe(prunedFileAccess));
+  }
+
+  private Dataset<Row> read(TestTable table, ReadQuery query, Map<String, 
String> options) {
+    DataFrameReader reader = spark().read().format("hudi").options(options);
+    switch (query) {
+      case SNAPSHOT:
+        reader.option(DataSourceReadOptions.QUERY_TYPE().key(), 
DataSourceReadOptions.QUERY_TYPE_SNAPSHOT_OPT_VAL());
+        break;
+      case READ_OPTIMIZED:
+        reader.option(DataSourceReadOptions.QUERY_TYPE().key(), 
DataSourceReadOptions.QUERY_TYPE_READ_OPTIMIZED_OPT_VAL());
+        break;
+      case TIME_TRAVEL:
+        reader.option(DataSourceReadOptions.TIME_TRAVEL_AS_OF_INSTANT().key(), 
table.firstInstant.requestedTime());
+        break;
+      case INCREMENTAL:
+      case CDC:
+        reader.option(DataSourceReadOptions.QUERY_TYPE().key(), 
DataSourceReadOptions.QUERY_TYPE_INCREMENTAL_OPT_VAL())
+            .option(DataSourceReadOptions.START_COMMIT().key(), 
table.incrementalTime(table.firstInstant))
+            .option(DataSourceReadOptions.END_COMMIT().key(), 
table.incrementalTime(table.lastInstant));
+        if (query == ReadQuery.CDC) {
+          reader.option(DataSourceReadOptions.INCREMENTAL_FORMAT().key(), 
DataSourceReadOptions.INCREMENTAL_FORMAT_CDC_VAL());
+        }
+        break;
+      default:
+        throw new IllegalArgumentException("Unknown query " + query);
+    }
+    return reader.load(table.basePath);
+  }
+
+  private TestTable getOrWriteTable(int tableVersion, HoodieTableType 
tableType, TableKind kind) {
+    String name = kind.name().toLowerCase() + "_" + 
tableType.name().toLowerCase() + "_v" + tableVersion;
+    return TABLES.computeIfAbsent(name, n -> writeTable(n, tableVersion, 
tableType, kind));
+  }
+
+  private TestTable writeTable(String name, int tableVersion, HoodieTableType 
tableType, TableKind kind) {
+    String basePath = tablesDir.resolve(name).toUri().toString();
+    Map<String, String> options = new HashMap<>();
+    options.put(HoodieTableConfig.NAME.key(), name);
+    options.put(DataSourceWriteOptions.TABLE_TYPE().key(), tableType.name());
+    options.put(DataSourceWriteOptions.RECORDKEY_FIELD().key(), "key");
+    options.put(DataSourceWriteOptions.PARTITIONPATH_FIELD().key(), "part");
+    options.put(DataSourceWriteOptions.ORDERING_FIELDS().key(), "ts");
+    options.put(HoodieWriteConfig.WRITE_TABLE_VERSION.key(), 
String.valueOf(tableVersion));
+    options.put(HoodieWriteConfig.AUTO_UPGRADE_VERSION.key(), "false");
+    options.put("hoodie.insert.shuffle.parallelism", "2");
+    options.put("hoodie.upsert.shuffle.parallelism", "2");
+    // Keep the whole history on the active timeline and the log files of 
MERGE_ON_READ uncompacted.
+    options.put(HoodieArchivalConfig.MIN_COMMITS_TO_KEEP.key(), 
String.valueOf(NUM_HISTORY_COMMITS + 10));
+    options.put(HoodieArchivalConfig.MAX_COMMITS_TO_KEEP.key(), 
String.valueOf(NUM_HISTORY_COMMITS + 20));
+    options.put(HoodieCompactionConfig.INLINE_COMPACT_NUM_DELTA_COMMITS.key(), 
String.valueOf(NUM_HISTORY_COMMITS + 10));
+    // Index column stats, so that data skipping has an index to consult.
+    options.put(HoodieMetadataConfig.ENABLE_METADATA_INDEX_COLUMN_STATS.key(), 
"true");
+    if (kind == TableKind.CDC) {
+      options.put(HoodieTableConfig.CDC_ENABLED.key(), "true");
+    }
+    if (kind == TableKind.PARQUET_LOG_BLOCKS) {
+      options.put(HoodieStorageConfig.LOGFILE_DATA_BLOCK_FORMAT.key(), 
"parquet");
+    }
+
+    initTableWithHistory(name, basePath, tableVersion, tableType, kind);
+    List<Row> inserts = IntStream.range(0, NUM_RECORDS)
+        .mapToObj(i -> RowFactory.create(key(i), "p" + (i % NUM_PARTITIONS), 
1L, "v1"))
+        .collect(Collectors.toList());
+    write(inserts, SCHEMA, options, basePath);
+    // The second commit updates half of the keys.
+    List<Row> updates = IntStream.range(0, NUM_UPDATED_RECORDS)
+        .mapToObj(i -> RowFactory.create(key(i), "p" + (i % NUM_PARTITIONS), 
2L, "v2"))
+        .collect(Collectors.toList());
+    write(updates, SCHEMA, options, basePath);
+
+    HoodieTableMetaClient metaClient = 
HoodieTableMetaClient.builder().setBasePath(basePath).setConf(storageConf()).build();
+    assertEquals(tableVersion, 
metaClient.getTableConfig().getTableVersion().versionCode());
+    List<HoodieInstant> commits = 
metaClient.getCommitsTimeline().filterCompletedInstants().getInstants();
+    assertEquals(NUM_HISTORY_COMMITS + 2, commits.size(), "Expected the 
history and two completed commits in " + name);
+    if (tableType == MERGE_ON_READ) {
+      assertTrue(countLogFiles(tablesDir.resolve(name)) >= NUM_PARTITIONS,
+          "The updates of " + name + " should write log files in every 
partition");
+    }
+    return new TestTable(name, basePath, tableVersion, 
commits.get(NUM_HISTORY_COMMITS), commits.get(NUM_HISTORY_COMMITS + 1));
+  }
+
+  /**
+   * Creates the table with the configuration the writes use and {@link 
#NUM_HISTORY_COMMITS} empty
+   * completed commits.
+   */
+  private void initTableWithHistory(String name, String basePath, int 
tableVersion, HoodieTableType tableType,
+                                    TableKind kind) {
+    try {
+      HoodieTableMetaClient metaClient = 
HoodieTableMetaClient.newTableBuilder()
+          .setTableType(tableType)
+          .setTableName(name)
+          .setTableVersion(tableVersion)
+          .setRecordKeyFields("key")
+          .setPartitionFields("part")
+          .setOrderingFields("ts")
+          .setKeyGeneratorClassProp(SimpleKeyGenerator.class.getName())
+          .setCDCEnabled(kind == TableKind.CDC)
+          .initTable(storageConf(), basePath);
+      HoodieTestTable history = HoodieTestTable.of(metaClient);
+      for (int i = 0; i < NUM_HISTORY_COMMITS; i++) {
+        String instantTime = InProcessTimeGenerator.createNewInstantTime();
+        if (tableType == COPY_ON_WRITE) {
+          history.addCommit(instantTime);
+        } else {
+          history.addDeltaCommit(instantTime);
+        }
+      }
+    } catch (Exception e) {
+      throw new IllegalStateException("Cannot create the history of " + name, 
e);
+    }
+  }
+
+  private void write(List<Row> rows, StructType schema, Map<String, String> 
options, String basePath) {
+    spark().createDataset(rows, 
SparkAdapterSupport$.MODULE$.sparkAdapter().getCatalystExpressionUtils().getEncoder(schema))
+        .write()
+        .format("hudi")
+        .options(options)
+        .mode(SaveMode.Append)
+        .save(basePath);
+  }
+
+  private static long countLogFiles(Path tableDir) {
+    try (Stream<Path> files = Files.walk(tableDir)) {
+      return files.filter(p -> !p.toString().contains("/.hoodie/") && 
p.getFileName().toString().contains(".log.")).count();
+    } catch (IOException e) {
+      throw new UncheckedIOException(e);
+    }
+  }
+
+  /**
+   * The running Spark version and Scala binary version, such as {@code 
3.5_2.12}.
+   */
+  private String sparkAndScalaBinaryVersion() {
+    String[] spark = spark().version().split("\\.");
+    String[] scala = Properties.versionNumberString().split("\\.");
+    return spark[0] + "." + spark[1] + "_" + scala[0] + "." + scala[1];
+  }
+
+  private static String key(int i) {
+    return String.format("key%03d", i);
+  }
+
+  private static final class TaskBudgets {
+    private final long columnarTaskBinaryBytes;
+    private final long columnarTaskStreamBytes;
+    private final long rowTaskBinaryBytes;
+    private final long rowTaskStreamBytes;
+
+    private TaskBudgets(long columnarTaskBinaryBytes, long 
columnarTaskStreamBytes, long rowTaskBinaryBytes,
+                        long rowTaskStreamBytes) {
+      this.columnarTaskBinaryBytes = columnarTaskBinaryBytes;
+      this.columnarTaskStreamBytes = columnarTaskStreamBytes;
+      this.rowTaskBinaryBytes = rowTaskBinaryBytes;
+      this.rowTaskStreamBytes = rowTaskStreamBytes;
+    }
+
+    private long maxTaskBinaryBytes(ReadShape shape) {
+      return shape == ReadShape.COLUMNAR ? columnarTaskBinaryBytes : 
rowTaskBinaryBytes;
+    }
+
+    private long maxTaskStreamBytes(ReadShape shape) {
+      return shape == ReadShape.COLUMNAR ? columnarTaskStreamBytes : 
rowTaskStreamBytes;
+    }
+  }
+
+  private static final class TestTable {
+    private final String name;
+    private final String basePath;
+    private final int tableVersion;
+    private final HoodieInstant firstInstant;
+    private final HoodieInstant lastInstant;
+
+    private TestTable(String name, String basePath, int tableVersion, 
HoodieInstant firstInstant, HoodieInstant lastInstant) {
+      this.name = name;
+      this.basePath = basePath;
+      this.tableVersion = tableVersion;
+      this.firstInstant = firstInstant;
+      this.lastInstant = lastInstant;
+    }
+
+    /**
+     * Incremental queries range over requested times before table version 8 
and over completion
+     * times from version 8 on.
+     */
+    private String incrementalTime(HoodieInstant instant) {
+      return tableVersion < 8 ? instant.requestedTime() : 
instant.getCompletionTime();
+    }
+  }
+}

Reply via email to