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

saihemanth-cloudera pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/hive.git


The following commit(s) were added to refs/heads/master by this push:
     new 58f7341617c HIVE-29724: Provide a Metastore tool for de-duplicating 
the columns (#6719)
58f7341617c is described below

commit 58f7341617c1185bafe9b2ee9c30429877b586c7
Author: dengzh <[email protected]>
AuthorDate: Wed Sep 30 01:07:26 2026 +0800

    HIVE-29724: Provide a Metastore tool for de-duplicating the columns (#6719)
---
 .../hadoop/hive/metastore/conf/MetastoreConf.java  |   2 +
 .../metastore/metastore/impl/TableStoreImpl.java   |   8 +-
 .../hive/metastore/tools/ColumnDeduplicator.java   | 343 +++++++++++++++++++++
 .../hive/metastore/tools/MetaToolObjectStore.java  |  88 +++++-
 .../metastore/tools/metatool/HiveMetaTool.java     |   3 +
 .../tools/metatool/HiveMetaToolCommandLine.java    |  87 +++++-
 .../tools/metatool/MetaToolTaskDedupColumns.java   | 120 +++++++
 .../metastore/TestHMSColumnDescriptorReuse.java    |  64 ++++
 .../metatool/TestHiveMetaToolCommandLine.java      |  53 +++-
 9 files changed, 742 insertions(+), 26 deletions(-)

diff --git 
a/standalone-metastore/metastore-common/src/main/java/org/apache/hadoop/hive/metastore/conf/MetastoreConf.java
 
b/standalone-metastore/metastore-common/src/main/java/org/apache/hadoop/hive/metastore/conf/MetastoreConf.java
index 83542c02c4e..853139b26e7 100644
--- 
a/standalone-metastore/metastore-common/src/main/java/org/apache/hadoop/hive/metastore/conf/MetastoreConf.java
+++ 
b/standalone-metastore/metastore-common/src/main/java/org/apache/hadoop/hive/metastore/conf/MetastoreConf.java
@@ -2054,6 +2054,8 @@ public enum ConfVars {
         "The maximum non-native tables allowed per table type during 
collecting the summary."),
     
METADATA_SUMMARY_NONNATIVE_THREADS("hive.metatool.summary.nonnative.threads", 
"hive.metatool.summary.nonnative.threads", 20,
         "Number of threads to be allocated for MetaToolTaskMetadataSummary for 
collecting the non-native table's summary."),
+    DEDUP_COLUMNS_TIMEOUT("hive.metatool.dedupColumns.timeout", 
"hive.metatool.dedupColumns.timeout", 60, TimeUnit.MINUTES,
+        "The maximum time in minutes for the -dedupColumns metatool command to 
run before timing out."),
     METASTORE_SUPPORT_ACID("metastore.support.acid", 
"hive.metastore.support.acid", true,
         "Whether to support acid functionality in Hive metastore server."),
 
diff --git 
a/standalone-metastore/metastore-server/src/main/java/org/apache/hadoop/hive/metastore/metastore/impl/TableStoreImpl.java
 
b/standalone-metastore/metastore-server/src/main/java/org/apache/hadoop/hive/metastore/metastore/impl/TableStoreImpl.java
index f1fa5aaac40..069b04043fe 100644
--- 
a/standalone-metastore/metastore-server/src/main/java/org/apache/hadoop/hive/metastore/metastore/impl/TableStoreImpl.java
+++ 
b/standalone-metastore/metastore-server/src/main/java/org/apache/hadoop/hive/metastore/metastore/impl/TableStoreImpl.java
@@ -23,6 +23,7 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.Lists;
 
+import javax.jdo.PersistenceManager;
 import javax.jdo.Query;
 import java.util.ArrayList;
 import java.util.Arrays;
@@ -146,7 +147,6 @@
 @SuppressWarnings("unchecked")
 public class TableStoreImpl extends RawStoreBundle implements TableStore {
   private final static Logger LOG = 
LoggerFactory.getLogger(TableStoreImpl.class);
-  private DatabaseProduct dbType;
   protected int batchSize = NO_BATCHING;
   private boolean areTxnStatsSupported = false;
   private PartitionExpressionProxy expressionProxy = null;
@@ -155,7 +155,6 @@ public class TableStoreImpl extends RawStoreBundle 
implements TableStore {
   @Override
   public void setBaseStore(RawStore store) {
     super.setBaseStore(store);
-    this.dbType = PersistenceManagerProvider.getDatabaseProduct();
     this.batchSize = MetastoreConf.getIntVar(store.getConf(),
         MetastoreConf.ConfVars.RAWSTORE_PARTITION_BATCH_SIZE);
     this.areTxnStatsSupported = MetastoreConf.getBoolVar(baseStore.getConf(),
@@ -442,7 +441,7 @@ private void removeUnusedColumnDescriptor(MColumnDescriptor 
oldCD) {
       return;
     }
     LOG.debug("execute removeUnusedColumnDescriptor");
-    if (!hasRemainingCDReference(oldCD)) {
+    if (!hasRemainingCDReference(pm, oldCD)) {
       // First remove any constraints that may be associated with this CD
       Query query = pm.newQuery(MConstraint.class, "parentColumn == inCD || 
childColumn == inCD");
       query.declareParameters("MColumnDescriptor inCD");
@@ -464,8 +463,9 @@ private void removeUnusedColumnDescriptor(MColumnDescriptor 
oldCD) {
    * @param oldCD the column descriptor to check if it has references or not
    * @return true if has references
    */
-  private boolean hasRemainingCDReference(MColumnDescriptor oldCD) {
+  public static boolean hasRemainingCDReference(PersistenceManager pm, 
MColumnDescriptor oldCD) {
     assert oldCD != null;
+    DatabaseProduct dbType = PersistenceManagerProvider.getDatabaseProduct();
     Query query;
     /**
      * In order to workaround oracle not supporting limit statement caused 
performance issue, HIVE-9447 makes
diff --git 
a/standalone-metastore/metastore-server/src/main/java/org/apache/hadoop/hive/metastore/tools/ColumnDeduplicator.java
 
b/standalone-metastore/metastore-server/src/main/java/org/apache/hadoop/hive/metastore/tools/ColumnDeduplicator.java
new file mode 100644
index 00000000000..b4854c80b3b
--- /dev/null
+++ 
b/standalone-metastore/metastore-server/src/main/java/org/apache/hadoop/hive/metastore/tools/ColumnDeduplicator.java
@@ -0,0 +1,343 @@
+/*
+ * 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.hadoop.hive.metastore.tools;
+
+import org.apache.commons.collections4.CollectionUtils;
+import org.apache.hadoop.hive.metastore.Deadline;
+import org.apache.hadoop.hive.metastore.RawStore;
+import org.apache.hadoop.hive.metastore.api.FieldSchema;
+import org.apache.hadoop.hive.metastore.api.MetaException;
+import org.apache.hadoop.hive.metastore.metastore.RawStoreBundle;
+import org.apache.hadoop.hive.metastore.model.MColumnDescriptor;
+import org.apache.hadoop.hive.metastore.model.MConstraint;
+import org.apache.hadoop.hive.metastore.model.MPartition;
+import org.apache.hadoop.hive.metastore.model.MStorageDescriptor;
+import org.apache.hadoop.hive.metastore.model.MTable;
+
+import javax.jdo.JDOHelper;
+import javax.jdo.PersistenceManager;
+import javax.jdo.Query;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.stream.Collectors;
+
+import static 
org.apache.hadoop.hive.metastore.ObjectStore.appendPatternCondition;
+import static 
org.apache.hadoop.hive.metastore.metastore.impl.TableStoreImpl.convertToFieldSchemas;
+import static 
org.apache.hadoop.hive.metastore.metastore.impl.TableStoreImpl.hasRemainingCDReference;
+import static org.apache.hadoop.hive.metastore.utils.StringUtils.isEmpty;
+
+/**
+ * De-duplicates column descriptors (CDs) for partitioned tables in the 
metastore.
+ * Identical column schemas within a table are merged so that partitions share
+ * the same CD, reducing metadata bloat that can accumulate during replication.
+ */
+final class ColumnDeduplicator {
+  private final RawStore store;
+  private final PersistenceManager pm;
+  private final AtomicReference<String> progress;
+  private final boolean isDryRun;
+  private final boolean isVerbose;
+
+  ColumnDeduplicator(RawStoreBundle bundle, AtomicReference<String> progress,
+      boolean isDryRun, boolean isVerbose) {
+    this.store = bundle.getBaseStore();
+    this.pm = bundle.getPersistentManager();
+    this.progress = progress;
+    this.isDryRun = isDryRun;
+    this.isVerbose = isVerbose;
+  }
+
+  MetaToolObjectStore.DedupColumnsResult run(String catalogFilter, String 
dbFilter, String tableFilter) {
+    List<TableInfo> tables = findPartitionedTables(catalogFilter, dbFilter, 
tableFilter);
+    MetaToolObjectStore.DedupColumnsResult result = new 
MetaToolObjectStore.DedupColumnsResult(tables.size());
+
+    long start = System.currentTimeMillis();
+    for (int i = 0; i < tables.size() && result.getException() == null; i++) {
+      boolean committed = false;
+      TableInfo table = tables.get(i);
+      store.openTransaction();
+      try {
+        Deadline.checkTimeout();
+        deduplicateTable(table, result);
+        committed = store.commitTransaction();
+      } catch (Exception ex) {
+        result.catchException(ex);
+      } finally {
+        if (!committed) {
+          store.rollbackTransaction();
+          if (result.getException() == null) {
+            result.catchException(
+                new MetaException("Failed to apply column descriptor 
de-duplication updates for table " + table));
+          }
+        }
+      }
+      if (progress != null) {
+        progress.set(String.format(
+            "Finished %d tables in %d total tables, time taken: %d ms, columns 
updated: %d, removed: %d",
+            (i + 1),
+            result.getTablesScanned(),
+            (System.currentTimeMillis() - start),
+            result.getStorageDescriptorsUpdated(),
+            result.getColumnDescriptorsRemoved()));
+      }
+    }
+    return result;
+  }
+
+  private void deduplicateTable(TableInfo table, 
MetaToolObjectStore.DedupColumnsResult result) {
+    List<PartitionSdInfo> partitionSds = 
loadPartitionStorageDescriptors(table.tableId);
+    if (partitionSds.isEmpty()) {
+      return;
+    }
+
+    Set<Long> cdIds = partitionSds.stream().map(p -> 
p.cdId).collect(Collectors.toSet());
+    cdIds.add(table.tableCdId);
+
+    Map<Long, List<FieldSchema>> cdColumns = loadColumnSchemas(cdIds);
+    Map<List<FieldSchema>, List<Long>> groups = groupByColumnSchema(cdColumns);
+
+    Map<Long, Long> cdRemap = new HashMap<>();
+    for (List<Long> group : groups.values()) {
+      if (group.size() <= 1) {
+        continue;
+      }
+      long canonicalCdId = pickCanonicalCdId(new HashSet<>(group), 
table.tableCdId, partitionSds);
+      for (long cdId : group) {
+        if (cdId != canonicalCdId) {
+          cdRemap.put(cdId, canonicalCdId);
+        }
+      }
+    }
+
+    if (cdRemap.isEmpty()) {
+      return;
+    }
+
+    List<Map.Entry<PartitionSdInfo, Long>> partSdUpdates = 
buildPartitionUpdates(partitionSds, cdRemap);
+    if (partSdUpdates.isEmpty()) {
+      return;
+    }
+
+    result.incrementTablesWithDuplicates();
+    for (Map.Entry<PartitionSdInfo, Long> update : partSdUpdates) {
+      result.incrementStorageDescriptorsUpdated();
+      if (isVerbose) {
+        PartitionSdInfo partSd = update.getKey();
+        long newCdId = update.getValue();
+        result.addDetail(String.format("table %s.%s.%s: SD %s CD %d -> %d",
+            table.catalogName, table.dbName, table.tableName,
+            JDOHelper.getObjectId(partSd.sd), partSd.cdId, newCdId));
+      }
+    }
+    if (!isDryRun) {
+      applyTableChanges(partSdUpdates, result);
+    }
+  }
+
+  private void applyTableChanges(List<Map.Entry<PartitionSdInfo, Long>> 
partSdUpdates,
+      MetaToolObjectStore.DedupColumnsResult result) {
+    Set<Long> replacedCdIds = new HashSet<>();
+    Map<Long, MColumnDescriptor> newCDs = new HashMap<>();
+    for (Map.Entry<PartitionSdInfo, Long> update : partSdUpdates) {
+      PartitionSdInfo partSd = update.getKey();
+      long newCdId = update.getValue();
+      MColumnDescriptor canonicalCd =
+          newCDs.computeIfAbsent(newCdId, id -> 
pm.getObjectById(MColumnDescriptor.class, id));
+      partSd.sd.setCD(canonicalCd);
+      replacedCdIds.add(partSd.cdId);
+    }
+    result.addColumnDescriptorsRemoved(deleteUnusedColumnDescriptors(pm, 
replacedCdIds));
+  }
+
+  private List<Map.Entry<PartitionSdInfo, Long>> buildPartitionUpdates(
+      List<PartitionSdInfo> partitionSds, Map<Long, Long> cdRemap) {
+    List<Map.Entry<PartitionSdInfo, Long>> updates = new ArrayList<>();
+    for (PartitionSdInfo partSd : partitionSds) {
+      Long newCdId = cdRemap.get(partSd.cdId);
+      if (newCdId != null && !newCdId.equals(partSd.cdId)) {
+        updates.add(Map.entry(partSd, newCdId));
+      }
+    }
+    return updates;
+  }
+
+  private long pickCanonicalCdId(Set<Long> group, long tableCdId, 
List<PartitionSdInfo> partitionSds) {
+    if (group.contains(tableCdId)) {
+      return tableCdId;
+    }
+    Map<Long, Long> usageCount = new HashMap<>();
+    for (PartitionSdInfo partSd : partitionSds) {
+      if (group.contains(partSd.cdId)) {
+        usageCount.merge(partSd.cdId, 1L, Long::sum);
+      }
+    }
+    return group.stream()
+        .max((a, b) -> {
+          int usageCompare = Long.compare(usageCount.getOrDefault(a, 0L), 
usageCount.getOrDefault(b, 0L));
+          return usageCompare != 0 ? usageCompare : Long.compare(b, a);
+        })
+        .orElse(group.iterator().next());
+  }
+
+  private List<TableInfo> findPartitionedTables(String catalogFilter, String 
dbFilter, String tableFilter) {
+    StringBuilder filter = new StringBuilder();
+    List<String> parameterVals = new ArrayList<>();
+    if (!isEmpty(catalogFilter)) {
+      appendPatternCondition(filter, "table.database.catalogName", 
catalogFilter, parameterVals);
+    }
+    if (!isEmpty(dbFilter)) {
+      appendPatternCondition(filter, "table.database.name", dbFilter, 
parameterVals);
+    }
+    if (!isEmpty(tableFilter)) {
+      appendPatternCondition(filter, "table.tableName", tableFilter, 
parameterVals);
+    }
+
+    Query query = !filter.isEmpty() ?
+        pm.newQuery(MPartition.class, filter.toString()) :
+        pm.newQuery(MPartition.class);
+    query.setResult("DISTINCT this.table");
+    boolean success = false;
+    List<TableInfo> tables = new ArrayList<>();
+    store.openTransaction();
+    try {
+      List<MTable> mTables = (List<MTable>) 
query.executeWithArray(parameterVals.toArray(new String[0]));
+      pm.retrieveAll(mTables);
+      for (MTable mTable : mTables) {
+        pm.retrieve(mTable.getDatabase());
+        pm.retrieve(mTable.getSd());
+        pm.retrieve(mTable.getSd().getCD());
+        tables.add(new TableInfo(
+            mTable.getId(),
+            mTable.getSd().getCD().getId(),
+            mTable.getDatabase().getCatalogName(),
+            mTable.getDatabase().getName(),
+            mTable.getTableName()));
+      }
+      success = store.commitTransaction();
+    } finally {
+      query.closeAll();
+      if (!success) {
+        store.rollbackTransaction();
+      }
+    }
+    return tables;
+  }
+
+  private List<PartitionSdInfo> loadPartitionStorageDescriptors(long tableId) {
+    Query query = pm.newQuery(MPartition.class, "table.id == tblId");
+    query.declareParameters("long tblId");
+    query.setResult("sd");
+    List<PartitionSdInfo> partitionSds = new ArrayList<>();
+    try {
+      List<MStorageDescriptor> sds = (List<MStorageDescriptor>) 
query.execute(tableId);
+      if (sds == null) {
+        return partitionSds;
+      }
+      pm.retrieveAll(sds);
+      for (MStorageDescriptor sd : sds) {
+        pm.retrieve(sd.getCD());
+        partitionSds.add(new PartitionSdInfo(sd, sd.getCD().getId()));
+      }
+    } finally {
+      query.closeAll();
+    }
+    return partitionSds;
+  }
+
+  private Map<Long, List<FieldSchema>> loadColumnSchemas(Set<Long> cdIds) {
+    Map<Long, List<FieldSchema>> result = new HashMap<>();
+    for (Long cdId : cdIds) {
+      MColumnDescriptor cd = pm.getObjectById(MColumnDescriptor.class, cdId);
+      if (cd != null) {
+        pm.retrieve(cd);
+        result.put(cdId, convertToFieldSchemas(cd.getCols()));
+      }
+    }
+    return result;
+  }
+
+  private static Map<List<FieldSchema>, List<Long>> 
groupByColumnSchema(Map<Long, List<FieldSchema>> cdColumns) {
+    Map<List<FieldSchema>, List<Long>> groups = new HashMap<>();
+    for (Map.Entry<Long, List<FieldSchema>> entry : cdColumns.entrySet()) {
+      groups.computeIfAbsent(entry.getValue(), ignored -> new ArrayList<>())
+          .add(entry.getKey());
+    }
+    return groups;
+  }
+
+  private int deleteUnusedColumnDescriptors(PersistenceManager pm, Set<Long> 
candidateCdIds) {
+    int removed = 0;
+    for (long cdId : candidateCdIds) {
+      MColumnDescriptor cd = pm.getObjectById(MColumnDescriptor.class, cdId);
+      if (cd == null || hasRemainingCDReference(pm, cd)) {
+        continue;
+      }
+      removeConstraintsForCd(cd);
+      pm.retrieve(cd);
+      pm.deletePersistent(cd);
+      removed++;
+    }
+    return removed;
+  }
+
+  /** Same constraint cleanup as {@code 
TableStoreImpl.removeUnusedColumnDescriptor}. */
+  private void removeConstraintsForCd(MColumnDescriptor cd) {
+    Query query = pm.newQuery(MConstraint.class, "parentColumn == inCD || 
childColumn == inCD");
+    query.declareParameters("MColumnDescriptor inCD");
+    try {
+      List<MConstraint> constraints = (List<MConstraint>) query.execute(cd);
+      if (CollectionUtils.isNotEmpty(constraints)) {
+        pm.deletePersistentAll(constraints);
+      }
+    } finally {
+      query.closeAll();
+    }
+  }
+
+  private static final class TableInfo {
+    private final long tableId;
+    private final long tableCdId;
+    private final String catalogName;
+    private final String dbName;
+    private final String tableName;
+
+    private TableInfo(long tableId, long tableCdId, String catalogName, String 
dbName, String tableName) {
+      this.tableId = tableId;
+      this.tableCdId = tableCdId;
+      this.catalogName = catalogName;
+      this.dbName = dbName;
+      this.tableName = tableName;
+    }
+
+    @Override
+    public String toString() {
+      return catalogName + "." + dbName + "." + tableName;
+    }
+  }
+
+  private record PartitionSdInfo(MStorageDescriptor sd, long cdId) {
+
+  }
+}
diff --git 
a/standalone-metastore/metastore-server/src/main/java/org/apache/hadoop/hive/metastore/tools/MetaToolObjectStore.java
 
b/standalone-metastore/metastore-server/src/main/java/org/apache/hadoop/hive/metastore/tools/MetaToolObjectStore.java
index a65e0b280b1..67a97243bd5 100644
--- 
a/standalone-metastore/metastore-server/src/main/java/org/apache/hadoop/hive/metastore/tools/MetaToolObjectStore.java
+++ 
b/standalone-metastore/metastore-server/src/main/java/org/apache/hadoop/hive/metastore/tools/MetaToolObjectStore.java
@@ -32,6 +32,7 @@
 import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
+import java.util.concurrent.atomic.AtomicReference;
 import java.util.stream.Collectors;
 
 import org.apache.commons.lang3.tuple.Pair;
@@ -672,7 +673,8 @@ public List<MetadataTableSummary> getMetadataSummary(String 
catalogFilter,
     return metadataTableSummaryList;
   }
 
-  private void collectPartitionSummary(Map<Long, MetadataTableSummary> 
summaries,  Set<Long> partedTabs,
+  private void collectPartitionSummary(Map<Long, MetadataTableSummary> 
summaries,
+      Set<Long> partedTabs,
       Set<Long> nonPartedTabs) throws MetaException {
     String queryText0 = "select \"TBL_ID\", count(1) from \"PARTITION_KEYS\" 
where \"TBL_ID\" in (";
     runBatched(batchSize, new ArrayList<>(summaries.keySet()), new 
Batchable<Long, Void>() {
@@ -724,7 +726,8 @@ public List<Void> run(List<Long> input) throws Exception {
   private void collectColumnSummary(Map<Long, MetadataTableSummary> summaries) 
throws MetaException {
     String queryText0 = "select \"TBL_ID\", count(*), sum(CASE WHEN 
\"TYPE_NAME\" like 'array%' THEN 1 ELSE 0 END)," +
         " sum(CASE WHEN \"TYPE_NAME\" like 'struct%' THEN 1 ELSE 0 END), 
sum(CASE WHEN \"TYPE_NAME\" like 'map%' THEN 1 ELSE 0 END)" +
-        " from \"TBLS\" t join \"SDS\" s on t.\"SD_ID\" = s.\"SD_ID\" join 
\"CDS\" c on s.\"CD_ID\" = c.\"CD_ID\" join \"COLUMNS_V2\" v on c.\"CD_ID\" = 
v.\"CD_ID\"" +
+        " from \"TBLS\" t join \"SDS\" s on t.\"SD_ID\" = s.\"SD_ID\" join 
\"CDS\" c" +
+        " on s.\"CD_ID\" = c.\"CD_ID\" join \"COLUMNS_V2\" v on c.\"CD_ID\" = 
v.\"CD_ID\"" +
         " where \"TBL_ID\" in (";
     runBatched(batchSize, new ArrayList<>(summaries.keySet()), new 
Batchable<Long, Void>() {
       @Override
@@ -750,7 +753,8 @@ public List<Void> run(List<Long> input) throws Exception {
   }
 
   private void collectTabFormatSummary(Map<Long, MetadataTableSummary> 
summaries) throws MetaException {
-    String queryText0 = "select t.\"TBL_ID\", d.\"SLIB\", s.\"IS_COMPRESSED\" 
from \"TBLS\" t left join \"SDS\" s on t.\"SD_ID\" = s.\"SD_ID\" left join 
\"SERDES\" d on d.\"SERDE_ID\" = s.\"SERDE_ID\"" +
+    String queryText0 = "select t.\"TBL_ID\", d.\"SLIB\", s.\"IS_COMPRESSED\" 
from \"TBLS\" t left join \"SDS\" s" +
+        " on t.\"SD_ID\" = s.\"SD_ID\" left join \"SERDES\" d on 
d.\"SERDE_ID\" = s.\"SERDE_ID\"" +
         " where t.\"TBL_ID\" in (";
     String queryText1 = "select p.\"TBL_ID\", " + 
dbType.toVarChar("p.\"PARAM_VALUE\"") + " from \"TABLE_PARAMS\" p " +
         " where p.\"PARAM_KEY\" = 'transactional_properties' and p.\"TBL_ID\" 
in (";
@@ -843,8 +847,10 @@ private void collectTabFormatSummary(List<Long> 
transactionalTables, Long tableI
 
   private void collectBasicStats(Map<Long, MetadataTableSummary> summaries, 
Set<Long> nonPartedTabs,
       Set<Long> partedTabs) throws MetaException {
-    String queryText0 = "select \"TBL_ID\", \"PARAM_KEY\", CAST(" + 
dbType.toVarChar("\"PARAM_VALUE\"") + " AS decimal(21,0)) from \"TABLE_PARAMS\" 
where \"PARAM_KEY\" " +
-        "in ('" + StatsSetupConst.TOTAL_SIZE + "', '" + 
StatsSetupConst.NUM_FILES + "', '" + StatsSetupConst.ROW_COUNT + "') and 
\"TBL_ID\" in (";
+    String queryText0 = "select \"TBL_ID\", \"PARAM_KEY\", CAST(" + 
dbType.toVarChar("\"PARAM_VALUE\"") +
+        " AS decimal(21,0)) from \"TABLE_PARAMS\" where \"PARAM_KEY\"" +
+        " in ('" + StatsSetupConst.TOTAL_SIZE + "', '" + 
StatsSetupConst.NUM_FILES + "', '" +
+        StatsSetupConst.ROW_COUNT + "') and \"TBL_ID\" in (";
     runBatched(batchSize, new ArrayList<>(nonPartedTabs), new Batchable<Long, 
Void>() {
       @Override
       public List<Void> run(List<Long> input) throws Exception {
@@ -853,9 +859,11 @@ public List<Void> run(List<Long> input) throws Exception {
       }
     });
 
-   String queryText1 = "select \"TBL_ID\", \"PARAM_KEY\", sum(CAST(" + 
dbType.toVarChar("\"PARAM_VALUE\"") + " AS decimal(21,0))) from \"PARTITIONS\" 
t " +
-       "join \"PARTITION_PARAMS\" p on p.\"PART_ID\" = t.\"PART_ID\" where 
\"PARAM_KEY\" " +
-       "in ('" + StatsSetupConst.TOTAL_SIZE + "', '" + 
StatsSetupConst.NUM_FILES + "', '" + StatsSetupConst.ROW_COUNT + "') and 
t.\"TBL_ID\" in (";
+   String queryText1 = "select \"TBL_ID\", \"PARAM_KEY\", sum(CAST(" + 
dbType.toVarChar("\"PARAM_VALUE\"") +
+       " AS decimal(21,0))) from \"PARTITIONS\" t" +
+       " join \"PARTITION_PARAMS\" p on p.\"PART_ID\" = t.\"PART_ID\" where 
\"PARAM_KEY\"" +
+       " in ('" + StatsSetupConst.TOTAL_SIZE + "', '" + 
StatsSetupConst.NUM_FILES + "', '" +
+       StatsSetupConst.ROW_COUNT + "') and t.\"TBL_ID\" in (";
    runBatched(batchSize, new ArrayList<>(partedTabs), new Batchable<Long, 
Void>() {
       @Override
       public List<Void> run(List<Long> input) throws Exception {
@@ -927,7 +935,8 @@ public List<Long> run(List<Long> input) throws Exception {
         int size = input.size();
         String queryText =
             "\"TBL_ID\" from \"TABLE_PARAMS\" where \"PARAM_KEY\" = 
'current-snapshot-timestamp-ms' "
-                + (lastUpdatedDays != null ? (" and CAST(" + 
dbType.toVarChar("\"PARAM_VALUE\"") + " AS decimal(21,0)) > " + 
(System.currentTimeMillis() - lastUpdatedDays * 24 * 3600000L)) : "")
+                + (lastUpdatedDays != null ? (" and CAST(" + 
dbType.toVarChar("\"PARAM_VALUE\"") + " AS decimal(21,0)) > "
+                + (System.currentTimeMillis() - lastUpdatedDays * 24 * 
3600000L)) : "")
                 + " and \"TBL_ID\" in (" +  (size == 0 ? "" : repeat(",?", 
size).substring(1)) + ") "
                 + " order by CAST(" + dbType.toVarChar("\"PARAM_VALUE\"") + " 
AS decimal(21,0)) DESC";
         if (tablesLimit != null && tablesLimit >= 0) {
@@ -960,4 +969,65 @@ public List<Long> run(List<Long> input) throws Exception {
     });
     return new HashSet<>(tables);
   }
+
+  public static final class DedupColumnsResult {
+    private final int tablesScanned;
+    private int tablesWithDuplicates;
+    private int storageDescriptorsUpdated;
+    private int columnDescriptorsRemoved;
+    private final List<String> details = new ArrayList<>();
+    private Exception exception;
+    DedupColumnsResult(int tablesScanned) {
+      this.tablesScanned = tablesScanned;
+    }
+    public int getTablesScanned() {
+      return tablesScanned;
+    }
+
+    public int getTablesWithDuplicates() {
+      return tablesWithDuplicates;
+    }
+
+    void incrementTablesWithDuplicates() {
+      this.tablesWithDuplicates++;
+    }
+
+    public int getStorageDescriptorsUpdated() {
+      return storageDescriptorsUpdated;
+    }
+
+    void incrementStorageDescriptorsUpdated() {
+      this.storageDescriptorsUpdated++;
+    }
+
+    public int getColumnDescriptorsRemoved() {
+      return columnDescriptorsRemoved;
+    }
+
+    void addColumnDescriptorsRemoved(int count) {
+      this.columnDescriptorsRemoved += count;
+    }
+
+    public List<String> getDetails() {
+      return details;
+    }
+
+    void addDetail(String detail) {
+      details.add(detail);
+    }
+
+    void catchException(Exception exception) {
+      this.exception = exception;
+    }
+
+    public Exception getException() {
+      return exception;
+    }
+  }
+
+  public DedupColumnsResult dedupColumns(String catalogFilter, String 
dbFilter, String tableFilter,
+      AtomicReference<String> progress, boolean isDryRun, boolean isVerbose) {
+    return new ColumnDeduplicator(this.createRawStoreBundle(), progress, 
isDryRun, isVerbose)
+        .run(catalogFilter, dbFilter, tableFilter);
+  }
 }
diff --git 
a/standalone-metastore/metastore-server/src/main/java/org/apache/hadoop/hive/metastore/tools/metatool/HiveMetaTool.java
 
b/standalone-metastore/metastore-server/src/main/java/org/apache/hadoop/hive/metastore/tools/metatool/HiveMetaTool.java
index 28814335280..2ab17c67edc 100644
--- 
a/standalone-metastore/metastore-server/src/main/java/org/apache/hadoop/hive/metastore/tools/metatool/HiveMetaTool.java
+++ 
b/standalone-metastore/metastore-server/src/main/java/org/apache/hadoop/hive/metastore/tools/metatool/HiveMetaTool.java
@@ -32,6 +32,7 @@
  * - execute JDOQL against the metastore using DataNucleus
  * - perform HA name node upgrade
  * - summarize the data in HMS
+ * - de-duplicate column descriptors for partitioned tables
  */
 public final class HiveMetaTool {
   private static final Logger LOGGER = 
LoggerFactory.getLogger(HiveMetaTool.class.getName());
@@ -60,6 +61,8 @@ public static void execute(String[] args) throws Exception {
         task = new MetaToolTaskDiffExtTblLocs();
       } else if (cl.isMetadataSummary()) {
         task = new MetaToolTaskMetadataSummary();
+      } else if (cl.isDedupColumns()) {
+        task = new MetaToolTaskDedupColumns();
       } else {
         throw new IllegalArgumentException("No task was specified!");
       }
diff --git 
a/standalone-metastore/metastore-server/src/main/java/org/apache/hadoop/hive/metastore/tools/metatool/HiveMetaToolCommandLine.java
 
b/standalone-metastore/metastore-server/src/main/java/org/apache/hadoop/hive/metastore/tools/metatool/HiveMetaToolCommandLine.java
index 29af84c407f..a1c9db7a327 100644
--- 
a/standalone-metastore/metastore-server/src/main/java/org/apache/hadoop/hive/metastore/tools/metatool/HiveMetaToolCommandLine.java
+++ 
b/standalone-metastore/metastore-server/src/main/java/org/apache/hadoop/hive/metastore/tools/metatool/HiveMetaToolCommandLine.java
@@ -86,9 +86,30 @@ class HiveMetaToolCommandLine {
           )
           .create("diffExtTblLocs");
 
+  @SuppressWarnings("static-access")
+  private static final Option DEDUP_COLUMNS = OptionBuilder
+      .withArgName("catalog> " + "<db> " + "<table")
+      .hasArgs(3)
+      .hasOptionalArgs(3)
+      .withDescription("De-duplicate column descriptors for partitioned tables 
to reduce metadata bloat. "
+          + "Optional catalog, database, and table filters support * 
wildcards. Use -dryRun to preview changes, "
+          + "-verbose to print per-partition details, and -timeout to set a 
timeout in seconds.")
+      .create("dedupColumns");
+
+  private static final Option VERBOSE = OptionBuilder
+      .withDescription("Print per-partition de-duplication details. Valid only 
with -dedupColumns.")
+      .create("verbose");
+
+  @SuppressWarnings("static-access")
+  private static final Option TIMEOUT = OptionBuilder
+      .withArgName("timeout-seconds")
+      .hasArg()
+      .withDescription("Timeout in seconds for -dedupColumns. Defaults to 
hive.metatool.dedupColumns.timeout.")
+      .create("timeout");
+
   private static final Option DRY_RUN = OptionBuilder
-      .withDescription("Perform a dry run of updateLocation changes.When run 
with the dryRun option updateLocation " +
-          "changes are displayed but not persisted. dryRun is valid only with 
the updateLocation option.")
+      .withDescription("Perform a dry run without persisting changes. Valid 
with -updateLocation and "
+          + "-dedupColumns.")
       .create("dryRun");
 
   @SuppressWarnings("static-access")
@@ -128,6 +149,9 @@ class HiveMetaToolCommandLine {
     OPTIONS.addOption(TABLE_PROP_KEY);
     OPTIONS.addOption(HELP);
     OPTIONS.addOption(METADATA_SUMMARY);
+    OPTIONS.addOption(DEDUP_COLUMNS);
+    OPTIONS.addOption(VERBOSE);
+    OPTIONS.addOption(TIMEOUT);
   }
 
   private boolean listFSRoot;
@@ -136,10 +160,13 @@ class HiveMetaToolCommandLine {
   private String[] listExtTblLocsParams;
   private String[] diffExtTblLocsParams;
   private boolean dryRun;
+  private boolean verbose;
+  private Long dedupColumnsTimeoutSeconds;
   private String serdePropKey;
   private String tablePropKey;
   private boolean help;
   private String[] metadataSummaryParams;
+  private String[] dedupColumnsParams;
 
   public static HiveMetaToolCommandLine parseArguments(String[] args) {
     HiveMetaToolCommandLine cl = null;
@@ -174,16 +201,28 @@ private void parseCommandLine(String[] args) throws 
ParseException {
     listExtTblLocsParams = cl.getOptionValues(LIST_EXT_TBL_LOCS.getOpt());
     diffExtTblLocsParams = cl.getOptionValues(DIFF_EXT_TBL_LOCS.getOpt());
     dryRun = cl.hasOption(DRY_RUN.getOpt());
+    verbose = cl.hasOption(VERBOSE.getOpt());
+    if (cl.hasOption(TIMEOUT.getOpt())) {
+      dedupColumnsTimeoutSeconds = 
Long.parseLong(cl.getOptionValue(TIMEOUT.getOpt()));
+      if (dedupColumnsTimeoutSeconds <= 0) {
+        throw new IllegalArgumentException("HiveMetaTool:-timeout must be a 
positive number of seconds");
+      }
+    }
     serdePropKey = cl.getOptionValue(SERDE_PROP_KEY.getOpt());
     tablePropKey = cl.getOptionValue(TABLE_PROP_KEY.getOpt());
     help = cl.hasOption(HELP.getOpt());
     metadataSummaryParams = cl.getOptionValues(METADATA_SUMMARY.getOpt());
+    dedupColumnsParams = cl.getOptionValues(DEDUP_COLUMNS.getOpt());
+    if (cl.hasOption(DEDUP_COLUMNS.getOpt()) && dedupColumnsParams == null) {
+      dedupColumnsParams = new String[0];
+    }
 
     int commandCount = (isListFSRoot() ? 1 : 0) + (isExecuteJDOQL() ? 1 : 0) + 
(isUpdateLocation() ? 1 : 0) +
-          (isListExtTblLocs() ? 1 : 0) + (isDiffExtTblLocs() ? 1 : 0) + 
(isMetadataSummary() ? 1 : 0);
+          (isListExtTblLocs() ? 1 : 0) + (isDiffExtTblLocs() ? 1 : 0) + 
(isMetadataSummary() ? 1 : 0) +
+          (isDedupColumns() ? 1 : 0);
     if (commandCount != 1) {
       throw new IllegalArgumentException("exactly one of -listFSRoot, 
-executeJDOQL, -updateLocation, " +
-              "-listExtTblLocs, -diffExtTblLocs, -metadataSummary must be 
set");
+              "-listExtTblLocs, -diffExtTblLocs, -metadataSummary, 
-dedupColumns must be set");
     }
 
     if (updateLocationParams != null && updateLocationParams.length != 2) {
@@ -201,9 +240,22 @@ private void parseCommandLine(String[] args) throws 
ParseException {
               diffExtTblLocsParams.length + " arguments");
     }
 
-    if ((dryRun || serdePropKey != null || tablePropKey != null) && 
!isUpdateLocation()) {
-      throw new IllegalArgumentException("-dryRun, -serdePropKey, 
-tablePropKey may be used only for the " +
-          "-updateLocation command");
+    if (dryRun && !isUpdateLocation() && !isDedupColumns()) {
+      throw new IllegalArgumentException(
+          "-dryRun may be used only for the -updateLocation or -dedupColumns 
commands");
+    }
+
+    if ((serdePropKey != null || tablePropKey != null) && !isUpdateLocation()) 
{
+      throw new IllegalArgumentException(
+          "-serdePropKey, -tablePropKey may be used only for the 
-updateLocation command");
+    }
+
+    if (verbose && !isDedupColumns()) {
+      throw new IllegalArgumentException("-verbose may be used only for the 
-dedupColumns command");
+    }
+
+    if (dedupColumnsTimeoutSeconds != null && !isDedupColumns()) {
+      throw new IllegalArgumentException("-timeout may be used only for the 
-dedupColumns command");
     }
 
     if (metadataSummaryParams != null && metadataSummaryParams.length < 1) {
@@ -233,9 +285,12 @@ private void printArguments() {
         "\tlistExtTblLocs: " + Arrays.toString(listExtTblLocsParams) + "\n" +
         "\tdiffExtTblLocs: " + Arrays.toString(diffExtTblLocsParams) + "\n" +
         "\tdryRun        : " + dryRun + "\n" +
+        "\tverbose       : " + verbose + "\n" +
+        "\ttimeout       : " + dedupColumnsTimeoutSeconds + "\n" +
         "\tserdePropKey  : " + serdePropKey + "\n" +
         "\ttablePropKey  : " + tablePropKey + "\n" +
-        "\tmetadataSummary : " + Arrays.toString(metadataSummaryParams));
+        "\tmetadataSummary : " + Arrays.toString(metadataSummaryParams) + "\n" 
+
+        "\tdedupColumns : " + Arrays.toString(dedupColumnsParams));
   }
 
   boolean isListFSRoot() {
@@ -278,6 +333,14 @@ boolean isDryRun() {
     return dryRun;
   }
 
+  boolean isVerbose() {
+    return verbose;
+  }
+
+  Long getDedupColumnsTimeoutSeconds() {
+    return dedupColumnsTimeoutSeconds;
+  }
+
   String getSerdePropKey() {
     return serdePropKey;
   }
@@ -294,6 +357,14 @@ String[] getMetadataSummaryParams() {
     return metadataSummaryParams;
   }
 
+  boolean isDedupColumns() {
+    return dedupColumnsParams != null;
+  }
+
+  String[] getDedupColumnsParams() {
+    return dedupColumnsParams == null ? new String[0] : dedupColumnsParams;
+  }
+
   private boolean isHelp() {
     return help;
   }
diff --git 
a/standalone-metastore/metastore-server/src/main/java/org/apache/hadoop/hive/metastore/tools/metatool/MetaToolTaskDedupColumns.java
 
b/standalone-metastore/metastore-server/src/main/java/org/apache/hadoop/hive/metastore/tools/metatool/MetaToolTaskDedupColumns.java
new file mode 100644
index 00000000000..06994e5d493
--- /dev/null
+++ 
b/standalone-metastore/metastore-server/src/main/java/org/apache/hadoop/hive/metastore/tools/metatool/MetaToolTaskDedupColumns.java
@@ -0,0 +1,120 @@
+/*
+ * 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.hadoop.hive.metastore.tools.metatool;
+
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
+
+import org.apache.hadoop.hive.metastore.Deadline;
+import org.apache.hadoop.hive.metastore.DeadlineException;
+import org.apache.hadoop.hive.metastore.api.MetaException;
+import org.apache.hadoop.hive.metastore.conf.MetastoreConf;
+import org.apache.hadoop.hive.metastore.tools.MetaToolObjectStore;
+
+class MetaToolTaskDedupColumns extends MetaToolTask {
+  @Override
+  void execute() {
+    String[] params = getCl().getDedupColumnsParams();
+    String catalogFilter = params.length > 0 ? params[0] : null;
+    String dbFilter = params.length > 1 ? params[1] : null;
+    String tableFilter = params.length > 2 ? params[2] : null;
+    boolean isDryRun = getCl().isDryRun();
+    boolean isVerbose = getCl().isVerbose();
+    
+    Thread daemon = null;
+    MetaToolObjectStore.DedupColumnsResult result = null;
+    AtomicReference<String> progress = new AtomicReference<>();
+    AtomicBoolean stopped = new AtomicBoolean(false);
+    long timeoutMs = getCl().getDedupColumnsTimeoutSeconds() != null ?
+        getCl().getDedupColumnsTimeoutSeconds() * 1000L :
+        MetastoreConf.getTimeVar(getObjectStore().getConf(), 
MetastoreConf.ConfVars.DEDUP_COLUMNS_TIMEOUT,
+            TimeUnit.MILLISECONDS);
+    Deadline.registerIfNot(timeoutMs);
+    boolean timerStarted = false;
+    try {
+      timerStarted = Deadline.startTimer("dedupColumns");
+      if (isVerbose) {
+        daemon = new Thread(() -> {
+          while (!stopped.get()) {
+            try {
+              Thread.sleep(30 * 1000);
+            } catch (InterruptedException e) {
+              Thread.currentThread().interrupt();
+              break;
+            }
+            String message = progress.get();
+            if (message != null) {
+              System.out.println(message);
+            }
+          }
+        });
+        daemon.setDaemon(true);
+        daemon.start();
+      }
+      result = getObjectStore().dedupColumns(catalogFilter, dbFilter, 
tableFilter, progress, isDryRun, isVerbose);
+    } catch (MetaException ex) {
+      // Deadline.startTimer throws MetaException, this shouldn't happen as 
the Deadline has been registered.
+      System.err.println(ex.getMessage());
+    } finally {
+      if (daemon != null) {
+        stopped.set(true);
+        daemon.interrupt();
+      }
+      if (timerStarted) {
+        try {
+          Deadline.stopTimer();
+        } catch (MetaException ignored) {
+        }
+      }
+    }
+
+    if (result != null) {
+      printSummary(result, isDryRun, isVerbose);
+      if (result.getException() != null) {
+        if (result.getException().getCause() instanceof DeadlineException 
deadline) {
+          // Just print the timeout message
+          System.err.println(deadline.getMessage());
+          return;
+        }
+        throw new IllegalStateException("HiveMetaTool: failed to de-duplicate 
column descriptors for all tables",
+            result.getException());
+      }
+    }
+  }
+
+  private void printSummary(MetaToolObjectStore.DedupColumnsResult result, 
boolean isDryRun, boolean isVerbose) {
+    System.out.println(isDryRun ?
+        "Dry run of -dedupColumns.." :
+        "De-duplicated column descriptors..");
+    System.out.println("Tables scanned: " + result.getTablesScanned());
+    System.out.println("Tables with duplicate column descriptors: " + 
result.getTablesWithDuplicates());
+    System.out.println("Partition storage descriptors " + (isDryRun ? "to 
update" : "updated") + ": "
+        + result.getStorageDescriptorsUpdated());
+    if (!isDryRun) {
+      System.out.println("Column descriptors removed: " + 
result.getColumnDescriptorsRemoved());
+    }
+    if (isVerbose) {
+      for (String detail : result.getDetails()) {
+        System.out.println(detail);
+      }
+    }
+  }
+}
diff --git 
a/standalone-metastore/metastore-server/src/test/java/org/apache/hadoop/hive/metastore/TestHMSColumnDescriptorReuse.java
 
b/standalone-metastore/metastore-server/src/test/java/org/apache/hadoop/hive/metastore/TestHMSColumnDescriptorReuse.java
index 008ee3b5a67..483c15fccba 100644
--- 
a/standalone-metastore/metastore-server/src/test/java/org/apache/hadoop/hive/metastore/TestHMSColumnDescriptorReuse.java
+++ 
b/standalone-metastore/metastore-server/src/test/java/org/apache/hadoop/hive/metastore/TestHMSColumnDescriptorReuse.java
@@ -22,6 +22,7 @@
 import org.apache.hadoop.hive.metastore.annotation.MetastoreUnitTest;
 import org.apache.hadoop.hive.metastore.api.Database;
 import org.apache.hadoop.hive.metastore.api.FieldSchema;
+import org.apache.hadoop.hive.metastore.api.GetPartitionsByNamesRequest;
 import org.apache.hadoop.hive.metastore.api.InvalidObjectException;
 import org.apache.hadoop.hive.metastore.api.MetaException;
 import org.apache.hadoop.hive.metastore.api.Partition;
@@ -29,9 +30,11 @@
 import org.apache.hadoop.hive.metastore.api.StorageDescriptor;
 import org.apache.hadoop.hive.metastore.api.Table;
 import org.apache.hadoop.hive.metastore.client.builder.DatabaseBuilder;
+import org.apache.hadoop.hive.metastore.client.builder.GetPartitionsArgs;
 import org.apache.hadoop.hive.metastore.conf.MetastoreConf;
 import org.apache.hadoop.hive.metastore.dbinstall.rules.DatabaseRule;
 import org.apache.hadoop.hive.metastore.dbinstall.rules.Derby;
+import org.apache.hadoop.hive.metastore.tools.MetaToolObjectStore;
 import org.apache.hadoop.hive.metastore.utils.TestTxnDbUtil;
 import org.junit.After;
 import org.junit.Before;
@@ -45,9 +48,12 @@
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.concurrent.atomic.AtomicReference;
 
 import static org.apache.hadoop.hive.metastore.Warehouse.DEFAULT_CATALOG_NAME;
 import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
 
 @Category(MetastoreUnitTest.class)
 public class TestHMSColumnDescriptorReuse {
@@ -166,6 +172,64 @@ public void testNoReusableColumnDescriptors() throws 
MetaException, InvalidObjec
     assertEquals(3, countColumnDescriptors());
   }
 
+  @Test
+  public void testDeduplicateColumnDescriptorsTool() throws Exception {
+    MetastoreConf.setBoolVar(conf, 
MetastoreConf.ConfVars.PARTITION_REUSE_COLUMN_DESCRIPTORS, false);
+
+    FieldSchema id = new FieldSchema("id", ColumnType.STRING_TYPE_NAME, "");
+    FieldSchema fname = new FieldSchema("fname", ColumnType.STRING_TYPE_NAME, 
"");
+    FieldSchema country = new FieldSchema("country", 
ColumnType.STRING_TYPE_NAME, "");
+
+    Table tbl1 = newTable(Arrays.asList(id, fname), 
Collections.singletonList(country));
+    objectStore.createTable(tbl1);
+    objectStore.addPartition(newPart(tbl1, "US"));
+    objectStore.addPartition(newPart(tbl1, "Greece"));
+    int cdsBeforeDedup = countColumnDescriptors();
+    assertTrue(cdsBeforeDedup == 1);
+
+    AtomicReference<String> progress = new AtomicReference<>();
+    MetaToolObjectStore metaToolStore = new MetaToolObjectStore();
+    metaToolStore.setConf(conf);
+    Deadline.registerIfNot(30 * 1000);
+    Deadline.startTimer("testDeduplicateColumnDescriptorsTool");
+    try {
+      MetaToolObjectStore.DedupColumnsResult result = 
metaToolStore.dedupColumns(null, "default", "person", progress,
+          false, false);
+      assertEquals(0, result.getTablesWithDuplicates());
+
+      FieldSchema lname = new FieldSchema("lname", 
ColumnType.STRING_TYPE_NAME, "");
+      Table tbl2 = newTable(Arrays.asList(id, fname, lname), 
Collections.singletonList(country));
+      objectStore.alterTable(DEFAULT_CATALOG_NAME, tbl1.getDbName(), 
tbl1.getTableName(), tbl2, null);
+      objectStore.addPartition(newPart(tbl2, "Italy"));
+      objectStore.addPartition(newPart(tbl1, "Germany"));
+      objectStore.addPartition(newPart(tbl1, "Belgium"));
+      objectStore.addPartition(newPart(tbl2, "England"));
+      cdsBeforeDedup = countColumnDescriptors();
+      assertTrue(cdsBeforeDedup > 2);
+
+      result = metaToolStore.dedupColumns(null, "default", "person", progress, 
false, false);
+      assertTrue(result.getStorageDescriptorsUpdated() > 0);
+      assertEquals(2, countColumnDescriptors());
+      assertNotNull(progress.get());
+
+      Deadline.registerIfNot(30 * 1000);
+      Deadline.startTimer("testDeduplicateColumnDescriptorsTool");
+      GetPartitionsByNamesRequest request = new 
GetPartitionsByNamesRequest("default", "person");
+      request.setNames(List.of("country=Germany", "country=Belgium", 
"country=Greece", "country=US"));
+      List<Partition> partitions = objectStore.getPartitionsByNames("hive", 
"default", "person",
+          GetPartitionsArgs.from(request));
+      assertEquals(4, partitions.size());
+      assertTrue(partitions.stream().allMatch(p -> 
tbl1.getSd().getCols().equals(p.getSd().getCols())));
+
+      request.setNames(List.of("country=Italy", "country=England"));
+      partitions = objectStore.getPartitionsByNames("hive", "default", 
"person", GetPartitionsArgs.from(request));
+      assertEquals(2, partitions.size());
+      assertTrue(partitions.stream().allMatch(p -> 
tbl2.getSd().getCols().equals(p.getSd().getCols())));
+    } finally {
+      Deadline.stopTimer();
+    }
+  }
+
   private int countColumnDescriptors() {
     try(Connection c = TestTxnDbUtil.getConnection(conf)){
       try(ResultSet rs = c.prepareStatement("SELECT COUNT(*) FROM 
\"CDS\"").executeQuery()) {
diff --git 
a/standalone-metastore/metastore-server/src/test/java/org/apache/hadoop/hive/metastore/tools/metatool/TestHiveMetaToolCommandLine.java
 
b/standalone-metastore/metastore-server/src/test/java/org/apache/hadoop/hive/metastore/tools/metatool/TestHiveMetaToolCommandLine.java
index 9297194196e..f28b177eb02 100644
--- 
a/standalone-metastore/metastore-server/src/test/java/org/apache/hadoop/hive/metastore/tools/metatool/TestHiveMetaToolCommandLine.java
+++ 
b/standalone-metastore/metastore-server/src/test/java/org/apache/hadoop/hive/metastore/tools/metatool/TestHiveMetaToolCommandLine.java
@@ -88,7 +88,7 @@ public void testParseUpdateLocation() throws ParseException {
   @Test
   public void testNoTask() throws ParseException {
     exception.expect(IllegalArgumentException.class);
-    exception.expectMessage("exactly one of -listFSRoot, -executeJDOQL, 
-updateLocation, -listExtTblLocs, -diffExtTblLocs, -metadataSummary must be 
set");
+    exception.expectMessage("exactly one of -listFSRoot, -executeJDOQL, 
-updateLocation, -listExtTblLocs, -diffExtTblLocs, -metadataSummary, 
-dedupColumns must be set");
 
     new HiveMetaToolCommandLine(new String[] {});
   }
@@ -96,7 +96,7 @@ public void testNoTask() throws ParseException {
   @Test
   public void testMultipleTask() throws ParseException {
     exception.expect(IllegalArgumentException.class);
-    exception.expectMessage("exactly one of -listFSRoot, -executeJDOQL, 
-updateLocation, -listExtTblLocs, -diffExtTblLocs, -metadataSummary must be 
set");
+    exception.expectMessage("exactly one of -listFSRoot, -executeJDOQL, 
-updateLocation, -listExtTblLocs, -diffExtTblLocs, -metadataSummary, 
-dedupColumns must be set");
 
     new HiveMetaToolCommandLine(new String[] {"-listFSRoot", "-executeJDOQL", 
"select a from b"});
   }
@@ -132,15 +132,58 @@ public void testDiffExtTblLocsArgCount() throws 
ParseException {
   @Test
   public void testDryRunNotAllowed() throws ParseException {
     exception.expect(IllegalArgumentException.class);
-    exception.expectMessage("-dryRun, -serdePropKey, -tablePropKey may be used 
only for the -updateLocation command");
+    exception.expectMessage("-dryRun may be used only for the -updateLocation 
or -dedupColumns commands");
 
     new HiveMetaToolCommandLine(new String[] {"-listFSRoot", "-dryRun"});
   }
 
+  @Test
+  public void testParseDedupColumns() throws ParseException {
+    HiveMetaToolCommandLine cl = new HiveMetaToolCommandLine(
+        new String[] {"-dedupColumns", "hive", "default", "person", "-dryRun", 
"-verbose"});
+    assertTrue(cl.isDedupColumns());
+    assertTrue(cl.isDryRun());
+    assertTrue(cl.isVerbose());
+    assertEquals("hive", cl.getDedupColumnsParams()[0]);
+    assertEquals("default", cl.getDedupColumnsParams()[1]);
+    assertEquals("person", cl.getDedupColumnsParams()[2]);
+
+    cl = new HiveMetaToolCommandLine(new String[] {"-dedupColumns"});
+    assertTrue(cl.isDedupColumns());
+    assertEquals(0, cl.getDedupColumnsParams().length);
+
+    cl = new HiveMetaToolCommandLine(new String[] {"-dedupColumns", 
"-timeout", "3600"});
+    assertEquals(Long.valueOf(3600L), cl.getDedupColumnsTimeoutSeconds());
+  }
+
+  @Test
+  public void testTimeoutNotAllowed() throws ParseException {
+    exception.expect(IllegalArgumentException.class);
+    exception.expectMessage("-timeout may be used only for the -dedupColumns 
command");
+
+    new HiveMetaToolCommandLine(new String[] {"-listFSRoot", "-timeout", 
"60"});
+  }
+
+  @Test
+  public void testInvalidTimeout() throws ParseException {
+    exception.expect(IllegalArgumentException.class);
+    exception.expectMessage("HiveMetaTool:-timeout must be a positive number 
of seconds");
+
+    new HiveMetaToolCommandLine(new String[] {"-dedupColumns", "-timeout", 
"0"});
+  }
+
+  @Test
+  public void testVerboseNotAllowed() throws ParseException {
+    exception.expect(IllegalArgumentException.class);
+    exception.expectMessage("-verbose may be used only for the -dedupColumns 
command");
+
+    new HiveMetaToolCommandLine(new String[] {"-listFSRoot", "-verbose"});
+  }
+
   @Test
   public void testSerdePropKeyNotAllowed() throws ParseException {
     exception.expect(IllegalArgumentException.class);
-    exception.expectMessage("-dryRun, -serdePropKey, -tablePropKey may be used 
only for the -updateLocation command");
+    exception.expectMessage("-serdePropKey, -tablePropKey may be used only for 
the -updateLocation command");
 
     new HiveMetaToolCommandLine(new String[] {"-listFSRoot", "-serdePropKey", 
"abc"});
   }
@@ -148,7 +191,7 @@ public void testSerdePropKeyNotAllowed() throws 
ParseException {
   @Test
   public void testTablePropKeyNotAllowed() throws ParseException {
     exception.expect(IllegalArgumentException.class);
-    exception.expectMessage("-dryRun, -serdePropKey, -tablePropKey may be used 
only for the -updateLocation command");
+    exception.expectMessage("-serdePropKey, -tablePropKey may be used only for 
the -updateLocation command");
 
     new HiveMetaToolCommandLine(new String[] {"-executeJDOQL", "select a from 
b", "-tablePropKey", "abc"});
   }

Reply via email to