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 8af07e1e1534 fix(utilities): validator Config.equals and 
drop-partitions hive sync, with tests for the standalone tools (#19875)
8af07e1e1534 is described below

commit 8af07e1e1534c92e1ba97dbc1e18a50f73f6c65c
Author: voonhous <[email protected]>
AuthorDate: Thu Sep 10 16:59:31 2026 +0800

    fix(utilities): validator Config.equals and drop-partitions hive sync, with 
tests for the standalone tools (#19875)
    
    Three bugs in the standalone hudi-utilities tools, found while adding
    tests for tools that had no coverage at all: HoodieDropPartitionsTool,
    HoodieDataTableValidator, TableSizeStats and HoodieTTLJob.
    
    - HoodieDataTableValidator.Config.equals cast its argument to the
      sibling HoodieMetadataTableValidator.Config, so comparing any two
      instances threw ClassCastException. It now casts to its own Config.
      All four tools compare basePath through Objects.equals (a default
      instance NPEd) and drop help from hashCode, which equals never
      compared, so every Config hashes over exactly the fields it
      compares.
    - HoodieDropPartitionsTool --sync-hive-meta could never work:
      buildHiveSyncProps stored two ConfigProperty objects as Properties
      keys, so HiveSyncConfig threw ClassCastException before any
      metastore contact. It now uses the keys and string values for the
      boolean flags.
    - HoodieDropPartitionsTool validated the hive arguments only after
      masking the partitions, so a typo in --hive-database cost the
      partitions first. The DELETE arm now verifies them before anything
      is dropped (dry runs unchanged), and --hive-partition-field is
      required with --sync-hive-meta: its empty default was written into
      the sync props verbatim, which blocked the inference from the table
      config and made HiveSyncTool skip every partition.
    
    Tests, one class per tool, each writing a small three-partition COW
    table and running the tool in-process:
    
    - TestHoodieDropPartitionsTool (10): dry-run reports the file ids of
      exactly the named partitions and changes nothing; delete writes one
      replacecommit masking exactly those partitions; props sourcing;
      unsupported mode; hive arguments verified before the drop; a failing
      metastore connection after the drop is committed; Config contracts.
    - TestHoodieDataTableValidator (7): healthy table; dangling base file
      with and without --ignore-failed, asserted through the validator's
      log; extra file for a completed commit; continuous mode stopping on
      the first failure, bounded by a timeout; Config contracts.
    - TestTableSizeStats (15): table and partition stats; the date
      interval filters including the half-open bounds and a partition
      dated yesterday for --num-days; base paths from --props-path; the
      error branches; Config contracts.
    - TestHoodieTTLJob (2): both constructors; expired partitions are
      replaced and the fresh one survives.
    - Shared test utilities: CapturingLogAppender for tools whose only
      output is their log, and ToolTestUtils for the helpers the classes
      had in common.
    
    Each fix has a test that fails without it. main() of each tool and
    HudiHiveSyncJob (needs a metastore) stay uncovered on purpose.
---
 .../hudi/utilities/HoodieDataTableValidator.java   |   6 +-
 .../hudi/utilities/HoodieDropPartitionsTool.java   |  21 +-
 .../utilities/HoodieMetadataTableValidator.java    |   4 +-
 .../org/apache/hudi/utilities/TableSizeStats.java  |   4 +-
 .../utilities/TestHoodieDataTableValidator.java    | 229 +++++++++++++
 .../utilities/TestHoodieDropPartitionsTool.java    | 358 +++++++++++++++++++++
 .../TestHoodieMetadataTableValidator.java          |  18 ++
 .../apache/hudi/utilities/TestHoodieTTLJob.java    | 130 ++++++++
 .../apache/hudi/utilities/TestTableSizeStats.java  | 311 ++++++++++++++++++
 .../utilities/testutils/CapturingLogAppender.java  |  85 +++++
 .../hudi/utilities/testutils/ToolTestUtils.java    |  59 ++++
 11 files changed, 1212 insertions(+), 13 deletions(-)

diff --git 
a/hudi-utilities/src/main/java/org/apache/hudi/utilities/HoodieDataTableValidator.java
 
b/hudi-utilities/src/main/java/org/apache/hudi/utilities/HoodieDataTableValidator.java
index ed689022da64..4c89816fbede 100644
--- 
a/hudi-utilities/src/main/java/org/apache/hudi/utilities/HoodieDataTableValidator.java
+++ 
b/hudi-utilities/src/main/java/org/apache/hudi/utilities/HoodieDataTableValidator.java
@@ -212,8 +212,8 @@ public class HoodieDataTableValidator implements 
Serializable {
       if (o == null || getClass() != o.getClass()) {
         return false;
       }
-      HoodieMetadataTableValidator.Config config = 
(HoodieMetadataTableValidator.Config) o;
-      return basePath.equals(config.basePath)
+      Config config = (Config) o;
+      return Objects.equals(basePath, config.basePath)
           && Objects.equals(continuous, config.continuous)
           && Objects.equals(minValidateIntervalSeconds, 
config.minValidateIntervalSeconds)
           && Objects.equals(parallelism, config.parallelism)
@@ -228,7 +228,7 @@ public class HoodieDataTableValidator implements 
Serializable {
     @Override
     public int hashCode() {
       return Objects.hash(basePath, continuous, minValidateIntervalSeconds, 
parallelism, ignoreFailed, sparkMaster, sparkMemory,
-          assumeDatePartitioning, propsFilePath, configs, help);
+          assumeDatePartitioning, propsFilePath, configs);
     }
   }
 
diff --git 
a/hudi-utilities/src/main/java/org/apache/hudi/utilities/HoodieDropPartitionsTool.java
 
b/hudi-utilities/src/main/java/org/apache/hudi/utilities/HoodieDropPartitionsTool.java
index 9b6a89c8dae9..0999f16ed438 100644
--- 
a/hudi-utilities/src/main/java/org/apache/hudi/utilities/HoodieDropPartitionsTool.java
+++ 
b/hudi-utilities/src/main/java/org/apache/hudi/utilities/HoodieDropPartitionsTool.java
@@ -244,7 +244,7 @@ public class HoodieDropPartitionsTool implements 
Serializable {
         return false;
       }
       Config config = (Config) o;
-      return basePath.equals(config.basePath)
+      return Objects.equals(basePath, config.basePath)
           && Objects.equals(runningMode, config.runningMode)
           && Objects.equals(tableName, config.tableName)
           && Objects.equals(partitions, config.partitions)
@@ -270,7 +270,7 @@ public class HoodieDropPartitionsTool implements 
Serializable {
       return Objects.hash(basePath, runningMode, tableName, partitions,
           syncToHive, hiveDataBase, hiveTableName, hiveUserName, hivePassWord, 
hiveURL,
           hivePartitionsField, hiveUseJdbc, hiveHMSUris, 
partitionValueExtractorClass,
-          sparkMaster, sparkMemory, propsFilePath, configs, 
hiveSyncIgnoreException, help);
+          sparkMaster, sparkMemory, propsFilePath, configs, 
hiveSyncIgnoreException);
     }
   }
 
@@ -304,6 +304,11 @@ public class HoodieDropPartitionsTool implements 
Serializable {
       switch (mode) {
         case DELETE:
           log.info(" ****** The Hoodie Drop Partitions Tool is in delete mode 
****** ");
+          if (cfg.syncToHive) {
+            // Check the hive configs before anything is dropped: they are 
otherwise only read once the partitions
+            // have already been masked, so a typo in --hive-database would 
cost the partitions before it surfaces.
+            verifyHiveConfigs();
+          }
           doDeleteTablePartitions();
           syncToHiveIfNecessary();
           break;
@@ -357,12 +362,12 @@ public class HoodieDropPartitionsTool implements 
Serializable {
     props.put(DataSourceWriteOptions.HIVE_PASS().key(), cfg.hivePassWord);
     props.put(DataSourceWriteOptions.HIVE_URL().key(), cfg.hiveURL);
     props.put(DataSourceWriteOptions.HIVE_PARTITION_FIELDS().key(), 
cfg.hivePartitionsField);
-    props.put(DataSourceWriteOptions.HIVE_USE_JDBC().key(), cfg.hiveUseJdbc);
+    props.put(DataSourceWriteOptions.HIVE_USE_JDBC().key(), 
String.valueOf(cfg.hiveUseJdbc));
     props.put(DataSourceWriteOptions.HIVE_SYNC_MODE().key(), cfg.hiveSyncMode);
-    props.put(DataSourceWriteOptions.HIVE_IGNORE_EXCEPTIONS().key(), 
cfg.hiveSyncIgnoreException);
+    props.put(DataSourceWriteOptions.HIVE_IGNORE_EXCEPTIONS().key(), 
String.valueOf(cfg.hiveSyncIgnoreException));
     props.put(DataSourceWriteOptions.HIVE_PASS().key(), cfg.hivePassWord);
-    props.put(HiveSyncConfig.META_SYNC_BASE_PATH, cfg.basePath);
-    props.put(HiveSyncConfig.META_SYNC_BASE_FILE_FORMAT, "PARQUET");
+    props.put(HiveSyncConfig.META_SYNC_BASE_PATH.key(), cfg.basePath);
+    props.put(HiveSyncConfig.META_SYNC_BASE_FILE_FORMAT.key(), "PARQUET");
     props.put(DataSourceWriteOptions.PARTITIONS_TO_DELETE().key(), 
cfg.partitions);
     props.put(DataSourceWriteOptions.HIVE_PARTITION_EXTRACTOR_CLASS().key(), 
cfg.partitionValueExtractorClass);
     props.put(KeyGeneratorOptions.PARTITIONPATH_FIELD_NAME.key(), 
cfg.hivePartitionsField);
@@ -373,6 +378,10 @@ public class HoodieDropPartitionsTool implements 
Serializable {
   private void verifyHiveConfigs() {
     
ValidationUtils.checkArgument(!StringUtils.isNullOrEmpty(cfg.hiveDataBase), 
"Hive database name couldn't be null or empty when enable sync meta, please set 
--hive-database/-db.");
     
ValidationUtils.checkArgument(!StringUtils.isNullOrEmpty(cfg.hiveTableName), 
"Hive table name couldn't be null or empty when enable sync meta, please set 
--hive-table-name/-tn.");
+    // This is written into the sync props verbatim, which stops 
HoodieSyncConfig inferring the fields from the
+    // table config; left empty, hive sync skips every partition and the drop 
is never reflected in the metastore.
+    
ValidationUtils.checkArgument(!StringUtils.isNullOrEmpty(cfg.hivePartitionsField),
+        "Hive partition fields couldn't be null or empty when enable sync 
meta, otherwise no partition is synced, please set --hive-partition-field.");
   }
 
   private void syncHive(HiveSyncConfig hiveSyncConfig) {
diff --git 
a/hudi-utilities/src/main/java/org/apache/hudi/utilities/HoodieMetadataTableValidator.java
 
b/hudi-utilities/src/main/java/org/apache/hudi/utilities/HoodieMetadataTableValidator.java
index fa99795c22fc..95ac651eda56 100644
--- 
a/hudi-utilities/src/main/java/org/apache/hudi/utilities/HoodieMetadataTableValidator.java
+++ 
b/hudi-utilities/src/main/java/org/apache/hudi/utilities/HoodieMetadataTableValidator.java
@@ -457,7 +457,7 @@ public class HoodieMetadataTableValidator implements 
Serializable {
         return false;
       }
       Config config = (Config) o;
-      return basePath.equals(config.basePath)
+      return Objects.equals(basePath, config.basePath)
           && Objects.equals(continuous, config.continuous)
           && Objects.equals(skipDataFilesForCleaning, 
config.skipDataFilesForCleaning)
           && Objects.equals(validateLatestFileSlices, 
config.validateLatestFileSlices)
@@ -491,7 +491,7 @@ public class HoodieMetadataTableValidator implements 
Serializable {
           validateSecondaryIndex, validateRecordIndexCount, 
validateRecordIndexContent, numRecordIndexErrorSamples,
           viewStorageTypeForFSListing, viewStorageTypeForMetadata,
           minValidateIntervalSeconds, parallelism, recordIndexParallelism, 
ignoreFailed,
-          sparkMaster, sparkMemory, assumeDatePartitioning, 
logDetailMaxLength, propsFilePath, configs, help);
+          sparkMaster, sparkMemory, assumeDatePartitioning, 
logDetailMaxLength, propsFilePath, configs);
     }
   }
 
diff --git 
a/hudi-utilities/src/main/java/org/apache/hudi/utilities/TableSizeStats.java 
b/hudi-utilities/src/main/java/org/apache/hudi/utilities/TableSizeStats.java
index 9a5bca95c318..fccc19cf1937 100644
--- a/hudi-utilities/src/main/java/org/apache/hudi/utilities/TableSizeStats.java
+++ b/hudi-utilities/src/main/java/org/apache/hudi/utilities/TableSizeStats.java
@@ -204,7 +204,7 @@ public class TableSizeStats implements Serializable {
         return false;
       }
       Config config = (Config) o;
-      return basePath.equals(config.basePath)
+      return Objects.equals(basePath, config.basePath)
           && Objects.equals(numDays, config.numDays)
           && Objects.equals(startDate, config.startDate)
           && Objects.equals(endDate, config.endDate)
@@ -219,7 +219,7 @@ public class TableSizeStats implements Serializable {
 
     @Override
     public int hashCode() {
-      return Objects.hash(basePath, numDays, startDate, endDate, tableStats, 
partitionStats, parallelism, sparkMaster, sparkMemory, propsFilePath, configs, 
help);
+      return Objects.hash(basePath, numDays, startDate, endDate, tableStats, 
partitionStats, parallelism, sparkMaster, sparkMemory, propsFilePath, configs);
     }
   }
 
diff --git 
a/hudi-utilities/src/test/java/org/apache/hudi/utilities/TestHoodieDataTableValidator.java
 
b/hudi-utilities/src/test/java/org/apache/hudi/utilities/TestHoodieDataTableValidator.java
new file mode 100644
index 000000000000..81d12a02bf78
--- /dev/null
+++ 
b/hudi-utilities/src/test/java/org/apache/hudi/utilities/TestHoodieDataTableValidator.java
@@ -0,0 +1,229 @@
+/*
+ * 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.utilities;
+
+import org.apache.hudi.client.SparkRDDWriteClient;
+import org.apache.hudi.client.WriteClientTestUtils;
+import org.apache.hudi.client.WriteStatus;
+import org.apache.hudi.common.fs.FSUtils;
+import org.apache.hudi.common.model.HoodieRecord;
+import org.apache.hudi.common.table.HoodieTableMetaClient;
+import org.apache.hudi.config.HoodieWriteConfig;
+import org.apache.hudi.exception.HoodieException;
+import org.apache.hudi.exception.HoodieIOException;
+import org.apache.hudi.exception.HoodieValidationException;
+import org.apache.hudi.testutils.HoodieSparkClientTestBase;
+import org.apache.hudi.utilities.testutils.CapturingLogAppender;
+
+import org.apache.spark.api.java.JavaRDD;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Timeout;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.UUID;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.Stream;
+
+import static 
org.apache.hudi.common.testutils.HoodieTestDataGenerator.DEFAULT_FIRST_PARTITION_PATH;
+import static 
org.apache.hudi.common.testutils.HoodieTestDataGenerator.DEFAULT_SECOND_PARTITION_PATH;
+import static 
org.apache.hudi.common.testutils.HoodieTestDataGenerator.DEFAULT_THIRD_PARTITION_PATH;
+import static org.apache.hudi.utilities.testutils.ToolTestUtils.stackMessages;
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Tests {@link HoodieDataTableValidator} against a small three-partition COW 
table, with and without
+ * data files that the timeline does not account for.
+ */
+public class TestHoodieDataTableValidator extends HoodieSparkClientTestBase {
+
+  private static final int RECORDS_PER_PARTITION = 4;
+
+  private HoodieDataTableValidator.Config validatorConfig(boolean 
ignoreFailed) {
+    HoodieDataTableValidator.Config cfg = new 
HoodieDataTableValidator.Config();
+    cfg.basePath = basePath;
+    cfg.parallelism = 2;
+    cfg.ignoreFailed = ignoreFailed;
+    return cfg;
+  }
+
+  private String writeOneCommit() {
+    HoodieWriteConfig writeConfig = getConfigBuilder().build();
+    try (SparkRDDWriteClient client = getHoodieWriteClient(writeConfig)) {
+      String instantTime = WriteClientTestUtils.createNewInstantTime();
+      List<HoodieRecord> records = new ArrayList<>();
+      for (String partition : Arrays.asList(
+          DEFAULT_FIRST_PARTITION_PATH, DEFAULT_SECOND_PARTITION_PATH, 
DEFAULT_THIRD_PARTITION_PATH)) {
+        records.addAll(dataGen.generateInsertsForPartition(instantTime, 
RECORDS_PER_PARTITION, partition));
+      }
+      WriteClientTestUtils.startCommitWithTime(client, instantTime);
+      JavaRDD<WriteStatus> writeStatuses = 
client.insert(jsc.parallelize(records, 1), instantTime);
+      client.commit(instantTime, writeStatuses);
+      return instantTime;
+    }
+  }
+
+  /**
+   * Copies an existing base file of the first partition to a new base file 
named after {@code instantTime} and a
+   * brand new file id, which is exactly the shape of a data file the timeline 
does not account for.
+   */
+  private String addUnaccountedBaseFile(String instantTime) throws IOException 
{
+    Path partitionDir = Paths.get(basePath, DEFAULT_FIRST_PARTITION_PATH);
+    Path source;
+    try (Stream<Path> files = Files.list(partitionDir)) {
+      source = files.filter(p -> p.toString().endsWith(".parquet")).findFirst()
+          .orElseThrow(() -> new IllegalStateException("no base file written 
under " + partitionDir));
+    }
+    String danglingName =
+        FSUtils.makeBaseFileName(instantTime, "1-0-1", 
UUID.randomUUID().toString(), ".parquet");
+    Files.copy(source, partitionDir.resolve(danglingName));
+    return danglingName;
+  }
+
+  @Test
+  public void testValidationPassesOnAHealthyTable() {
+    writeOneCommit();
+    HoodieDataTableValidator validator = new HoodieDataTableValidator(jsc, 
validatorConfig(false));
+    // the validator reports through an exception only, so a clean table is 
asserted by the absence of one
+    assertDoesNotThrow(validator::run);
+  }
+
+  @Test
+  public void testMissingPropsFileFails() {
+    HoodieDataTableValidator.Config cfg = validatorConfig(false);
+    cfg.propsFilePath = 
tempDir.resolve("does-not-exist.properties").toAbsolutePath().toString();
+    assertThrows(HoodieIOException.class, () -> new 
HoodieDataTableValidator(jsc, cfg));
+  }
+
+  /**
+   * A base file whose instant time precedes the first instant of the active 
timeline is dangling; whether that
+   * fails the job depends on --ignore-failed.
+   */
+  @ParameterizedTest
+  @ValueSource(booleans = {true, false})
+  public void testDanglingFileBeforeTheActiveTimeline(boolean ignoreFailed) 
throws IOException {
+    writeOneCommit();
+    String danglingFile = addUnaccountedBaseFile("00000000000001");
+
+    HoodieDataTableValidator validator = new HoodieDataTableValidator(jsc, 
validatorConfig(ignoreFailed));
+    if (ignoreFailed) {
+      // the run survives, but the finding still has to be reported
+      List<String> messages;
+      try (CapturingLogAppender logs = 
CapturingLogAppender.attachTo(HoodieDataTableValidator.class)) {
+        assertDoesNotThrow(validator::run);
+        messages = logs.messages();
+      }
+      assertTrue(messages.contains(
+          "Data table validation failed due to dangling files count 1, found 
before active timeline"),
+          messages.toString());
+      assertTrue(messages.stream().anyMatch(m -> m.startsWith("Dangling file: 
") && m.endsWith(danglingFile)),
+          "the dangling file must be named in " + messages);
+      assertTrue(messages.contains("Data table validation failed."), 
messages.toString());
+    } else {
+      HoodieException thrown = assertThrows(HoodieException.class, 
validator::run);
+      assertTrue(thrown.getCause() instanceof HoodieValidationException, "got 
" + thrown.getCause());
+      assertTrue(thrown.getCause().getMessage().contains("dangling files 1"), 
thrown.getCause().getMessage());
+    }
+  }
+
+  /**
+   * A base file carrying the instant time of a completed commit, but absent 
from that commit's metadata, is an
+   * extra file and fails the second check.
+   */
+  @Test
+  public void testExtraFileForCompletedCommitFailsValidation() throws 
IOException {
+    String instantTime = writeOneCommit();
+    addUnaccountedBaseFile(instantTime);
+
+    HoodieDataTableValidator validator = new HoodieDataTableValidator(jsc, 
validatorConfig(false));
+    HoodieException thrown = assertThrows(HoodieException.class, 
validator::run);
+    assertTrue(thrown.getCause() instanceof HoodieValidationException, "got " 
+ thrown.getCause());
+    assertTrue(thrown.getCause().getMessage().contains("dangling files 1"), 
thrown.getCause().getMessage());
+    // the table itself is untouched by validation
+    assertEquals(1, 
HoodieTableMetaClient.reload(metaClient).getActiveTimeline()
+        .filterCompletedInstants().countInstants());
+  }
+
+  /**
+   * In continuous mode the async service keeps validating until it fails; 
with --ignore-failed off the very
+   * first round throws, which is what stops the job.
+   */
+  @Test
+  @Timeout(value = 2, unit = TimeUnit.MINUTES)
+  public void testContinuousModeStopsOnValidationFailure() throws IOException {
+    writeOneCommit();
+    addUnaccountedBaseFile("00000000000001");
+
+    HoodieDataTableValidator.Config cfg = validatorConfig(false);
+    cfg.continuous = true;
+    cfg.minValidateIntervalSeconds = 1;
+    HoodieDataTableValidator validator = new HoodieDataTableValidator(jsc, 
cfg);
+
+    HoodieException thrown = assertThrows(HoodieException.class, 
validator::run);
+    assertTrue(stackMessages(thrown).contains("dangling files 1"), 
stackMessages(thrown));
+  }
+
+  @Test
+  public void testConfigEqualsHashCodeAndToString() {
+    HoodieDataTableValidator.Config cfg = validatorConfig(true);
+    cfg.basePath = "/tmp/table";
+    cfg.continuous = true;
+    cfg.minValidateIntervalSeconds = 30;
+
+    assertEquals(cfg, cfg);
+    assertNotEquals(cfg, null);
+    assertNotEquals(cfg, "not a config");
+    // a Config straight out of JCommander has no base path yet
+    assertEquals(new HoodieDataTableValidator.Config(), new 
HoodieDataTableValidator.Config());
+    assertEquals(new HoodieDataTableValidator.Config().hashCode(), new 
HoodieDataTableValidator.Config().hashCode());
+    // --help is not compared, so it must not be hashed either
+    HoodieDataTableValidator.Config askedForHelp = new 
HoodieDataTableValidator.Config();
+    askedForHelp.help = true;
+    assertEquals(new HoodieDataTableValidator.Config(), askedForHelp);
+    assertEquals(new HoodieDataTableValidator.Config().hashCode(), 
askedForHelp.hashCode());
+
+    HoodieDataTableValidator.Config same = validatorConfig(true);
+    same.basePath = "/tmp/table";
+    same.continuous = true;
+    same.minValidateIntervalSeconds = 30;
+    assertEquals(cfg, same);
+    assertEquals(cfg.hashCode(), same.hashCode());
+
+    same.minValidateIntervalSeconds = 60;
+    assertNotEquals(cfg, same);
+    assertNotEquals(cfg.hashCode(), same.hashCode());
+
+    String printed = cfg.toString();
+    assertTrue(printed.contains("--base-path /tmp/table"));
+    assertTrue(printed.contains("--continuous true"));
+    assertTrue(printed.contains("--ignore-failed true"));
+    assertTrue(printed.contains("--min-validate-interval-seconds 30"));
+  }
+}
diff --git 
a/hudi-utilities/src/test/java/org/apache/hudi/utilities/TestHoodieDropPartitionsTool.java
 
b/hudi-utilities/src/test/java/org/apache/hudi/utilities/TestHoodieDropPartitionsTool.java
new file mode 100644
index 000000000000..25315924fdbf
--- /dev/null
+++ 
b/hudi-utilities/src/test/java/org/apache/hudi/utilities/TestHoodieDropPartitionsTool.java
@@ -0,0 +1,358 @@
+/*
+ * 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.utilities;
+
+import org.apache.hudi.client.SparkRDDWriteClient;
+import org.apache.hudi.client.WriteClientTestUtils;
+import org.apache.hudi.client.WriteStatus;
+import org.apache.hudi.common.config.HoodieMetadataConfig;
+import org.apache.hudi.common.model.HoodieBaseFile;
+import org.apache.hudi.common.model.HoodieRecord;
+import org.apache.hudi.common.model.HoodieReplaceCommitMetadata;
+import org.apache.hudi.common.table.HoodieTableMetaClient;
+import org.apache.hudi.common.table.timeline.HoodieInstant;
+import org.apache.hudi.common.table.view.FileSystemViewManager;
+import org.apache.hudi.common.table.view.HoodieTableFileSystemView;
+import org.apache.hudi.config.HoodieWriteConfig;
+import org.apache.hudi.exception.HoodieException;
+import org.apache.hudi.hive.HoodieHiveSyncException;
+import org.apache.hudi.testutils.HoodieSparkClientTestBase;
+import org.apache.hudi.utilities.testutils.CapturingLogAppender;
+
+import org.apache.spark.api.java.JavaRDD;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+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.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import static 
org.apache.hudi.common.testutils.HoodieTestDataGenerator.DEFAULT_FIRST_PARTITION_PATH;
+import static 
org.apache.hudi.common.testutils.HoodieTestDataGenerator.DEFAULT_SECOND_PARTITION_PATH;
+import static 
org.apache.hudi.common.testutils.HoodieTestDataGenerator.DEFAULT_THIRD_PARTITION_PATH;
+import static 
org.apache.hudi.utilities.testutils.ToolTestUtils.latestBaseFileCount;
+import static org.apache.hudi.utilities.testutils.ToolTestUtils.stackMessages;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Tests {@link HoodieDropPartitionsTool} against a small three-partition COW 
table.
+ */
+public class TestHoodieDropPartitionsTool extends HoodieSparkClientTestBase {
+
+  private static final int RECORDS_PER_PARTITION = 4;
+
+  private HoodieDropPartitionsTool.Config toolConfig(String mode, String 
partitions) {
+    HoodieDropPartitionsTool.Config cfg = new 
HoodieDropPartitionsTool.Config();
+    cfg.basePath = basePath;
+    cfg.tableName = metaClient.getTableConfig().getTableName();
+    cfg.runningMode = mode;
+    cfg.partitions = partitions;
+    cfg.parallelism = 2;
+    cfg.configs.add(HoodieWriteConfig.TBL_NAME.key() + "=" + cfg.tableName);
+    return cfg;
+  }
+
+  /**
+   * Writes two insert commits: the first spreads records over all three 
partitions, the second adds a
+   * second file slice to the first partition.
+   */
+  private void writeThreePartitionTable() {
+    HoodieWriteConfig writeConfig = getConfigBuilder().build();
+    try (SparkRDDWriteClient client = getHoodieWriteClient(writeConfig)) {
+      String firstCommit = WriteClientTestUtils.createNewInstantTime();
+      List<HoodieRecord> firstBatch = new ArrayList<>();
+      for (String partition : Arrays.asList(
+          DEFAULT_FIRST_PARTITION_PATH, DEFAULT_SECOND_PARTITION_PATH, 
DEFAULT_THIRD_PARTITION_PATH)) {
+        firstBatch.addAll(dataGen.generateInsertsForPartition(firstCommit, 
RECORDS_PER_PARTITION, partition));
+      }
+      writeBatchAndCommit(client, firstCommit, firstBatch);
+
+      String secondCommit = WriteClientTestUtils.createNewInstantTime();
+      writeBatchAndCommit(client, secondCommit,
+          dataGen.generateInsertsForPartition(secondCommit, 
RECORDS_PER_PARTITION, DEFAULT_FIRST_PARTITION_PATH));
+    }
+  }
+
+  private void writeBatchAndCommit(SparkRDDWriteClient client, String 
instantTime, List<HoodieRecord> records) {
+    WriteClientTestUtils.startCommitWithTime(client, instantTime);
+    JavaRDD<WriteStatus> writeStatuses = 
client.insert(jsc.parallelize(records, 1), instantTime);
+    client.commit(instantTime, writeStatuses);
+  }
+
+  private List<String> latestFileIds(String partition) {
+    HoodieTableMetaClient reloaded = HoodieTableMetaClient.reload(metaClient);
+    try (HoodieTableFileSystemView fsView = 
FileSystemViewManager.createInMemoryFileSystemView(
+        context, reloaded, 
HoodieMetadataConfig.newBuilder().enable(false).build())) {
+      return 
fsView.getLatestBaseFiles(partition).map(HoodieBaseFile::getFileId).collect(Collectors.toList());
+    }
+  }
+
+  private List<String> completedInstants() {
+    return 
HoodieTableMetaClient.reload(metaClient).getActiveTimeline().filterCompletedInstants()
+        
.getInstantsAsStream().map(HoodieInstant::requestedTime).collect(Collectors.toList());
+  }
+
+  @Test
+  public void 
testDryRunReportsTheFilesItWouldDeleteAndLeavesTheTableUntouched() {
+    writeThreePartitionTable();
+    List<String> instantsBefore = completedInstants();
+    // what the tool prints must be the file ids the two partitions really hold
+    Set<String> expectedReport = new HashSet<>(Arrays.asList(
+        "Partitions : " + DEFAULT_FIRST_PARTITION_PATH + ", corresponding data 
file IDs : "
+            + latestFileIds(DEFAULT_FIRST_PARTITION_PATH),
+        "Partitions : " + DEFAULT_SECOND_PARTITION_PATH + ", corresponding 
data file IDs : "
+            + latestFileIds(DEFAULT_SECOND_PARTITION_PATH)));
+
+    HoodieDropPartitionsTool.Config cfg = toolConfig("dry_run",
+        DEFAULT_FIRST_PARTITION_PATH + "," + DEFAULT_SECOND_PARTITION_PATH);
+    List<String> messages;
+    try (CapturingLogAppender logs = 
CapturingLogAppender.attachTo(HoodieDropPartitionsTool.class)) {
+      new HoodieDropPartitionsTool(jsc, cfg).run();
+      messages = logs.messages();
+    }
+
+    assertTrue(messages.contains("Data files and partitions to delete : "), 
messages.toString());
+    assertEquals(expectedReport,
+        messages.stream().filter(m -> m.startsWith("Partitions : 
")).collect(Collectors.toSet()));
+    assertTrue(messages.stream().noneMatch(m -> 
m.contains(DEFAULT_THIRD_PARTITION_PATH)),
+        "the partition that was not named must not be reported: " + messages);
+
+    assertEquals(instantsBefore, completedInstants(), "dry run must not add 
any instant");
+    assertEquals(1, latestBaseFileCount(context, metaClient, 
DEFAULT_FIRST_PARTITION_PATH));
+    assertEquals(1, latestBaseFileCount(context, metaClient, 
DEFAULT_SECOND_PARTITION_PATH));
+    assertEquals(1, latestBaseFileCount(context, metaClient, 
DEFAULT_THIRD_PARTITION_PATH));
+  }
+
+  @Test
+  public void testDeleteMasksOnlyTheRequestedPartitions() throws IOException {
+    writeThreePartitionTable();
+    int instantsBefore = completedInstants().size();
+
+    HoodieDropPartitionsTool.Config cfg = toolConfig("delete",
+        DEFAULT_FIRST_PARTITION_PATH + "," + DEFAULT_SECOND_PARTITION_PATH);
+    new HoodieDropPartitionsTool(jsc, cfg).run();
+
+    HoodieTableMetaClient reloaded = HoodieTableMetaClient.reload(metaClient);
+    assertEquals(instantsBefore + 1, completedInstants().size(), "delete must 
add exactly one instant");
+    HoodieInstant replaceInstant = 
reloaded.getActiveTimeline().getCompletedReplaceTimeline().lastInstant().get();
+    HoodieReplaceCommitMetadata replaceMetadata =
+        reloaded.getActiveTimeline().readReplaceCommitMetadata(replaceInstant);
+    assertEquals(
+        new HashSet<>(Arrays.asList(DEFAULT_FIRST_PARTITION_PATH, 
DEFAULT_SECOND_PARTITION_PATH)),
+        replaceMetadata.getPartitionToReplaceFileIds().keySet());
+    // the file group of the first partition, written by both commits, is 
masked
+    assertEquals(1, 
replaceMetadata.getPartitionToReplaceFileIds().get(DEFAULT_FIRST_PARTITION_PATH).size());
+
+    assertEquals(0, latestBaseFileCount(context, metaClient, 
DEFAULT_FIRST_PARTITION_PATH));
+    assertEquals(0, latestBaseFileCount(context, metaClient, 
DEFAULT_SECOND_PARTITION_PATH));
+    assertEquals(1, latestBaseFileCount(context, metaClient, 
DEFAULT_THIRD_PARTITION_PATH),
+        "the partition that was not named must survive");
+  }
+
+  /**
+   * The tool takes its write properties either from --props or from repeated 
--hoodie-conf, and only defaults
+   * hoodie.meta.fields.mode from the table when the operator did not name it. 
Both sources are checked by asking
+   * for a meta-fields mode the table does not have and expecting the write 
config gate to reject it.
+   */
+  @ParameterizedTest
+  @ValueSource(booleans = {true, false})
+  public void testWritePropertiesComeFromPropsFileAndHoodieConf(boolean 
usePropsFile) throws IOException {
+    writeThreePartitionTable();
+
+    HoodieDropPartitionsTool.Config cfg = toolConfig("dry_run", 
DEFAULT_THIRD_PARTITION_PATH);
+    String metaFieldsOverride = "hoodie.meta.fields.mode=NONE";
+    if (usePropsFile) {
+      // the file carries the mode, the --hoodie-conf entry already on the 
config carries the table name, so
+      // both sources have to be merged for this run to reach the write config 
gate
+      Path propsFile = tempDir.resolve("drop-partitions.properties");
+      Files.write(propsFile, Collections.singletonList(metaFieldsOverride), 
StandardCharsets.UTF_8);
+      cfg.propsFilePath = propsFile.toAbsolutePath().toString();
+    } else {
+      cfg.configs.add(metaFieldsOverride);
+    }
+
+    HoodieDropPartitionsTool tool = new HoodieDropPartitionsTool(jsc, cfg);
+    Throwable thrown = assertThrows(HoodieException.class, tool::run);
+    assertTrue(stackMessages(thrown).contains("hoodie.meta.fields.mode"),
+        "expected the meta fields mode from the config source to reach the 
write config, got: " + thrown);
+  }
+
+  @Test
+  public void testUnsupportedModeFails() {
+    writeThreePartitionTable();
+    HoodieDropPartitionsTool.Config cfg = toolConfig("purge", 
DEFAULT_THIRD_PARTITION_PATH);
+    HoodieDropPartitionsTool tool = new HoodieDropPartitionsTool(jsc, cfg);
+
+    HoodieException thrown = assertThrows(HoodieException.class, tool::run);
+    assertTrue(thrown.getMessage().contains("Unable to delete table partitions 
in " + basePath));
+    assertTrue(thrown.getCause() instanceof IllegalArgumentException, "got " + 
thrown.getCause());
+    assertEquals(0, 
HoodieTableMetaClient.reload(metaClient).getActiveTimeline()
+        .getCompletedReplaceTimeline().countInstants());
+  }
+
+  /**
+   * A missing --hive-database is caught before the delete runs, so the 
partitions are still there afterwards.
+   */
+  @Test
+  public void testHiveSyncConfigIsVerifiedBeforeTheDrop() {
+    writeThreePartitionTable();
+    HoodieDropPartitionsTool.Config cfg = toolConfig("delete", 
DEFAULT_THIRD_PARTITION_PATH);
+    cfg.syncToHive = true;
+    cfg.hiveDataBase = null;
+    HoodieDropPartitionsTool tool = new HoodieDropPartitionsTool(jsc, cfg);
+
+    HoodieException thrown = assertThrows(HoodieException.class, tool::run);
+    assertTrue(thrown.getCause() instanceof IllegalArgumentException, "got " + 
thrown.getCause());
+    assertTrue(thrown.getCause().getMessage().contains("--hive-database"));
+    assertEquals(0, 
HoodieTableMetaClient.reload(metaClient).getActiveTimeline()
+        .getCompletedReplaceTimeline().countInstants(), "nothing may be 
dropped once the hive configs are bad");
+    assertEquals(1, latestBaseFileCount(context, metaClient, 
DEFAULT_THIRD_PARTITION_PATH));
+  }
+
+  /**
+   * With the hive configs in place the sync props are built and the sync is 
attempted for real; pointing it at a
+   * port nothing listens on keeps the test free of a metastore. The drop is 
committed before that attempt, so a
+   * metastore that is down costs the sync, not the partitions.
+   */
+  @Test
+  public void testHiveSyncFailureLeavesTheDropCommitted() {
+    // the tool feeds the FileSystem's hadoop conf into the HiveConf, which is 
the only way in for these
+    jsc.hadoopConfiguration().set("hive.metastore.connect.retries", "1");
+    jsc.hadoopConfiguration().set("hive.metastore.client.connect.retry.delay", 
"0s");
+    jsc.hadoopConfiguration().set("hive.metastore.failure.retries", "0");
+    writeThreePartitionTable();
+    HoodieDropPartitionsTool.Config cfg = toolConfig("delete", 
DEFAULT_THIRD_PARTITION_PATH);
+    cfg.syncToHive = true;
+    cfg.hiveDataBase = "db";
+    cfg.hiveTableName = "tbl";
+    cfg.hivePartitionsField = "partition_path";
+    cfg.hiveHMSUris = "thrift://localhost:1";
+    HoodieDropPartitionsTool tool = new HoodieDropPartitionsTool(jsc, cfg);
+
+    HoodieException thrown = assertThrows(HoodieException.class, tool::run);
+    assertTrue(thrown.getCause() instanceof HoodieHiveSyncException, "got " + 
thrown.getCause());
+    assertTrue(stackMessages(thrown).contains("Failed to create 
HiveMetaStoreClient"), stackMessages(thrown));
+    assertTrue(stackMessages(thrown).contains("Could not connect to meta store 
using any of the URIs provided"),
+        stackMessages(thrown));
+
+    assertEquals(1, 
HoodieTableMetaClient.reload(metaClient).getActiveTimeline()
+        .getCompletedReplaceTimeline().countInstants(), "the drop is committed 
before hive sync runs");
+    assertEquals(0, latestBaseFileCount(context, metaClient, 
DEFAULT_THIRD_PARTITION_PATH));
+  }
+
+  /**
+   * Dry run touches no partition, so it does not need the hive configs to be 
sound: it still prints its listing.
+   */
+  @Test
+  public void testDryRunDoesNotNeedHiveConfigs() {
+    writeThreePartitionTable();
+    List<String> instantsBefore = completedInstants();
+    HoodieDropPartitionsTool.Config cfg = toolConfig("dry_run", 
DEFAULT_THIRD_PARTITION_PATH);
+    cfg.syncToHive = true;
+    cfg.hiveDataBase = null;
+
+    List<String> messages;
+    try (CapturingLogAppender logs = 
CapturingLogAppender.attachTo(HoodieDropPartitionsTool.class)) {
+      new HoodieDropPartitionsTool(jsc, cfg).run();
+      messages = logs.messages();
+    }
+
+    assertEquals(
+        Collections.singleton("Partitions : " + DEFAULT_THIRD_PARTITION_PATH + 
", corresponding data file IDs : "
+            + latestFileIds(DEFAULT_THIRD_PARTITION_PATH)),
+        messages.stream().filter(m -> m.startsWith("Partitions : 
")).collect(Collectors.toSet()));
+    assertEquals(instantsBefore, completedInstants(), "dry run must not add 
any instant");
+  }
+
+  /**
+   * The partition fields are written into the sync props verbatim; empty, 
hive sync silently skips every
+   * partition, so the tool refuses the run rather than dropping partitions 
the metastore never hears about.
+   */
+  @Test
+  public void testHiveSyncWithoutPartitionFieldIsRejectedBeforeTheDrop() {
+    writeThreePartitionTable();
+    HoodieDropPartitionsTool.Config cfg = toolConfig("delete", 
DEFAULT_THIRD_PARTITION_PATH);
+    cfg.syncToHive = true;
+    cfg.hiveDataBase = "db";
+    cfg.hiveTableName = "tbl";
+    // cfg.hivePartitionsField is left at its default, the empty string
+    HoodieDropPartitionsTool tool = new HoodieDropPartitionsTool(jsc, cfg);
+
+    HoodieException thrown = assertThrows(HoodieException.class, tool::run);
+    assertTrue(thrown.getCause() instanceof IllegalArgumentException, "got " + 
thrown.getCause());
+    
assertTrue(thrown.getCause().getMessage().contains("--hive-partition-field"),
+        thrown.getCause().getMessage());
+    assertEquals(0, 
HoodieTableMetaClient.reload(metaClient).getActiveTimeline()
+        .getCompletedReplaceTimeline().countInstants(), "nothing may be 
dropped once the hive configs are bad");
+    assertEquals(1, latestBaseFileCount(context, metaClient, 
DEFAULT_THIRD_PARTITION_PATH));
+  }
+
+  @Test
+  public void testConfigEqualsHashCodeAndToString() {
+    HoodieDropPartitionsTool.Config left = new 
HoodieDropPartitionsTool.Config();
+    left.basePath = "/tmp/table";
+    left.runningMode = "delete";
+    left.tableName = "t1";
+    left.partitions = "p1,p2";
+    left.configs = new ArrayList<>(Collections.singletonList("k=v"));
+
+    HoodieDropPartitionsTool.Config right = new 
HoodieDropPartitionsTool.Config();
+    right.basePath = "/tmp/table";
+    right.runningMode = "delete";
+    right.tableName = "t1";
+    right.partitions = "p1,p2";
+    right.configs = new ArrayList<>(Collections.singletonList("k=v"));
+
+    assertEquals(left, left);
+    assertEquals(left, right);
+    assertEquals(left.hashCode(), right.hashCode());
+    assertNotEquals(left, null);
+    assertNotEquals(left, "not a config");
+    // a Config straight out of JCommander has no base path yet
+    assertEquals(new HoodieDropPartitionsTool.Config(), new 
HoodieDropPartitionsTool.Config());
+    assertEquals(new HoodieDropPartitionsTool.Config().hashCode(), new 
HoodieDropPartitionsTool.Config().hashCode());
+    // --help is not compared, so it must not be hashed either
+    HoodieDropPartitionsTool.Config askedForHelp = new 
HoodieDropPartitionsTool.Config();
+    askedForHelp.help = true;
+    assertEquals(new HoodieDropPartitionsTool.Config(), askedForHelp);
+    assertEquals(new HoodieDropPartitionsTool.Config().hashCode(), 
askedForHelp.hashCode());
+
+    right.hiveDataBase = "db";
+    assertNotEquals(left, right);
+    assertNotEquals(left.hashCode(), right.hashCode());
+
+    String printed = left.toString();
+    assertTrue(printed.contains("--base-path /tmp/table"));
+    assertTrue(printed.contains("--partitions p1,p2"));
+    assertTrue(printed.contains("--hoodie-conf [k=v]"));
+    assertTrue(printed.contains("--hive-user-name Masked"), "credentials must 
not be printed");
+  }
+}
diff --git 
a/hudi-utilities/src/test/java/org/apache/hudi/utilities/TestHoodieMetadataTableValidator.java
 
b/hudi-utilities/src/test/java/org/apache/hudi/utilities/TestHoodieMetadataTableValidator.java
index af8e3b4d49c6..26ca1b16278c 100644
--- 
a/hudi-utilities/src/test/java/org/apache/hudi/utilities/TestHoodieMetadataTableValidator.java
+++ 
b/hudi-utilities/src/test/java/org/apache/hudi/utilities/TestHoodieMetadataTableValidator.java
@@ -167,6 +167,24 @@ public class TestHoodieMetadataTableValidator extends 
HoodieSparkClientTestBase
     });
   }
 
+  @Test
+  public void testConfigEqualsAndHashCodeIgnoreHelp() {
+    HoodieMetadataTableValidator.Config config = new 
HoodieMetadataTableValidator.Config();
+    config.basePath = "/tmp/table";
+    HoodieMetadataTableValidator.Config askedForHelp = new 
HoodieMetadataTableValidator.Config();
+    askedForHelp.basePath = "/tmp/table";
+    askedForHelp.help = true;
+
+    // --help is not compared, so it must not be hashed either
+    assertEquals(config, askedForHelp);
+    assertEquals(config.hashCode(), askedForHelp.hashCode());
+
+    // a Config straight out of JCommander has no base path yet
+    assertEquals(new HoodieMetadataTableValidator.Config(), new 
HoodieMetadataTableValidator.Config());
+    assertEquals(new HoodieMetadataTableValidator.Config().hashCode(),
+        new HoodieMetadataTableValidator.Config().hashCode());
+  }
+
   @Test
   public void testAggregateColumnStats() {
     HoodieColumnRangeMetadata<Comparable> fileColumn1Range1 = 
HoodieColumnRangeMetadata.<Comparable>create(
diff --git 
a/hudi-utilities/src/test/java/org/apache/hudi/utilities/TestHoodieTTLJob.java 
b/hudi-utilities/src/test/java/org/apache/hudi/utilities/TestHoodieTTLJob.java
new file mode 100644
index 000000000000..846c11c91729
--- /dev/null
+++ 
b/hudi-utilities/src/test/java/org/apache/hudi/utilities/TestHoodieTTLJob.java
@@ -0,0 +1,130 @@
+/*
+ * 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.utilities;
+
+import org.apache.hudi.client.SparkRDDWriteClient;
+import org.apache.hudi.client.WriteClientTestUtils;
+import org.apache.hudi.client.WriteStatus;
+import org.apache.hudi.common.config.TypedProperties;
+import org.apache.hudi.common.model.HoodieRecord;
+import org.apache.hudi.common.model.HoodieReplaceCommitMetadata;
+import org.apache.hudi.common.table.HoodieTableMetaClient;
+import org.apache.hudi.common.table.timeline.HoodieInstant;
+import org.apache.hudi.config.HoodieCleanConfig;
+import org.apache.hudi.config.HoodieTTLConfig;
+import org.apache.hudi.config.HoodieWriteConfig;
+import org.apache.hudi.testutils.HoodieSparkClientTestBase;
+
+import org.apache.spark.api.java.JavaRDD;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.List;
+
+import static 
org.apache.hudi.common.testutils.HoodieTestDataGenerator.DEFAULT_FIRST_PARTITION_PATH;
+import static 
org.apache.hudi.common.testutils.HoodieTestDataGenerator.DEFAULT_SECOND_PARTITION_PATH;
+import static 
org.apache.hudi.common.testutils.HoodieTestDataGenerator.DEFAULT_THIRD_PARTITION_PATH;
+import static 
org.apache.hudi.common.testutils.HoodieTestDataGenerator.getCommitTimeAtUTC;
+import static 
org.apache.hudi.utilities.testutils.ToolTestUtils.latestBaseFileCount;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+
+/**
+ * Tests {@link HoodieTTLJob} on a table whose partitions were written at 
different times.
+ */
+public class TestHoodieTTLJob extends HoodieSparkClientTestBase {
+
+  private static final int RECORDS_PER_PARTITION = 4;
+
+  /**
+   * Both constructors are covered: with an explicit props/meta client pair, 
and with the (jsc, cfg) constructor
+   * that has to read --props and --hoodie-conf itself.
+   */
+  @ParameterizedTest
+  @ValueSource(booleans = {true, false})
+  public void testTtlDropsOnlyExpiredPartitions(boolean 
readPropsFromFileSystem) throws IOException {
+    writeOnePartitionPerInstant();
+
+    HoodieTTLJob.Config cfg = new HoodieTTLJob.Config();
+    cfg.basePath = basePath;
+    cfg.parallelism = 2;
+
+    HoodieTTLJob job;
+    if (readPropsFromFileSystem) {
+      Path propsFile = tempDir.resolve("ttl.properties");
+      Files.write(propsFile, Arrays.asList(
+          HoodieWriteConfig.TBL_NAME.key() + "=" + 
metaClient.getTableConfig().getTableName(),
+          HoodieTTLConfig.PARTITION_TTL_STRATEGY_TYPE.key() + 
"=KEEP_BY_TIME"), StandardCharsets.UTF_8);
+      cfg.propsFilePath = propsFile.toAbsolutePath().toString();
+      cfg.configs.add(HoodieTTLConfig.DAYS_RETAIN.key() + "=10");
+      job = new HoodieTTLJob(jsc, cfg);
+    } else {
+      TypedProperties props = new TypedProperties();
+      props.setProperty(HoodieWriteConfig.TBL_NAME.key(), 
metaClient.getTableConfig().getTableName());
+      props.setProperty(HoodieTTLConfig.PARTITION_TTL_STRATEGY_TYPE.key(), 
"KEEP_BY_TIME");
+      props.setProperty(HoodieTTLConfig.DAYS_RETAIN.key(), "10");
+      job = new HoodieTTLJob(jsc, cfg, props, metaClient);
+      // the job turns async cleaning off on the properties it was handed
+      assertEquals("false", 
props.get(HoodieCleanConfig.ASYNC_CLEAN.key()).toString());
+    }
+
+    job.run();
+
+    HoodieTableMetaClient reloaded = HoodieTableMetaClient.reload(metaClient);
+    HoodieInstant replaceInstant =
+        
reloaded.getActiveTimeline().getCompletedReplaceTimeline().lastInstant().get();
+    HoodieReplaceCommitMetadata replaceMetadata =
+        reloaded.getActiveTimeline().readReplaceCommitMetadata(replaceInstant);
+    assertEquals(
+        new HashSet<>(Arrays.asList(DEFAULT_FIRST_PARTITION_PATH, 
DEFAULT_SECOND_PARTITION_PATH)),
+        replaceMetadata.getPartitionToReplaceFileIds().keySet(),
+        "only the partitions older than the retention are dropped");
+
+    assertEquals(0, latestBaseFileCount(context, metaClient, 
DEFAULT_FIRST_PARTITION_PATH));
+    assertEquals(0, latestBaseFileCount(context, metaClient, 
DEFAULT_SECOND_PARTITION_PATH));
+    assertEquals(1, latestBaseFileCount(context, metaClient, 
DEFAULT_THIRD_PARTITION_PATH),
+        "the fresh partition must survive");
+    
assertFalse(replaceMetadata.getPartitionToReplaceFileIds().containsKey(DEFAULT_THIRD_PARTITION_PATH));
+  }
+
+  private void writeOnePartitionPerInstant() {
+    HoodieWriteConfig writeConfig = getConfigBuilder().build();
+    try (SparkRDDWriteClient client = getHoodieWriteClient(writeConfig)) {
+      // two partitions written far in the past, one written now
+      writeRecordsForPartition(client, DEFAULT_FIRST_PARTITION_PATH, 
getCommitTimeAtUTC(0));
+      writeRecordsForPartition(client, DEFAULT_SECOND_PARTITION_PATH, 
getCommitTimeAtUTC(1000));
+      writeRecordsForPartition(client, DEFAULT_THIRD_PARTITION_PATH, 
WriteClientTestUtils.createNewInstantTime());
+    }
+  }
+
+  private void writeRecordsForPartition(SparkRDDWriteClient client, String 
partition, String instantTime) {
+    List<HoodieRecord> records =
+        new ArrayList<>(dataGen.generateInsertsForPartition(instantTime, 
RECORDS_PER_PARTITION, partition));
+    WriteClientTestUtils.startCommitWithTime(client, instantTime);
+    JavaRDD<WriteStatus> writeStatuses = 
client.insert(jsc.parallelize(records, 1), instantTime);
+    client.commit(instantTime, writeStatuses);
+  }
+}
diff --git 
a/hudi-utilities/src/test/java/org/apache/hudi/utilities/TestTableSizeStats.java
 
b/hudi-utilities/src/test/java/org/apache/hudi/utilities/TestTableSizeStats.java
new file mode 100644
index 000000000000..d40a851befa1
--- /dev/null
+++ 
b/hudi-utilities/src/test/java/org/apache/hudi/utilities/TestTableSizeStats.java
@@ -0,0 +1,311 @@
+/*
+ * 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.utilities;
+
+import org.apache.hudi.client.SparkRDDWriteClient;
+import org.apache.hudi.client.WriteClientTestUtils;
+import org.apache.hudi.client.WriteStatus;
+import org.apache.hudi.common.model.HoodieRecord;
+import org.apache.hudi.config.HoodieWriteConfig;
+import org.apache.hudi.exception.HoodieException;
+import org.apache.hudi.exception.HoodieIOException;
+import org.apache.hudi.testutils.HoodieSparkClientTestBase;
+import org.apache.hudi.utilities.testutils.CapturingLogAppender;
+
+import org.apache.spark.api.java.JavaRDD;
+import org.junit.jupiter.api.Test;
+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.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.LocalDate;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
+import java.util.UUID;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import static 
org.apache.hudi.common.testutils.HoodieTestDataGenerator.DEFAULT_FIRST_PARTITION_PATH;
+import static 
org.apache.hudi.common.testutils.HoodieTestDataGenerator.DEFAULT_SECOND_PARTITION_PATH;
+import static 
org.apache.hudi.common.testutils.HoodieTestDataGenerator.DEFAULT_THIRD_PARTITION_PATH;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Tests {@link TableSizeStats}. The tool reports through its log, so the 
assertions read back the lines it
+ * logged for the table and for every partition it decided to include.
+ */
+public class TestTableSizeStats extends HoodieSparkClientTestBase {
+
+  private static final int RECORDS_PER_PARTITION = 4;
+  private static final String PARTITION_STATS_PREFIX = "Partition stats [name: 
";
+  // the tool parses partition names as yyyy/M/d, so a partition from 
yesterday is one --num-days can select
+  private static final String YESTERDAY_PARTITION_PATH =
+      
LocalDate.now().minusDays(1).format(DateTimeFormatter.ofPattern("yyyy/M/d"));
+
+  private static Stream<Arguments> dateIntervalArgs() {
+    return Stream.of(
+        // everything on or after the start date: the 2016 partition and 
yesterday's
+        Arguments.of("2016/1/1", null, 0L,
+            Arrays.asList(DEFAULT_FIRST_PARTITION_PATH, 
YESTERDAY_PARTITION_PATH)),
+        // only the 2015 partitions are before the end date
+        Arguments.of(null, "2016/1/1", 0L,
+            Arrays.asList(DEFAULT_SECOND_PARTITION_PATH, 
DEFAULT_THIRD_PARTITION_PATH)),
+        // half open interval [start, end): the start date is included, the 
end date is not
+        Arguments.of("2015/3/16", "2015/3/17", 0L, 
Collections.singletonList(DEFAULT_SECOND_PARTITION_PATH)),
+        // --num-days walks back from today: only yesterday's partition falls 
inside a ten day window
+        Arguments.of(null, null, 10L, 
Collections.singletonList(YESTERDAY_PARTITION_PATH)));
+  }
+
+  private TableSizeStats.Config statsConfig() {
+    TableSizeStats.Config cfg = new TableSizeStats.Config();
+    cfg.basePath = basePath;
+    cfg.parallelism = 2;
+    return cfg;
+  }
+
+  private void writeOneCommit(String... partitions) {
+    HoodieWriteConfig writeConfig = getConfigBuilder().build();
+    try (SparkRDDWriteClient client = getHoodieWriteClient(writeConfig)) {
+      String instantTime = WriteClientTestUtils.createNewInstantTime();
+      List<HoodieRecord> records = new ArrayList<>();
+      for (String partition : partitions) {
+        records.addAll(dataGen.generateInsertsForPartition(instantTime, 
RECORDS_PER_PARTITION, partition));
+      }
+      WriteClientTestUtils.startCommitWithTime(client, instantTime);
+      JavaRDD<WriteStatus> writeStatuses = 
client.insert(jsc.parallelize(records, 1), instantTime);
+      client.commit(instantTime, writeStatuses);
+    }
+  }
+
+  private void writeDefaultPartitions() {
+    writeOneCommit(DEFAULT_FIRST_PARTITION_PATH, 
DEFAULT_SECOND_PARTITION_PATH, DEFAULT_THIRD_PARTITION_PATH);
+  }
+
+  private List<String> runAndCollectLogs(TableSizeStats.Config cfg) {
+    try (CapturingLogAppender logs = 
CapturingLogAppender.attachTo(TableSizeStats.class)) {
+      new TableSizeStats(jsc, cfg).run();
+      return logs.messages();
+    }
+  }
+
+  private static Set<String> partitionStatHeaders(List<String> messages) {
+    return messages.stream().filter(m -> 
m.startsWith(PARTITION_STATS_PREFIX)).collect(Collectors.toSet());
+  }
+
+  private static String lineAfter(List<String> messages, String header) {
+    int index = messages.indexOf(header);
+    assertTrue(index >= 0 && index + 1 < messages.size(), "missing log line [" 
+ header + "] in " + messages);
+    return messages.get(index + 1);
+  }
+
+  @Test
+  public void testTableAndPartitionStatsCoverEveryPartition() {
+    writeDefaultPartitions();
+    TableSizeStats.Config cfg = statsConfig();
+    cfg.tableStats = true;
+    cfg.partitionStats = true;
+
+    List<String> messages = runAndCollectLogs(cfg);
+
+    Set<String> expectedHeaders = Stream.of(
+            DEFAULT_FIRST_PARTITION_PATH, DEFAULT_SECOND_PARTITION_PATH, 
DEFAULT_THIRD_PARTITION_PATH)
+        .map(p -> PARTITION_STATS_PREFIX + p + 
"]").collect(Collectors.toSet());
+    assertEquals(expectedHeaders, partitionStatHeaders(messages));
+    for (String header : expectedHeaders) {
+      assertEquals("Number of files: 1", lineAfter(messages, header));
+    }
+
+    String tableHeader = "Table stats [path: " + basePath + "]";
+    assertEquals("Number of files: 3", lineAfter(messages, tableHeader));
+    assertTrue(messages.stream().anyMatch(m -> m.matches("Total size: 
\\d+\\.\\d{2} (B|KB|MB|GB|TB)")),
+        "expected a formatted total size in " + messages);
+  }
+
+  @Test
+  public void testTotalSizeOnlyWhenTableStatsAreOff() {
+    writeDefaultPartitions();
+    List<String> messages = runAndCollectLogs(statsConfig());
+
+    assertEquals(Collections.emptySet(), partitionStatHeaders(messages),
+        "partition stats must stay off unless asked for");
+    assertTrue(messages.stream().noneMatch(m -> m.startsWith("Table stats 
[path: ")));
+    assertTrue(messages.stream().anyMatch(m -> m.matches("Total size: 
\\d+\\.\\d{2} (B|KB|MB|GB|TB)")),
+        "expected a formatted total size in " + messages);
+  }
+
+  @ParameterizedTest
+  @MethodSource("dateIntervalArgs")
+  public void testOnlyPartitionsInsideTheDateIntervalAreCounted(String 
startDate, String endDate, long numDays,
+                                                                List<String> 
expectedPartitions) {
+    writeOneCommit(DEFAULT_FIRST_PARTITION_PATH, 
DEFAULT_SECOND_PARTITION_PATH, DEFAULT_THIRD_PARTITION_PATH,
+        YESTERDAY_PARTITION_PATH);
+    TableSizeStats.Config cfg = statsConfig();
+    cfg.partitionStats = true;
+    cfg.startDate = startDate;
+    cfg.endDate = endDate;
+    cfg.numDays = numDays;
+
+    List<String> messages = runAndCollectLogs(cfg);
+
+    Set<String> expectedHeaders = expectedPartitions.stream()
+        .map(p -> PARTITION_STATS_PREFIX + p + ", has date: 
yes]").collect(Collectors.toSet());
+    assertEquals(expectedHeaders, partitionStatHeaders(messages));
+  }
+
+  @Test
+  public void testBasePathsAreReadFromThePropsFile() throws IOException {
+    writeDefaultPartitions();
+    Path propsFile = tempDir.resolve("base-paths.properties");
+    Files.write(propsFile, Collections.singletonList(basePath), 
StandardCharsets.UTF_8);
+
+    TableSizeStats.Config cfg = statsConfig();
+    cfg.basePath = null;
+    cfg.propsFilePath = propsFile.toAbsolutePath().toString();
+    cfg.tableStats = true;
+
+    List<String> messages = runAndCollectLogs(cfg);
+    assertEquals("Number of files: 3", lineAfter(messages, "Table stats [path: 
" + basePath + "]"));
+  }
+
+  /**
+   * --props-path is read twice: once by the constructor as a hoodie 
properties file, and again by run() as the
+   * list of base paths. A file that is missing from the start never reaches 
the second read.
+   */
+  @Test
+  public void testMissingPropsFileFailsInTheConstructor() {
+    TableSizeStats.Config cfg = statsConfig();
+    cfg.propsFilePath = tempDir.resolve("missing-" + UUID.randomUUID() + 
".properties").toAbsolutePath().toString();
+    HoodieIOException thrown =
+        assertThrows(HoodieIOException.class, () -> new TableSizeStats(jsc, 
cfg).run());
+    assertTrue(thrown.getMessage().contains("Properties file does not exist"), 
thrown.getMessage());
+  }
+
+  @Test
+  public void testPropsFileRemovedAfterTheConstructorFailsTheRun() throws 
IOException {
+    Path propsFile = tempDir.resolve("base-paths.properties");
+    Files.write(propsFile, Collections.singletonList(basePath), 
StandardCharsets.UTF_8);
+    TableSizeStats.Config cfg = statsConfig();
+    cfg.propsFilePath = propsFile.toAbsolutePath().toString();
+
+    TableSizeStats stats = new TableSizeStats(jsc, cfg);
+    Files.delete(propsFile);
+
+    HoodieException thrown = assertThrows(HoodieException.class, stats::run);
+    assertTrue(thrown.getCause().getMessage().contains("Cannot read properties 
from dfs from file"),
+        thrown.getCause().getMessage());
+  }
+
+  @Test
+  public void testMissingBasePathFails() {
+    TableSizeStats.Config cfg = statsConfig();
+    cfg.basePath = null;
+    HoodieException thrown = assertThrows(HoodieException.class, () -> new 
TableSizeStats(jsc, cfg).run());
+    assertTrue(thrown.getCause().getMessage().contains("Base path needs to be 
set."), thrown.getCause().toString());
+  }
+
+  @Test
+  public void testDateIntervalOnPartitionsWithoutDatesFails() {
+    writeOneCommit("country=us");
+    TableSizeStats.Config cfg = statsConfig();
+    cfg.numDays = 10;
+
+    HoodieException thrown = assertThrows(HoodieException.class, () -> new 
TableSizeStats(jsc, cfg).run());
+    assertTrue(thrown.getCause().getMessage().contains("Cannot apply 
--start-date, --end-date, or --num-days"),
+        thrown.getCause().getMessage());
+    assertTrue(thrown.getCause().getMessage().contains("country=us"), 
thrown.getCause().getMessage());
+  }
+
+  @Test
+  public void testStartDateAfterEndDateFails() {
+    TableSizeStats.Config cfg = statsConfig();
+    cfg.startDate = "2017/1/1";
+    cfg.endDate = "2016/1/1";
+    HoodieException thrown = assertThrows(HoodieException.class, () -> new 
TableSizeStats(jsc, cfg).run());
+    assertTrue(thrown.getCause().getMessage().contains("Starting date must be 
before ending date"),
+        thrown.getCause().getMessage());
+  }
+
+  @Test
+  public void testNegativeNumDaysFails() {
+    TableSizeStats.Config cfg = statsConfig();
+    cfg.numDays = -1;
+    HoodieException thrown = assertThrows(HoodieException.class, () -> new 
TableSizeStats(jsc, cfg).run());
+    assertTrue(thrown.getCause().getMessage().contains("--num-days must 
specify a positive value"),
+        thrown.getCause().getMessage());
+  }
+
+  @Test
+  public void testUnparseableEndDateFails() {
+    TableSizeStats.Config cfg = statsConfig();
+    cfg.endDate = "yesterday";
+    HoodieException thrown = assertThrows(HoodieException.class, () -> new 
TableSizeStats(jsc, cfg).run());
+    assertTrue(thrown.getCause().getMessage().contains("Unable to parse 
--end-date"),
+        thrown.getCause().getMessage());
+  }
+
+  @Test
+  public void testConfigEqualsHashCodeAndToString() {
+    TableSizeStats.Config left = new TableSizeStats.Config();
+    left.basePath = "/tmp/table";
+    left.numDays = 3;
+    left.tableStats = true;
+    left.configs = new ArrayList<>(Collections.singletonList("k=v"));
+
+    TableSizeStats.Config right = new TableSizeStats.Config();
+    right.basePath = "/tmp/table";
+    right.numDays = 3;
+    right.tableStats = true;
+    right.configs = new ArrayList<>(Collections.singletonList("k=v"));
+
+    assertEquals(left, left);
+    assertEquals(left, right);
+    assertEquals(left.hashCode(), right.hashCode());
+    assertNotEquals(left, null);
+    assertNotEquals(left, "not a config");
+    // a Config straight out of JCommander has no base path yet
+    assertEquals(new TableSizeStats.Config(), new TableSizeStats.Config());
+    assertEquals(new TableSizeStats.Config().hashCode(), new 
TableSizeStats.Config().hashCode());
+    // --help is not compared, so it must not be hashed either
+    TableSizeStats.Config askedForHelp = new TableSizeStats.Config();
+    askedForHelp.help = true;
+    assertEquals(new TableSizeStats.Config(), askedForHelp);
+    assertEquals(new TableSizeStats.Config().hashCode(), 
askedForHelp.hashCode());
+
+    right.endDate = "2016/1/1";
+    assertNotEquals(left, right);
+    assertNotEquals(left.hashCode(), right.hashCode());
+
+    String printed = left.toString();
+    assertTrue(printed.contains("--base-path /tmp/table"));
+    assertTrue(printed.contains("--num-days 3"));
+    assertTrue(printed.contains("--enable-table-stats true"));
+    assertTrue(printed.contains("--hoodie-conf [k=v]"));
+  }
+}
diff --git 
a/hudi-utilities/src/test/java/org/apache/hudi/utilities/testutils/CapturingLogAppender.java
 
b/hudi-utilities/src/test/java/org/apache/hudi/utilities/testutils/CapturingLogAppender.java
new file mode 100644
index 000000000000..feb36ba107bc
--- /dev/null
+++ 
b/hudi-utilities/src/test/java/org/apache/hudi/utilities/testutils/CapturingLogAppender.java
@@ -0,0 +1,85 @@
+/*
+ * 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.utilities.testutils;
+
+import org.apache.logging.log4j.Level;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.core.LogEvent;
+import org.apache.logging.log4j.core.Logger;
+import org.apache.logging.log4j.core.appender.AbstractAppender;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.UUID;
+
+/**
+ * Collects the messages one class logs, for tools whose only output is their 
log.
+ *
+ * <p>{@code addAppender} gives the named logger a {@link 
org.apache.logging.log4j.core.config.LoggerConfig} of
+ * its own, cloned from the nearest configured ancestor, and refreshes every 
live logger from it. That refresh
+ * drops any level set beforehand, so the level is raised only after the 
appender is in place. Events of child
+ * loggers still reach the appender through that config, hence the logger name 
check in
+ * {@link #append(LogEvent)}.
+ */
+public class CapturingLogAppender extends AbstractAppender implements 
AutoCloseable {
+
+  private final String loggerName;
+  private final Logger logger;
+  private final Level previousLevel;
+  private final List<String> messages = Collections.synchronizedList(new 
ArrayList<>());
+
+  private CapturingLogAppender(String loggerName, Level level) {
+    super("Capture-" + loggerName + "-" + UUID.randomUUID(), null, null, 
false, null);
+    this.loggerName = loggerName;
+    this.logger = (Logger) LogManager.getLogger(loggerName);
+    this.previousLevel = logger.getLevel();
+    start();
+    logger.addAppender(this);
+    logger.setLevel(level);
+  }
+
+  /**
+   * Starts capturing everything {@code loggerClass} logs at INFO or above, 
until the returned appender is closed.
+   */
+  public static CapturingLogAppender attachTo(Class<?> loggerClass) {
+    return new CapturingLogAppender(loggerClass.getName(), Level.INFO);
+  }
+
+  @Override
+  public void append(LogEvent event) {
+    if (loggerName.equals(event.getLoggerName())) {
+      messages.add(event.getMessage().getFormattedMessage());
+    }
+  }
+
+  /**
+   * The formatted messages captured so far, in the order they were logged.
+   */
+  public List<String> messages() {
+    return new ArrayList<>(messages);
+  }
+
+  @Override
+  public void close() {
+    logger.removeAppender(this);
+    logger.setLevel(previousLevel);
+    stop();
+  }
+}
diff --git 
a/hudi-utilities/src/test/java/org/apache/hudi/utilities/testutils/ToolTestUtils.java
 
b/hudi-utilities/src/test/java/org/apache/hudi/utilities/testutils/ToolTestUtils.java
new file mode 100644
index 000000000000..805f4ffa8dcb
--- /dev/null
+++ 
b/hudi-utilities/src/test/java/org/apache/hudi/utilities/testutils/ToolTestUtils.java
@@ -0,0 +1,59 @@
+/*
+ * 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.utilities.testutils;
+
+import org.apache.hudi.common.config.HoodieMetadataConfig;
+import org.apache.hudi.common.engine.HoodieEngineContext;
+import org.apache.hudi.common.table.HoodieTableMetaClient;
+import org.apache.hudi.common.table.view.FileSystemViewManager;
+import org.apache.hudi.common.table.view.HoodieTableFileSystemView;
+
+/**
+ * Assertion helpers shared by the tests of the standalone spark tools.
+ */
+public final class ToolTestUtils {
+
+  private ToolTestUtils() {
+  }
+
+  /**
+   * The message of {@code throwable} and of every cause under it, one per 
line. The tools wrap what they catch,
+   * so the detail a test cares about is usually a few causes down.
+   */
+  public static String stackMessages(Throwable throwable) {
+    StringBuilder sb = new StringBuilder();
+    for (Throwable t = throwable; t != null; t = t.getCause()) {
+      sb.append(t.getMessage()).append('\n');
+    }
+    return sb.toString();
+  }
+
+  /**
+   * How many base files the latest file slices of {@code partition} hold, 
listed off the file system rather than
+   * the metadata table so that a partition dropped by a replacecommit reads 
as empty.
+   */
+  public static long latestBaseFileCount(HoodieEngineContext context, 
HoodieTableMetaClient metaClient,
+                                         String partition) {
+    HoodieTableMetaClient reloaded = HoodieTableMetaClient.reload(metaClient);
+    try (HoodieTableFileSystemView fsView = 
FileSystemViewManager.createInMemoryFileSystemView(
+        context, reloaded, 
HoodieMetadataConfig.newBuilder().enable(false).build())) {
+      return fsView.getLatestBaseFiles(partition).count();
+    }
+  }
+}

Reply via email to