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

danny0405 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 a4373f75bad4 test(hudi-client): improve metadata table writer coverage 
(#19363)
a4373f75bad4 is described below

commit a4373f75bad4e8175ab00b121706417b1cfaed7c
Author: Shuo Cheng <[email protected]>
AuthorDate: Fri Jul 24 13:55:54 2026 +0800

    test(hudi-client): improve metadata table writer coverage (#19363)
    
    * test(hudi-client): improve metadata table writer coverage
---
 .../TestHoodieBackedTableMetadataWriter.java       | 254 +++++++++++++++++
 ...ieBackedTableMetadataWriterTableVersionSix.java | 153 ++++++++++
 .../metadata/TestHoodieMetadataWriteUtils.java     |  45 +++
 .../TestSecondaryIndexRecordGenerationUtils.java   |  73 +++++
 .../TestSparkHoodieBackedTableMetadataWriter.java  | 183 ++++++++++++
 .../functional/TestMetadataTableWithSparkSQL.scala | 311 +++++++++++++++++++++
 6 files changed, 1019 insertions(+)

diff --git 
a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/metadata/TestHoodieBackedTableMetadataWriter.java
 
b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/metadata/TestHoodieBackedTableMetadataWriter.java
index 1a6d32a592e2..e3f4ae662182 100644
--- 
a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/metadata/TestHoodieBackedTableMetadataWriter.java
+++ 
b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/metadata/TestHoodieBackedTableMetadataWriter.java
@@ -18,40 +18,59 @@
 
 package org.apache.hudi.metadata;
 
+import org.apache.hudi.avro.model.HoodieIndexPartitionInfo;
+import org.apache.hudi.avro.model.HoodieRestoreMetadata;
 import org.apache.hudi.client.BaseHoodieWriteClient;
 import org.apache.hudi.common.config.HoodieMetadataConfig;
 import org.apache.hudi.common.config.HoodieTableServiceManagerConfig;
+import org.apache.hudi.common.data.HoodieData;
 import org.apache.hudi.common.engine.HoodieEngineContext;
 import org.apache.hudi.common.model.HoodieCommitMetadata;
 import org.apache.hudi.common.model.HoodieFailedWritesCleaningPolicy;
 import org.apache.hudi.common.model.HoodieRecord;
+import org.apache.hudi.common.table.HoodieTableConfig;
 import org.apache.hudi.common.table.HoodieTableMetaClient;
 import org.apache.hudi.common.table.timeline.HoodieActiveTimeline;
 import org.apache.hudi.common.table.timeline.HoodieInstant;
 import org.apache.hudi.common.table.timeline.HoodieTimeline;
 import org.apache.hudi.common.table.timeline.versioning.v2.ActiveTimelineV2;
+import org.apache.hudi.common.table.view.HoodieTableFileSystemView;
+import org.apache.hudi.common.util.Lazy;
 import org.apache.hudi.common.util.Option;
 import org.apache.hudi.config.HoodieCleanConfig;
 import org.apache.hudi.config.HoodieWriteConfig;
 import org.apache.hudi.exception.HoodieException;
+import org.apache.hudi.exception.HoodieIOException;
+import org.apache.hudi.exception.HoodieIndexException;
+import org.apache.hudi.metadata.index.Indexer;
 
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
 import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.Arguments;
 import org.junit.jupiter.params.provider.CsvSource;
 import org.junit.jupiter.params.provider.MethodSource;
 import org.mockito.MockedStatic;
 
+import java.io.IOException;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.nio.file.Path;
 import java.util.ArrayList;
 import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
 import java.util.List;
+import java.util.Map;
 import java.util.Properties;
 import java.util.stream.Stream;
 
 import static 
org.apache.hudi.common.testutils.HoodieTestUtils.INSTANT_GENERATOR;
 import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertSame;
 import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.CALLS_REAL_METHODS;
@@ -252,6 +271,241 @@ class TestHoodieBackedTableMetadataWriter {
     assertDoesNotThrow(() -> validateRollbackMethod.invoke(writer, 
instantToRollback));
   }
 
+  @Test
+  void exercisesNoOpAndUnsupportedBaseWriterPaths() throws Exception {
+    // Cover base no-op hooks and fail fast when an indexer is disabled.
+    HoodieBackedTableMetadataWriter<List<HoodieRecord>, List<?>> writer =
+        mock(HoodieBackedTableMetadataWriter.class, CALLS_REAL_METHODS);
+
+    assertDoesNotThrow(() -> writer.buildMetadataPartitions(
+        mock(HoodieEngineContext.class), Collections.emptyList(), "001"));
+    assertThrows(UnsupportedOperationException.class,
+        () -> writer.streamWriteToMetadataTable(null, "001"));
+    assertThrows(UnsupportedOperationException.class,
+        () -> 
writer.secondaryWriteToMetadataTablePartitions(Collections.emptyList(), "001"));
+
+    HoodieWriteConfig metadataWriteConfig = mock(HoodieWriteConfig.class);
+    when(metadataWriteConfig.getBasePath()).thenReturn("/tmp/metadata");
+    writer.metadataWriteConfig = metadataWriteConfig;
+    setField(writer, "enabledIndexerMap", Collections.emptyMap());
+    HoodieIndexPartitionInfo disabledPartition = 
HoodieIndexPartitionInfo.newBuilder()
+        .setVersion(1)
+        
.setMetadataPartitionPath(MetadataPartitionType.COLUMN_STATS.getPartitionPath())
+        .setIndexUptoInstant("001")
+        .build();
+    assertThrows(HoodieIndexException.class,
+        () -> writer.buildMetadataPartitions(
+            mock(HoodieEngineContext.class), 
Collections.singletonList(disabledPartition), "001"));
+  }
+
+  @Test
+  void fallsBackToConfiguredIndexersWhenTableConfigHasNoMetadataPartitions() 
throws Exception {
+    // A fresh table must derive active indexers from writer configuration.
+    HoodieBackedTableMetadataWriter<List<HoodieRecord>, List<?>> writer =
+        mock(HoodieBackedTableMetadataWriter.class, CALLS_REAL_METHODS);
+    HoodieTableMetaClient dataMetaClient = mock(HoodieTableMetaClient.class);
+    HoodieTableConfig tableConfig = mock(HoodieTableConfig.class);
+    when(dataMetaClient.getTableConfig()).thenReturn(tableConfig);
+    when(tableConfig.getMetadataPartitions()).thenReturn(new HashSet<>());
+    
when(tableConfig.getMetadataPartitionsInflight()).thenReturn(Collections.emptySet());
+    writer.dataMetaClient = dataMetaClient;
+    Map<MetadataPartitionType, Indexer> enabledIndexers = new HashMap<>();
+    enabledIndexers.put(MetadataPartitionType.FILES, mock(Indexer.class));
+    setField(writer, "enabledIndexerMap", enabledIndexers);
+
+    assertDoesNotThrow(() -> writer.processAndCommit("001", 
Collections::emptyList));
+
+    HoodieBackedTableMetadataWriter<List<HoodieRecord>, List<?>> 
streamingWriter =
+        mock(HoodieBackedTableMetadataWriter.class, CALLS_REAL_METHODS);
+    HoodieEngineContext engineContext = mock(HoodieEngineContext.class);
+    HoodieData<HoodieRecord> emptyData = mock(HoodieData.class);
+    when(engineContext.<HoodieRecord>emptyHoodieData()).thenReturn(emptyData);
+    streamingWriter.dataMetaClient = dataMetaClient;
+    setField(streamingWriter, "engineContext", engineContext);
+    setField(streamingWriter, "enabledIndexerMap", Collections.emptyMap());
+    assertSame(emptyData, streamingWriter.streamWriteToMetadataPartitions(
+        mock(HoodieData.class), Collections.emptySet(), "001"));
+  }
+
+  @Test
+  void wrapsMetadataReaderAndFileSliceReadFailures() throws Exception {
+    // Reader setup and lazy file listing must preserve the public exception 
contract.
+    HoodieBackedTableMetadataWriter<List<HoodieRecord>, List<?>> writer =
+        mock(HoodieBackedTableMetadataWriter.class, CALLS_REAL_METHODS);
+    writer.dataWriteConfig = 
HoodieWriteConfig.newBuilder().withPath("/tmp/missing-table").build();
+    writer.dataMetaClient = mock(HoodieTableMetaClient.class);
+    Method maybeReinitializeReader =
+        
HoodieBackedTableMetadataWriter.class.getDeclaredMethod("mayBeReinitMetadataReader");
+    maybeReinitializeReader.setAccessible(true);
+    InvocationTargetException readerFailure = assertThrows(
+        InvocationTargetException.class, () -> 
maybeReinitializeReader.invoke(writer));
+    assertTrue(readerFailure.getCause() instanceof HoodieException);
+
+    HoodieBackedTableMetadata metadata = mock(HoodieBackedTableMetadata.class);
+    HoodieTableFileSystemView metadataView = 
mock(HoodieTableFileSystemView.class);
+    HoodieTableMetaClient dataMetaClient = mock(HoodieTableMetaClient.class, 
RETURNS_DEEP_STUBS);
+    
when(dataMetaClient.getActiveTimeline().filterCompletedAndCompactionInstants().lastInstant())
+        .thenReturn(Option.empty());
+    when(metadata.getMetadataFileSystemView()).thenReturn(metadataView);
+    when(metadata.getAllPartitionPaths()).thenThrow(new IOException("listing 
failed"));
+    writer.metadata = metadata;
+    writer.dataMetaClient = dataMetaClient;
+    setField(writer, "metadataView", metadataView);
+    Method getLazyMergedFileSlices =
+        
HoodieBackedTableMetadataWriter.class.getDeclaredMethod("getLazyMergedFileSlices");
+    getLazyMergedFileSlices.setAccessible(true);
+    Lazy<?> lazyFileSlices = (Lazy<?>) getLazyMergedFileSlices.invoke(writer);
+    assertThrows(HoodieIOException.class, lazyFileSlices::get);
+  }
+
+  @Test
+  void detectsEmptyMetadataTimelineAndHandlesMissingMetadataTable(@TempDir 
Path tempDir) throws Exception {
+    // Missing MDT state requires bootstrap without trusting stale table 
config.
+    HoodieBackedTableMetadataWriter<List<HoodieRecord>, List<?>> writer =
+        mock(HoodieBackedTableMetadataWriter.class, CALLS_REAL_METHODS);
+    Method isBootstrapNeeded = HoodieBackedTableMetadataWriter.class
+        .getDeclaredMethod("isBootstrapNeeded", Option.class);
+    isBootstrapNeeded.setAccessible(true);
+    assertTrue((boolean) isBootstrapNeeded.invoke(writer, Option.empty()));
+
+    HoodieTableMetaClient dataMetaClient = mock(HoodieTableMetaClient.class);
+    HoodieTableConfig tableConfig = mock(HoodieTableConfig.class);
+    when(dataMetaClient.getTableConfig()).thenReturn(tableConfig);
+    when(tableConfig.isMetadataTableAvailable()).thenReturn(true);
+    writer.storageConf = 
org.apache.hudi.common.testutils.HoodieTestUtils.getDefaultStorageConf();
+    writer.dataWriteConfig = HoodieWriteConfig.newBuilder()
+        .withPath(tempDir.resolve("data-table").toString())
+        .build();
+    writer.metadataWriteConfig = HoodieWriteConfig.newBuilder()
+        .withPath(tempDir.resolve("missing-metadata-table").toString())
+        .build();
+    Method metadataTableExists = HoodieBackedTableMetadataWriter.class
+        .getDeclaredMethod("metadataTableExists", HoodieTableMetaClient.class);
+    metadataTableExists.setAccessible(true);
+    assertFalse((boolean) metadataTableExists.invoke(writer, dataMetaClient));
+  }
+
+  @Test
+  void ignoresIOExceptionWhileRemovingPendingIndexInstant() throws Exception {
+    // A corrupt pending index plan must not block partition cleanup.
+    HoodieTableMetaClient metaClient = mock(HoodieTableMetaClient.class);
+    HoodieActiveTimeline timeline = mock(HoodieActiveTimeline.class);
+    HoodieInstant pendingIndex = INSTANT_GENERATOR.createNewInstant(
+        HoodieInstant.State.REQUESTED, HoodieTimeline.INDEXING_ACTION, "001");
+    when(metaClient.getInstantGenerator()).thenReturn(INSTANT_GENERATOR);
+    when(metaClient.reloadActiveTimeline()).thenReturn(timeline);
+    when(metaClient.getActiveTimeline()).thenReturn(timeline);
+    when(timeline.filterPendingIndexTimeline()).thenReturn(timeline);
+    when(timeline.getInstantsAsStream()).thenReturn(Stream.of(pendingIndex));
+    when(timeline.readIndexPlan(pendingIndex)).thenThrow(new 
IOException("cannot read plan"));
+    Method deletePendingIndexingInstant = HoodieBackedTableMetadataWriter.class
+        .getDeclaredMethod("deletePendingIndexingInstant", 
HoodieTableMetaClient.class, String.class);
+    deletePendingIndexingInstant.setAccessible(true);
+
+    assertDoesNotThrow(() -> deletePendingIndexingInstant.invoke(null, 
metaClient, "column_stats"));
+  }
+
+  @Test
+  void wrapsRestorePlanReadFailure() throws Exception {
+    // Restore-plan I/O failures must surface as HoodieIOException.
+    HoodieBackedTableMetadataWriter<List<HoodieRecord>, List<?>> writer =
+        mock(HoodieBackedTableMetadataWriter.class, CALLS_REAL_METHODS);
+    HoodieBackedTableMetadata metadata = mock(HoodieBackedTableMetadata.class);
+    HoodieTableFileSystemView metadataView = 
mock(HoodieTableFileSystemView.class);
+    HoodieTableMetaClient metadataMetaClient = 
mock(HoodieTableMetaClient.class);
+    HoodieTableMetaClient dataMetaClient = mock(HoodieTableMetaClient.class);
+    HoodieActiveTimeline timeline = mock(HoodieActiveTimeline.class);
+    when(metadata.getMetadataFileSystemView()).thenReturn(metadataView);
+    when(dataMetaClient.getInstantGenerator()).thenReturn(INSTANT_GENERATOR);
+    when(dataMetaClient.getActiveTimeline()).thenReturn(timeline);
+    when(timeline.readRestorePlan(any())).thenThrow(new IOException("cannot 
read restore plan"));
+    writer.metadata = metadata;
+    writer.metadataMetaClient = metadataMetaClient;
+    writer.dataMetaClient = dataMetaClient;
+
+    assertThrows(HoodieIOException.class,
+        () -> writer.update(mock(HoodieRestoreMetadata.class), "001"));
+  }
+
+  @Test
+  void rejectsPendingMetadataCompactionAndWrapsCloseFailures() {
+    // Pending compaction blocks scheduling, while close errors remain visible.
+    HoodieBackedTableMetadataWriter<List<HoodieRecord>, List<?>> writer =
+        mock(HoodieBackedTableMetadataWriter.class, CALLS_REAL_METHODS);
+    HoodieWriteConfig metadataWriteConfig = mock(HoodieWriteConfig.class);
+    when(metadataWriteConfig.isLogCompactionEnabled()).thenReturn(true);
+    writer.metadataWriteConfig = metadataWriteConfig;
+    HoodieTableMetaClient metadataMetaClient = 
mock(HoodieTableMetaClient.class);
+    HoodieActiveTimeline metadataTimeline = mock(HoodieActiveTimeline.class, 
RETURNS_DEEP_STUBS);
+    HoodieInstant pendingCompaction = INSTANT_GENERATOR.createNewInstant(
+        HoodieInstant.State.REQUESTED, HoodieTimeline.COMPACTION_ACTION, 
"001");
+    when(metadataMetaClient.getActiveTimeline()).thenReturn(metadataTimeline);
+    
when(metadataTimeline.filterPendingLogCompactionTimeline().firstInstant()).thenReturn(Option.empty());
+    
when(metadataTimeline.filterPendingCompactionTimeline().firstInstant()).thenReturn(Option.of(pendingCompaction));
+    writer.metadataMetaClient = metadataMetaClient;
+
+    assertThrows(HoodieException.class, () -> {
+      doThrow(new HoodieException("close failed")).when(writer).close();
+      writer.closeInternal();
+    });
+    assertFalse(writer.validateCompactionScheduling(Option.empty(), "002"));
+  }
+
+  @Test
+  void compactIfNecessaryHandlesSkipDelegationAndFailures() {
+    // Exercise skip, delegation, and failure propagation for both compaction 
types.
+    Properties tableServiceManagerProperties = new Properties();
+    tableServiceManagerProperties.put(
+        HoodieTableServiceManagerConfig.TABLE_SERVICE_MANAGER_ENABLED.key(), 
"true");
+    tableServiceManagerProperties.put(
+        HoodieTableServiceManagerConfig.TABLE_SERVICE_MANAGER_ACTIONS.key(), 
"compaction,logcompaction");
+    HoodieTableServiceManagerConfig tableServiceManagerConfig =
+        
HoodieTableServiceManagerConfig.newBuilder().fromProperties(tableServiceManagerProperties).build();
+    HoodieWriteConfig metadataWriteConfig = mock(HoodieWriteConfig.class);
+    
when(metadataWriteConfig.getTableServiceManagerConfig()).thenReturn(tableServiceManagerConfig);
+    when(metadataWriteConfig.isLogCompactionEnabled()).thenReturn(true);
+
+    HoodieTableMetaClient dataMetaClient = mock(HoodieTableMetaClient.class, 
RETURNS_DEEP_STUBS);
+    when(dataMetaClient.reloadActiveTimeline().filterInflightsAndRequested()
+        .filter(any()).firstInstant()).thenReturn(Option.empty());
+    HoodieTableMetaClient metadataMetaClient = 
mock(HoodieTableMetaClient.class);
+    HoodieActiveTimeline metadataTimeline = mock(HoodieActiveTimeline.class);
+    HoodieTimeline completedTimeline = mock(HoodieTimeline.class);
+    when(metadataMetaClient.getActiveTimeline()).thenReturn(metadataTimeline);
+    
when(metadataTimeline.filterCompletedInstants()).thenReturn(completedTimeline);
+    when(completedTimeline.containsInstant(any(String.class)))
+        .thenAnswer(invocation -> "100".equals(invocation.getArgument(0)));
+
+    HoodieBackedTableMetadataWriter<List<HoodieRecord>, List<?>> writer =
+        mock(HoodieBackedTableMetadataWriter.class, CALLS_REAL_METHODS);
+    writer.dataMetaClient = dataMetaClient;
+    writer.metadataMetaClient = metadataMetaClient;
+    writer.metadataWriteConfig = metadataWriteConfig;
+    writer.metrics = Option.empty();
+
+    BaseHoodieWriteClient writeClient = mock(BaseHoodieWriteClient.class);
+    when(writeClient.createNewInstantTime(false)).thenReturn("100", "200", 
"300", "400");
+    when(writeClient.scheduleCompactionAtInstant("200", 
Option.empty())).thenReturn(true);
+    when(writeClient.scheduleCompactionAtInstant("300", Option.empty()))
+        .thenThrow(new HoodieException("compaction failed"));
+    when(writeClient.scheduleCompactionAtInstant("400", 
Option.empty())).thenReturn(false);
+    when(writeClient.scheduleLogCompaction(Option.empty()))
+        .thenReturn(Option.of("201"))
+        .thenThrow(new HoodieException("log compaction failed"));
+
+    writer.compactIfNecessary(writeClient, Option.empty());
+    writer.compactIfNecessary(writeClient, Option.empty());
+    assertThrows(HoodieException.class, () -> 
writer.compactIfNecessary(writeClient, Option.empty()));
+    assertThrows(HoodieException.class, () -> 
writer.compactIfNecessary(writeClient, Option.empty()));
+  }
+
+  private static void setField(Object target, String name, Object value) 
throws Exception {
+    // Exercise private failure paths without changing production visibility.
+    java.lang.reflect.Field field = 
HoodieBackedTableMetadataWriter.class.getDeclaredField(name);
+    field.setAccessible(true);
+    field.set(target, value);
+  }
+
   @SuppressWarnings("deprecation")
   private HoodieActiveTimeline createMockTimeline(List<HoodieInstant> 
instants) {
     ActiveTimelineV2 timeline = new ActiveTimelineV2();
diff --git 
a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/metadata/TestHoodieBackedTableMetadataWriterTableVersionSix.java
 
b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/metadata/TestHoodieBackedTableMetadataWriterTableVersionSix.java
index 804acc5f2ef3..184003552ca3 100644
--- 
a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/metadata/TestHoodieBackedTableMetadataWriterTableVersionSix.java
+++ 
b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/metadata/TestHoodieBackedTableMetadataWriterTableVersionSix.java
@@ -18,6 +18,10 @@
 
 package org.apache.hudi.metadata;
 
+import org.apache.hudi.client.BaseHoodieWriteClient;
+import org.apache.hudi.common.config.HoodieMetadataConfig;
+import org.apache.hudi.common.config.HoodieTableServiceManagerConfig;
+import org.apache.hudi.common.model.ActionType;
 import org.apache.hudi.common.table.HoodieTableMetaClient;
 import org.apache.hudi.common.table.timeline.HoodieActiveTimeline;
 import org.apache.hudi.common.table.timeline.HoodieInstant;
@@ -26,19 +30,28 @@ import 
org.apache.hudi.common.table.timeline.InstantGenerator;
 import org.apache.hudi.common.table.timeline.versioning.v1.ActiveTimelineV1;
 import org.apache.hudi.common.table.timeline.versioning.v1.InstantGeneratorV1;
 import org.apache.hudi.common.util.Option;
+import org.apache.hudi.config.HoodieWriteConfig;
+import org.apache.hudi.exception.HoodieMetadataException;
 
 import org.junit.jupiter.api.Test;
 
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.HashSet;
 import java.util.List;
+import java.util.Properties;
 import java.util.Set;
 
 import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.Mockito.CALLS_REAL_METHODS;
+import static org.mockito.Mockito.RETURNS_DEEP_STUBS;
 import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
 /**
@@ -225,6 +238,146 @@ class TestHoodieBackedTableMetadataWriterTableVersionSix {
     assertFalse(result, "Should block initialization when rollbacks are 
completed, not pending");
   }
 
+  @Test
+  void testGenerateUniqueInstantTimePreservesIndexingInstant() throws 
Exception {
+    // An indexing instant is already globally unique and must be reused.
+    String indexingInstant = "20250101120000000";
+    HoodieTableMetaClient dataMetaClient = mock(HoodieTableMetaClient.class);
+    HoodieActiveTimeline timeline = 
createMockTimeline(Collections.singletonList(
+        INSTANT_GENERATOR.createNewInstant(
+            HoodieInstant.State.REQUESTED, HoodieTimeline.INDEXING_ACTION, 
indexingInstant)));
+    when(dataMetaClient.getActiveTimeline()).thenReturn(timeline);
+    HoodieBackedTableMetadataWriterTableVersionSix<?, ?> writer = 
createMockWriter(dataMetaClient);
+
+    
assertTrue(indexingInstant.equals(writer.generateUniqueInstantTime(indexingInstant)));
+  }
+
+  @Test
+  void testValidateCompactionSchedulingRejectsPendingMetadataTableService() 
throws Exception {
+    // Do not schedule compaction while another metadata table service is 
pending.
+    HoodieTableMetaClient dataMetaClient = mock(HoodieTableMetaClient.class);
+    HoodieActiveTimeline dataTimeline = 
createMockTimeline(Collections.emptyList());
+    when(dataMetaClient.reloadActiveTimeline()).thenReturn(dataTimeline);
+    HoodieBackedTableMetadataWriterTableVersionSix<?, ?> writer = 
createMockWriter(dataMetaClient);
+
+    HoodieTableMetaClient metadataMetaClient = 
mock(HoodieTableMetaClient.class);
+    HoodieActiveTimeline metadataTimeline = mock(HoodieActiveTimeline.class, 
RETURNS_DEEP_STUBS);
+    HoodieInstant pendingCompaction = INSTANT_GENERATOR.createNewInstant(
+        HoodieInstant.State.REQUESTED, HoodieTimeline.COMPACTION_ACTION, 
"20250101120000001");
+    when(metadataMetaClient.getActiveTimeline()).thenReturn(metadataTimeline);
+    
when(metadataTimeline.filterPendingLogCompactionTimeline().firstInstant()).thenReturn(Option.empty());
+    
when(metadataTimeline.filterPendingCompactionTimeline().firstInstant()).thenReturn(Option.of(pendingCompaction));
+    writer.metadataMetaClient = metadataMetaClient;
+
+    assertFalse(writer.validateCompactionScheduling(Option.empty(), 
"20250101120000002"));
+  }
+
+  @Test
+  void testValidateCompactionSchedulingRejectsExcessiveDeltaCommits() throws 
Exception {
+    // Protect pending data commits from unbounded metadata delta commits.
+    HoodieTableMetaClient dataMetaClient = mock(HoodieTableMetaClient.class);
+    HoodieInstant pendingCommit = INSTANT_GENERATOR.createNewInstant(
+        HoodieInstant.State.REQUESTED, HoodieTimeline.COMMIT_ACTION, 
"20250101120000000");
+    HoodieActiveTimeline dataTimeline = 
createMockTimeline(Collections.singletonList(pendingCommit));
+    when(dataMetaClient.reloadActiveTimeline()).thenReturn(dataTimeline);
+    HoodieBackedTableMetadataWriterTableVersionSix<?, ?> writer = 
createMockWriter(dataMetaClient);
+
+    HoodieTableMetaClient metadataMetaClient = 
mock(HoodieTableMetaClient.class);
+    HoodieActiveTimeline metadataTimeline = mock(HoodieActiveTimeline.class, 
RETURNS_DEEP_STUBS);
+    
when(metadataMetaClient.reloadActiveTimeline()).thenReturn(metadataTimeline);
+    
when(metadataTimeline.filterCompletedInstants().filter(any()).lastInstant()).thenReturn(Option.empty());
+    
when(metadataTimeline.getDeltaCommitTimeline().countInstants()).thenReturn(2);
+    writer.metadataMetaClient = metadataMetaClient;
+    writer.dataWriteConfig = HoodieWriteConfig.newBuilder()
+        .withPath("/tmp/table")
+        .withMetadataConfig(HoodieMetadataConfig.newBuilder()
+            .withMaxNumDeltacommitsWhenPending(1)
+            .build())
+        .build();
+
+    assertThrows(HoodieMetadataException.class,
+        () -> writer.validateCompactionScheduling(Option.empty(), 
"20250101120000001"));
+  }
+
+  @Test
+  void testCompactIfNecessaryCoversExistingDelegatedAndLogCompactionPaths() {
+    // Exercise completed, delegated, and log-compaction fallback paths.
+    Properties tableServiceManagerProperties = new Properties();
+    tableServiceManagerProperties.put(
+        HoodieTableServiceManagerConfig.TABLE_SERVICE_MANAGER_ENABLED.key(), 
"true");
+    tableServiceManagerProperties.put(
+        HoodieTableServiceManagerConfig.TABLE_SERVICE_MANAGER_ACTIONS.key(), 
ActionType.compaction.name());
+    HoodieTableServiceManagerConfig tableServiceManagerConfig =
+        HoodieTableServiceManagerConfig.newBuilder()
+            .fromProperties(tableServiceManagerProperties)
+            .build();
+    HoodieWriteConfig metadataWriteConfig = mock(HoodieWriteConfig.class);
+    
when(metadataWriteConfig.getTableServiceManagerConfig()).thenReturn(tableServiceManagerConfig);
+    when(metadataWriteConfig.isLogCompactionEnabled()).thenReturn(true);
+
+    HoodieTableMetaClient metadataMetaClient = 
mock(HoodieTableMetaClient.class);
+    HoodieActiveTimeline timeline = mock(HoodieActiveTimeline.class, 
RETURNS_DEEP_STUBS);
+    when(metadataMetaClient.getActiveTimeline()).thenReturn(timeline);
+    
when(timeline.filterCompletedInstants().containsInstant("100001")).thenReturn(true);
+    
when(timeline.filterCompletedInstants().containsInstant("200001")).thenReturn(false);
+    
when(timeline.filterCompletedInstants().containsInstant("300001")).thenReturn(false);
+    
when(timeline.filterCompletedInstants().containsInstant("300005")).thenReturn(false);
+    
when(timeline.filterCompletedInstants().containsInstant("400001")).thenReturn(false);
+    
when(timeline.filterCompletedInstants().containsInstant("400005")).thenReturn(false);
+
+    HoodieBackedTableMetadataWriterTableVersionSix<?, ?> writer =
+        mock(HoodieBackedTableMetadataWriterTableVersionSix.class, 
CALLS_REAL_METHODS);
+    writer.metadataMetaClient = metadataMetaClient;
+    writer.metadataWriteConfig = metadataWriteConfig;
+    BaseHoodieWriteClient writeClient = mock(BaseHoodieWriteClient.class);
+    when(writeClient.scheduleCompactionAtInstant("200001", 
Option.empty())).thenReturn(true);
+    when(writeClient.scheduleCompactionAtInstant("300001", 
Option.empty())).thenReturn(false);
+    when(writeClient.scheduleLogCompactionAtInstant("300005", 
Option.empty())).thenReturn(true);
+
+    // Version 6 derives compaction and log-compaction instants with fixed 
suffixes.
+    writer.compactIfNecessary(writeClient, Option.of("100"));
+    writer.compactIfNecessary(writeClient, Option.of("200"));
+    writer.compactIfNecessary(writeClient, Option.of("300"));
+
+    Properties allTableServicesProperties = new Properties();
+    allTableServicesProperties.put(
+        HoodieTableServiceManagerConfig.TABLE_SERVICE_MANAGER_ENABLED.key(), 
"true");
+    allTableServicesProperties.put(
+        HoodieTableServiceManagerConfig.TABLE_SERVICE_MANAGER_ACTIONS.key(), 
"compaction,logcompaction");
+    when(metadataWriteConfig.getTableServiceManagerConfig()).thenReturn(
+        HoodieTableServiceManagerConfig.newBuilder()
+            .fromProperties(allTableServicesProperties)
+            .build());
+    when(writeClient.scheduleCompactionAtInstant("400001", 
Option.empty())).thenReturn(false);
+    when(writeClient.scheduleLogCompactionAtInstant("400005", 
Option.empty())).thenReturn(true);
+    writer.compactIfNecessary(writeClient, Option.of("400"));
+
+    verify(writeClient).scheduleCompactionAtInstant("200001", Option.empty());
+    verify(writeClient).scheduleLogCompactionAtInstant("300005", 
Option.empty());
+    verify(writeClient).logCompact("300005", true);
+  }
+
+  @Test
+  void testValidateRollbackRejectsCommitBeforeLatestCompaction() throws 
Exception {
+    // Version 6 cannot roll back beyond the latest compaction boundary.
+    HoodieBackedTableMetadataWriterTableVersionSix<?, ?> writer =
+        mock(HoodieBackedTableMetadataWriterTableVersionSix.class, 
CALLS_REAL_METHODS);
+    HoodieInstant compactionInstant = INSTANT_GENERATOR.createNewInstant(
+        HoodieInstant.State.COMPLETED, HoodieTimeline.COMMIT_ACTION, "200", 
"201");
+    HoodieTimeline deltaCommits = mock(HoodieTimeline.class);
+    when(deltaCommits.countInstants()).thenReturn(2);
+    when(deltaCommits.getInstants()).thenReturn(Collections.emptyList());
+    Method validateRollback = 
HoodieBackedTableMetadataWriterTableVersionSix.class
+        .getDeclaredMethod(
+            "validateRollbackVersionSix", String.class, HoodieInstant.class, 
HoodieTimeline.class);
+    validateRollback.setAccessible(true);
+
+    InvocationTargetException exception = assertThrows(
+        InvocationTargetException.class,
+        () -> validateRollback.invoke(writer, "100", compactionInstant, 
deltaCommits));
+    assertTrue(exception.getCause() instanceof HoodieMetadataException);
+  }
+
   private HoodieBackedTableMetadataWriterTableVersionSix<?, ?> 
createMockWriter(HoodieTableMetaClient dataMetaClient) throws Exception {
     // Use CALLS_REAL_METHODS so that shouldInitializeFromFilesystem executes 
the real logic
     HoodieBackedTableMetadataWriterTableVersionSix<?, ?> writer = 
mock(HoodieBackedTableMetadataWriterTableVersionSix.class, CALLS_REAL_METHODS);
diff --git 
a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/metadata/TestHoodieMetadataWriteUtils.java
 
b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/metadata/TestHoodieMetadataWriteUtils.java
index 296716fcaaa3..a68bd289119a 100644
--- 
a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/metadata/TestHoodieMetadataWriteUtils.java
+++ 
b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/metadata/TestHoodieMetadataWriteUtils.java
@@ -24,6 +24,7 @@ import 
org.apache.hudi.client.transaction.lock.ZookeeperBasedImplicitBasePathLoc
 import org.apache.hudi.client.transaction.lock.ZookeeperBasedLockProvider;
 import org.apache.hudi.common.config.HoodieMetadataConfig;
 import org.apache.hudi.common.config.HoodieStorageConfig;
+import org.apache.hudi.common.config.metrics.HoodieMetricsConfig;
 import org.apache.hudi.common.engine.HoodieLocalEngineContext;
 import org.apache.hudi.common.engine.TaskContextSupplier;
 import org.apache.hudi.common.fs.FSUtils;
@@ -46,15 +47,19 @@ import org.apache.hudi.config.HoodieWriteConfig;
 import org.apache.hudi.core.io.storage.HoodieIOFactory;
 import org.apache.hudi.core.transaction.lock.InProcessLockProvider;
 import org.apache.hudi.exception.HoodieException;
+import org.apache.hudi.exception.HoodieMetadataException;
+import org.apache.hudi.metrics.MetricsReporterType;
 import org.apache.hudi.storage.HoodieStorage;
 import org.apache.hudi.storage.StoragePath;
 
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.io.TempDir;
 import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.EnumSource;
 import org.junit.jupiter.params.provider.ValueSource;
 
 import java.nio.file.Path;
+import java.util.Collections;
 import java.util.Map;
 import java.util.Properties;
 
@@ -124,6 +129,46 @@ public class TestHoodieMetadataWriteUtils {
     assertEquals(hfileBloomFilterEnabled, 
metadataWriteConfig.hfileBloomFilterEnabled());
   }
 
+  @ParameterizedTest
+  @EnumSource(value = MetricsReporterType.class, names = {
+      "GRAPHITE", "JMX", "PROMETHEUS_PUSHGATEWAY", "M3", "PROMETHEUS"
+  })
+  void 
testCreateMetadataWriteConfigPropagatesSupportedMetricsReporter(MetricsReporterType
 reporterType) {
+    // Metadata writes must retain every reporter supported by the writer path.
+    HoodieWriteConfig writeConfig = HoodieWriteConfig.newBuilder()
+        .withPath("/tmp/base_path/")
+        .withProps(Collections.singletonMap(
+            "hoodie.metrics.graphite.metric.prefix", "metadata-test"))
+        .withMetricsConfig(HoodieMetricsConfig.newBuilder()
+            .on(true)
+            .withReporterType(reporterType.name())
+            .build())
+        .build();
+
+    HoodieWriteConfig metadataWriteConfig = 
HoodieMetadataWriteUtils.createMetadataWriteConfig(
+        writeConfig, HoodieFailedWritesCleaningPolicy.EAGER, 
HoodieTableVersion.EIGHT);
+
+    assertTrue(metadataWriteConfig.isMetricsOn());
+    assertEquals(reporterType, metadataWriteConfig.getMetricsReporterType());
+  }
+
+  @Test
+  void testCreateMetadataWriteConfigRejectsUnsupportedMetricsReporter() {
+    // Reject unsupported reporters before the metadata writer starts.
+    HoodieWriteConfig writeConfig = HoodieWriteConfig.newBuilder()
+        .withPath("/tmp/base_path/")
+        .withMetricsConfig(HoodieMetricsConfig.newBuilder()
+            .on(true)
+            .withReporterType(MetricsReporterType.SLF4J.name())
+            .build())
+        .build();
+
+    HoodieMetadataException exception = 
assertThrows(HoodieMetadataException.class,
+        () -> HoodieMetadataWriteUtils.createMetadataWriteConfig(
+            writeConfig, HoodieFailedWritesCleaningPolicy.EAGER, 
HoodieTableVersion.EIGHT));
+    assertTrue(exception.getMessage().contains("Unsupported Metrics Reporter 
type SLF4J"));
+  }
+
   @Test
   public void testCreateMetadataWriteConfigForCleaner() {
     HoodieWriteConfig writeConfig1 = HoodieWriteConfig.newBuilder()
diff --git 
a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/metadata/TestSecondaryIndexRecordGenerationUtils.java
 
b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/metadata/TestSecondaryIndexRecordGenerationUtils.java
new file mode 100644
index 000000000000..3db50783cc6c
--- /dev/null
+++ 
b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/metadata/TestSecondaryIndexRecordGenerationUtils.java
@@ -0,0 +1,73 @@
+/*
+ * 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.metadata;
+
+import org.apache.hudi.common.model.HoodieWriteStat;
+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.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+
+import java.util.Collections;
+
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.Mockito.CALLS_REAL_METHODS;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+
+/**
+ * Tests validation and error propagation before secondary-index record 
generation.
+ */
+class TestSecondaryIndexRecordGenerationUtils {
+
+  @Test
+  void rejectsLogFileInsertsBeforeReadingFileSlices() {
+    // Log-file inserts cannot be reconstructed without a base-file slice.
+    HoodieWriteStat writeStat = new HoodieWriteStat();
+    writeStat.setPartitionPath("p1");
+    writeStat.setPath("p1/.fileid-1_014.log.1_1-0-1");
+    writeStat.setNumInserts(1);
+    HoodieWriteConfig writeConfig = 
HoodieWriteConfig.newBuilder().withPath("/tmp/table").build();
+
+    assertThrows(HoodieIOException.class,
+        () -> 
SecondaryIndexRecordGenerationUtils.convertWriteStatsToSecondaryIndexRecords(
+            Collections.singletonList(writeStat), "001", null, null, null, 
null, writeConfig));
+  }
+
+  @Test
+  void wrapsTableSchemaResolutionFailure() {
+    // Wrap schema lookup failures in the metadata utility's exception type.
+    HoodieTableMetaClient metaClient = mock(HoodieTableMetaClient.class);
+    HoodieWriteConfig writeConfig = 
HoodieWriteConfig.newBuilder().withPath("/tmp/table").build();
+
+    try (MockedStatic<HoodieTableMetadataUtil> metadataUtil =
+             mockStatic(HoodieTableMetadataUtil.class, CALLS_REAL_METHODS)) {
+      metadataUtil.when(() -> 
HoodieTableMetadataUtil.tryResolveSchemaForTable(metaClient))
+          .thenThrow(new IllegalStateException("no schema"));
+
+      assertThrows(HoodieException.class,
+          () -> 
SecondaryIndexRecordGenerationUtils.convertWriteStatsToSecondaryIndexRecords(
+              Collections.emptyList(), "001", null, null, metaClient, null, 
writeConfig));
+    }
+  }
+}
diff --git 
a/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/metadata/TestSparkHoodieBackedTableMetadataWriter.java
 
b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/metadata/TestSparkHoodieBackedTableMetadataWriter.java
new file mode 100644
index 000000000000..370d71512536
--- /dev/null
+++ 
b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/metadata/TestSparkHoodieBackedTableMetadataWriter.java
@@ -0,0 +1,183 @@
+/*
+ * 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.metadata;
+
+import org.apache.hudi.client.BaseHoodieWriteClient;
+import org.apache.hudi.client.HoodieWriteResult;
+import org.apache.hudi.client.SparkRDDMetadataWriteClient;
+import org.apache.hudi.client.SparkRDDWriteClient;
+import org.apache.hudi.client.WriteStatus;
+import org.apache.hudi.common.config.HoodieMetadataConfig;
+import org.apache.hudi.common.engine.EngineType;
+import org.apache.hudi.common.model.HoodieFileGroupId;
+import org.apache.hudi.common.model.HoodieRecord;
+import org.apache.hudi.common.table.HoodieTableMetaClient;
+import org.apache.hudi.common.util.Option;
+import org.apache.hudi.config.HoodieWriteConfig;
+import org.apache.hudi.exception.HoodieNotSupportedException;
+import org.apache.hudi.index.HoodieSparkIndexClient;
+
+import org.apache.spark.api.java.JavaRDD;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedConstruction;
+
+import java.util.Collections;
+
+import static 
org.apache.hudi.common.table.timeline.HoodieTimeline.DELTA_COMMIT_ACTION;
+import static 
org.apache.hudi.common.table.timeline.HoodieTimeline.REPLACE_COMMIT_ACTION;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.CALLS_REAL_METHODS;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockConstruction;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * Tests Spark-specific behavior shared by current and table-version-six 
metadata writers.
+ */
+class TestSparkHoodieBackedTableMetadataWriter {
+
+  @Test
+  void exposesSparkEngineAndRejectsV6StreamingConversion() {
+    // Both implementations use Spark, but version 6 has no streaming 
conversion.
+    SparkHoodieBackedTableMetadataWriter currentWriter =
+        mock(SparkHoodieBackedTableMetadataWriter.class, CALLS_REAL_METHODS);
+    SparkHoodieBackedTableMetadataWriterTableVersionSix versionSixWriter =
+        mock(SparkHoodieBackedTableMetadataWriterTableVersionSix.class, 
CALLS_REAL_METHODS);
+
+    assertEquals(EngineType.SPARK, currentWriter.getEngineType());
+    assertEquals(EngineType.SPARK, versionSixWriter.getEngineType());
+    assertThrows(HoodieNotSupportedException.class,
+        () -> versionSixWriter.convertEngineSpecificDataToHoodieData(null));
+  }
+
+  @Test
+  void versionSixFileGroupUpsertCommitsWriteStatuses() {
+    // Version 6 file-group writes use a prepped-record upsert and delta 
commit.
+    SparkHoodieBackedTableMetadataWriterTableVersionSix writer =
+        mock(SparkHoodieBackedTableMetadataWriterTableVersionSix.class, 
CALLS_REAL_METHODS);
+    BaseHoodieWriteClient<?, JavaRDD<HoodieRecord>, ?, JavaRDD<WriteStatus>> 
writeClient =
+        mock(BaseHoodieWriteClient.class);
+    JavaRDD<HoodieRecord> records = mock(JavaRDD.class);
+    JavaRDD<WriteStatus> writeStatuses = mock(JavaRDD.class);
+    when(writeClient.upsertPreppedRecords(records, 
"001")).thenReturn(writeStatuses);
+
+    writer.upsertAndCommit(writeClient, "001", records, 
Collections.emptyList());
+
+    verify(writeClient).commit(
+        "001", writeStatuses, Option.empty(), DELTA_COMMIT_ACTION, 
Collections.emptyMap());
+  }
+
+  @Test
+  void currentFileGroupUpsertCommitsFirstUpsertStatuses() {
+    // Current writer uses first-upsert semantics when file groups are 
supplied.
+    SparkHoodieBackedTableMetadataWriter writer =
+        mock(SparkHoodieBackedTableMetadataWriter.class, CALLS_REAL_METHODS);
+    SparkRDDMetadataWriteClient writeClient = 
mock(SparkRDDMetadataWriteClient.class);
+    JavaRDD<HoodieRecord> records = mock(JavaRDD.class);
+    JavaRDD<WriteStatus> writeStatuses = mock(JavaRDD.class);
+    java.util.List<HoodieFileGroupId> fileGroups = Collections.emptyList();
+    // The writer resolves its internal client even when one is passed in.
+    doReturn(writeClient).when(writer).getWriteClient();
+    when(writeClient.firstUpsertPreppedRecords(records, "003", 
fileGroups)).thenReturn(writeStatuses);
+
+    writer.upsertAndCommit(writeClient, "003", records, fileGroups);
+
+    verify(writeClient).commit(
+        "003", writeStatuses, Option.empty(), DELTA_COMMIT_ACTION, 
Collections.emptyMap());
+  }
+
+  @Test
+  void currentUpsertCoalescesRecordPreparationInput() {
+    // Record preparation honors its configured parallelism before writing.
+    SparkHoodieBackedTableMetadataWriter writer =
+        mock(SparkHoodieBackedTableMetadataWriter.class, CALLS_REAL_METHODS);
+    HoodieMetadataConfig metadataConfig = mock(HoodieMetadataConfig.class);
+    HoodieWriteConfig dataWriteConfig = mock(HoodieWriteConfig.class);
+    when(dataWriteConfig.getMetadataConfig()).thenReturn(metadataConfig);
+    when(metadataConfig.getRecordPreparationParallelism()).thenReturn(1);
+    writer.dataWriteConfig = dataWriteConfig;
+
+    BaseHoodieWriteClient<?, JavaRDD<HoodieRecord>, ?, JavaRDD<WriteStatus>> 
writeClient =
+        mock(BaseHoodieWriteClient.class);
+    JavaRDD<HoodieRecord> records = mock(JavaRDD.class);
+    JavaRDD<HoodieRecord> coalescedRecords = mock(JavaRDD.class);
+    JavaRDD<WriteStatus> writeStatuses = mock(JavaRDD.class);
+    when(records.getNumPartitions()).thenReturn(2);
+    when(records.coalesce(1)).thenReturn(coalescedRecords);
+    when(writeClient.upsertPreppedRecords(coalescedRecords, 
"004")).thenReturn(writeStatuses);
+
+    writer.upsertAndCommit(writeClient, "004", records);
+
+    verify(writeClient).commit(
+        "004", writeStatuses, Option.empty(), DELTA_COMMIT_ACTION, 
Collections.emptyMap());
+  }
+
+  @Test
+  void bothSparkWritersUpdateColumnStatsDefinition() {
+    // Both writer versions delegate column-stat definitions to the Spark 
index client.
+    SparkHoodieBackedTableMetadataWriter currentWriter =
+        mock(SparkHoodieBackedTableMetadataWriter.class, CALLS_REAL_METHODS);
+    SparkHoodieBackedTableMetadataWriterTableVersionSix versionSixWriter =
+        mock(SparkHoodieBackedTableMetadataWriterTableVersionSix.class, 
CALLS_REAL_METHODS);
+    java.util.List<String> columns = Collections.singletonList("rider");
+
+    // Capture index clients created internally by both writer versions.
+    try (MockedConstruction<HoodieSparkIndexClient> construction =
+             mockConstruction(HoodieSparkIndexClient.class)) {
+      currentWriter.updateColumnsToIndexWithColStats(columns);
+      versionSixWriter.updateColumnsToIndexWithColStats(columns);
+
+      assertEquals(2, construction.constructed().size());
+      verify(construction.constructed().get(0))
+          .createOrUpdateColumnStatsIndexDefinition(null, columns);
+      verify(construction.constructed().get(1))
+          .createOrUpdateColumnStatsIndexDefinition(null, columns);
+    }
+  }
+
+  @Test
+  void versionSixDeletePartitionsCommitsReplaceCommit() {
+    // Deleting an MDT partition is committed as a replace commit.
+    SparkHoodieBackedTableMetadataWriterTableVersionSix writer =
+        mock(SparkHoodieBackedTableMetadataWriterTableVersionSix.class, 
CALLS_REAL_METHODS);
+    SparkRDDWriteClient writeClient = mock(SparkRDDWriteClient.class);
+    HoodieWriteResult writeResult = mock(HoodieWriteResult.class);
+    JavaRDD<WriteStatus> writeStatuses = mock(JavaRDD.class);
+    HoodieTableMetaClient metadataMetaClient = 
mock(HoodieTableMetaClient.class);
+    writer.metadataMetaClient = metadataMetaClient;
+    doReturn(writeClient).when(writer).getWriteClient();
+    when(writeResult.getWriteStatuses()).thenReturn(writeStatuses);
+    
when(writeResult.getPartitionToReplaceFileIds()).thenReturn(Collections.emptyMap());
+    when(writeClient.deletePartitions(
+        
Collections.singletonList(MetadataPartitionType.RECORD_INDEX.getPartitionPath()),
 "002"))
+        .thenReturn(writeResult);
+
+    writer.deletePartitions("002", 
Collections.singletonList(MetadataPartitionType.RECORD_INDEX));
+
+    verify(writeClient).startCommitForMetadataTable(eq(metadataMetaClient), 
eq("002"), any());
+    verify(writeClient).commit(
+        "002", writeStatuses, Option.empty(), REPLACE_COMMIT_ACTION, 
Collections.emptyMap());
+  }
+}
diff --git 
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestMetadataTableWithSparkSQL.scala
 
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestMetadataTableWithSparkSQL.scala
new file mode 100644
index 000000000000..3b063b2932e4
--- /dev/null
+++ 
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestMetadataTableWithSparkSQL.scala
@@ -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.functional
+
+import org.apache.hudi.DataSourceWriteOptions._
+import org.apache.hudi.client.SparkRDDWriteClient
+import org.apache.hudi.client.common.HoodieSparkEngineContext
+import org.apache.hudi.common.config.{HoodieMetadataConfig, TypedProperties}
+import org.apache.hudi.common.model.HoodieTableType
+import org.apache.hudi.common.table.{HoodieTableMetaClient, 
HoodieTableVersion, TableSchemaResolver}
+import org.apache.hudi.common.testutils.HoodieTestUtils
+import org.apache.hudi.config.{HoodieLockConfig, HoodieWriteConfig}
+import org.apache.hudi.core.transaction.lock.InProcessLockProvider
+import 
org.apache.hudi.metadata.HoodieMetadataPayload.SECONDARY_INDEX_RECORD_KEY_SEPARATOR
+import org.apache.hudi.metadata.MetadataPartitionType
+import 
org.apache.hudi.testutils.SparkClientFunctionalTestHarness.getSparkSqlConf
+import org.apache.hudi.testutils.SparkClientFunctionalTestHarnessScala
+
+import org.apache.spark.SparkConf
+import org.junit.jupiter.api.{BeforeEach, Tag, Test}
+import org.junit.jupiter.api.Assertions.{assertEquals, assertFalse, assertTrue}
+
+import scala.collection.JavaConverters.mapAsJavaMapConverter
+
+/**
+ * Exercises {@code SparkHoodieBackedTableMetadataWriter} and its 
table-version-six
+ * implementation through real Spark writes.
+ */
+@Tag("functional-c")
+class TestMetadataTableWithSparkSQL extends 
SparkClientFunctionalTestHarnessScala {
+
+  override def conf: SparkConf = conf(getSparkSqlConf)
+
+  @BeforeEach
+  override def runBeforeEach(): Unit = {
+    super.runBeforeEach()
+    spark.sql(s"set ${HoodieLockConfig.LOCK_PROVIDER_CLASS_NAME.key()} = 
${classOf[InProcessLockProvider].getName}")
+  }
+
+  @Test
+  def testAllMetadataIndexesAcrossUpsertAndRollback(): Unit = {
+    // Validate the full MDT lifecycle against a current-version COW table.
+    val tableName = "metadata_writer_all_indexes"
+    val tablePath = s"$basePath/$tableName"
+    val writeOptions = metadataWriteOptions(tableName, streamingWrites = true)
+
+    // Bootstrap all supported MDT partitions through real writes.
+    createTable(tableName, tablePath, tableVersion = None)
+    spark.sql(
+      s"""insert into $tableName values
+         |  (1, 'row1', 'alpha', 'p1'),
+         |  (2, 'row2', 'beta', 'p1'),
+         |  (3, 'row3', 'gamma', 'p2')
+         |""".stripMargin)
+    spark.sql(s"create index idx_rider on $tableName (rider)")
+
+    var metaClient = createMetaClient(tablePath)
+    assertMetadataPartitions(metaClient, includePartitionStats = true, 
includeSecondaryIndex = true)
+    assertCommonMetadataRecords(tablePath, expectedRecordIndexCount = 3, 
includePartitionStats = true)
+    // Metadata payload type 7 stores secondary-index mappings.
+    checkAnswer(s"select key from hudi_metadata('$tablePath') where type=7")(
+      Seq(s"alpha${SECONDARY_INDEX_RECORD_KEY_SEPARATOR}row1"),
+      Seq(s"beta${SECONDARY_INDEX_RECORD_KEY_SEPARATOR}row2"),
+      Seq(s"gamma${SECONDARY_INDEX_RECORD_KEY_SEPARATOR}row3")
+    )
+
+    // Rebuild the secondary index and verify its records are removed and 
restored.
+    spark.sql(s"drop index idx_rider on $tableName")
+    metaClient = HoodieTableMetaClient.reload(metaClient)
+    
assertFalse(metaClient.getTableConfig.getMetadataPartitions.contains("secondary_index_idx_rider"))
+    assertEquals(0L, spark.sql(s"select key from hudi_metadata('$tablePath') 
where type=7").count())
+
+    spark.sql(s"create index idx_rider on $tableName (rider)")
+    metaClient = HoodieTableMetaClient.reload(metaClient)
+    assertMetadataPartitions(metaClient, includePartitionStats = true, 
includeSecondaryIndex = true)
+    checkAnswer(s"select key from hudi_metadata('$tablePath') where type=7")(
+      Seq(s"alpha${SECONDARY_INDEX_RECORD_KEY_SEPARATOR}row1"),
+      Seq(s"beta${SECONDARY_INDEX_RECORD_KEY_SEPARATOR}row2"),
+      Seq(s"gamma${SECONDARY_INDEX_RECORD_KEY_SEPARATOR}row3")
+    )
+
+    // Upsert changes both data and the secondary-index key.
+    spark.sql(s"update $tableName set rider = 'delta', ts = 4 where id = 
'row1'")
+    metaClient = HoodieTableMetaClient.reload(metaClient)
+    val upsertInstant = metaClient.getActiveTimeline.getCommitsTimeline
+      .filterCompletedInstants.lastInstant().get().requestedTime()
+
+    checkAnswer(s"select id, rider, part from $tableName order by id")(
+      Seq("row1", "delta", "p1"),
+      Seq("row2", "beta", "p1"),
+      Seq("row3", "gamma", "p2")
+    )
+    checkAnswer(s"select key from hudi_metadata('$tablePath') where type=7")(
+      Seq(s"delta${SECONDARY_INDEX_RECORD_KEY_SEPARATOR}row1"),
+      Seq(s"beta${SECONDARY_INDEX_RECORD_KEY_SEPARATOR}row2"),
+      Seq(s"gamma${SECONDARY_INDEX_RECORD_KEY_SEPARATOR}row3")
+    )
+    assertCommonMetadataRecords(tablePath, expectedRecordIndexCount = 3, 
includePartitionStats = true)
+
+    // Rollback must restore data and all MDT indexes.
+    rollback(metaClient, writeOptions, upsertInstant)
+    spark.catalog.refreshTable(tableName)
+    metaClient = HoodieTableMetaClient.reload(metaClient)
+
+    checkAnswer(s"select id, rider, part from $tableName order by id")(
+      Seq("row1", "alpha", "p1"),
+      Seq("row2", "beta", "p1"),
+      Seq("row3", "gamma", "p2")
+    )
+    assertMetadataPartitions(metaClient, includePartitionStats = true, 
includeSecondaryIndex = true)
+    assertCommonMetadataRecords(tablePath, expectedRecordIndexCount = 3, 
includePartitionStats = true)
+    checkAnswer(s"select key from hudi_metadata('$tablePath') where type=7")(
+      Seq(s"alpha${SECONDARY_INDEX_RECORD_KEY_SEPARATOR}row1"),
+      Seq(s"beta${SECONDARY_INDEX_RECORD_KEY_SEPARATOR}row2"),
+      Seq(s"gamma${SECONDARY_INDEX_RECORD_KEY_SEPARATOR}row3")
+    )
+  }
+
+  @Test
+  def testTableVersionSixMetadataWritesAndRollback(): Unit = {
+    // Validate the legacy writer with the indexes supported by table version 
6.
+    val tableName = "metadata_writer_v6"
+    val tablePath = s"$basePath/$tableName"
+    val writeOptions = metadataWriteOptions(tableName, streamingWrites = 
false) +
+      (HoodieWriteConfig.WRITE_TABLE_VERSION.key() -> 
HoodieTableVersion.SIX.versionCode().toString)
+
+    // Version 6 uses the legacy writer and supports a smaller index set.
+    createTable(tableName, tablePath, tableVersion = 
Some(HoodieTableVersion.SIX.versionCode()))
+    spark.sql(
+      s"""insert into $tableName values
+         |  (1, 'row1', 'alpha', 'p1'),
+         |  (2, 'row2', 'beta', 'p2')
+         |""".stripMargin)
+
+    var metaClient = createMetaClient(tablePath)
+    val metadataMetaClient = createMetaClient(metaClient.getMetaPath + 
"/metadata")
+    assertEquals(HoodieTableVersion.SIX, 
metaClient.getTableConfig.getTableVersion)
+    assertEquals(HoodieTableVersion.SIX, 
metadataMetaClient.getTableConfig.getTableVersion)
+    assertMetadataPartitions(metaClient, includePartitionStats = false, 
includeSecondaryIndex = false)
+    assertCommonMetadataRecords(tablePath, expectedRecordIndexCount = 2, 
includePartitionStats = false)
+
+    spark.sql(s"update $tableName set rider = 'updated', ts = 3 where id = 
'row1'")
+    metaClient = HoodieTableMetaClient.reload(metaClient)
+    val upsertInstant = metaClient.getActiveTimeline.getCommitsTimeline
+      .filterCompletedInstants.lastInstant().get().requestedTime()
+    checkAnswer(s"select id, rider from $tableName order by id")(
+      Seq("row1", "updated"),
+      Seq("row2", "beta")
+    )
+    assertCommonMetadataRecords(tablePath, expectedRecordIndexCount = 2, 
includePartitionStats = false)
+
+    rollback(metaClient, writeOptions, upsertInstant)
+    spark.catalog.refreshTable(tableName)
+    metaClient = HoodieTableMetaClient.reload(metaClient)
+
+    checkAnswer(s"select id, rider from $tableName order by id")(
+      Seq("row1", "alpha"),
+      Seq("row2", "beta")
+    )
+    assertEquals(HoodieTableVersion.SIX, 
metaClient.getTableConfig.getTableVersion)
+    assertMetadataPartitions(metaClient, includePartitionStats = false, 
includeSecondaryIndex = false)
+    assertCommonMetadataRecords(tablePath, expectedRecordIndexCount = 2, 
includePartitionStats = false)
+
+    // Verify record-index deletion and bootstrap on the legacy table.
+    spark.sql(s"drop index record_index on $tableName")
+    metaClient = HoodieTableMetaClient.reload(metaClient)
+    
assertFalse(metaClient.getTableConfig.getMetadataPartitions.contains(MetadataPartitionType.RECORD_INDEX.getPartitionPath))
+    assertEquals(0L, spark.sql(s"select key from hudi_metadata('$tablePath') 
where type=5").count())
+
+    spark.sql(s"create index record_index on $tableName (id)")
+    metaClient = HoodieTableMetaClient.reload(metaClient)
+    
assertTrue(metaClient.getTableConfig.getMetadataPartitions.contains(MetadataPartitionType.RECORD_INDEX.getPartitionPath))
+    assertEquals(2L, spark.sql(s"select key from hudi_metadata('$tablePath') 
where type=5").count())
+  }
+
+  private def createTable(
+      tableName: String,
+      tablePath: String,
+      tableVersion: Option[Int]): Unit = {
+    val tableVersionOption = tableVersion
+      .map(version => s"hoodie.write.table.version = '$version',")
+      .getOrElse("")
+    val streamingWrites = tableVersion.isEmpty
+    spark.sql(
+      s"""
+         |create table $tableName (
+         |  ts bigint,
+         |  id string,
+         |  rider string,
+         |  part string
+         |) using hudi
+         | options (
+         |  primaryKey = 'id',
+         |  orderingFields = 'ts',
+         |  type = 'cow',
+         |  $tableVersionOption
+         |  hoodie.metadata.enable = 'true',
+         |  hoodie.metadata.index.column.stats.enable = 'true',
+         |  hoodie.metadata.index.partition.stats.enable = 'true',
+         |  hoodie.metadata.record.index.enable = 'true',
+         |  hoodie.metadata.streaming.write.enabled = '$streamingWrites',
+         |  hoodie.metadata.record.preparation.parallelism = '1',
+         |  hoodie.metrics.on = 'true',
+         |  hoodie.metrics.reporter.type = 'CONSOLE',
+         |  hoodie.metrics.executor.enable = 'true',
+         |  hoodie.datasource.write.recordkey.field = 'id',
+         |  hoodie.datasource.write.partitionpath.field = 'part',
+         |  hoodie.datasource.write.payload.class =
+         |    'org.apache.hudi.common.model.OverwriteWithLatestAvroPayload'
+         | )
+         | partitioned by (part)
+         | location '$tablePath'
+         |""".stripMargin)
+  }
+
+  private def metadataWriteOptions(tableName: String, streamingWrites: 
Boolean): Map[String, String] = Map(
+    HoodieWriteConfig.TBL_NAME.key() -> tableName,
+    TABLE_TYPE.key() -> HoodieTableType.COPY_ON_WRITE.name(),
+    RECORDKEY_FIELD.key() -> "id",
+    PARTITIONPATH_FIELD.key() -> "part",
+    PRECOMBINE_FIELD.key() -> "ts",
+    HoodieMetadataConfig.ENABLE.key() -> "true",
+    HoodieMetadataConfig.ENABLE_METADATA_INDEX_COLUMN_STATS.key() -> "true",
+    HoodieMetadataConfig.ENABLE_METADATA_INDEX_PARTITION_STATS.key() -> "true",
+    HoodieMetadataConfig.GLOBAL_RECORD_LEVEL_INDEX_ENABLE_PROP.key() -> "true",
+    HoodieMetadataConfig.STREAMING_WRITE_ENABLED.key() -> 
streamingWrites.toString,
+    HoodieMetadataConfig.RECORD_PREPARATION_PARALLELISM.key() -> "1",
+    "hoodie.metrics.on" -> "true",
+    "hoodie.metrics.reporter.type" -> "CONSOLE",
+    "hoodie.metrics.executor.enable" -> "true",
+    HoodieLockConfig.LOCK_PROVIDER_CLASS_NAME.key() -> 
classOf[InProcessLockProvider].getName
+  )
+
+  private def createMetaClient(tablePath: String): HoodieTableMetaClient =
+    HoodieTableMetaClient.builder()
+      .setBasePath(tablePath)
+      .setConf(HoodieTestUtils.getDefaultStorageConf)
+      .build()
+
+  private def rollback(
+      metaClient: HoodieTableMetaClient,
+      writeOptions: Map[String, String],
+      instantTime: String): Unit = {
+    val props = TypedProperties.fromMap(writeOptions.asJava)
+    val writeConfig = HoodieWriteConfig.newBuilder()
+      .withPath(metaClient.getBasePath)
+      .withSchema(new 
TableSchemaResolver(metaClient).getTableSchema(false).toString)
+      .withProps(props)
+      .withEmbeddedTimelineServerEnabled(false)
+      .build()
+    val writeClient = new SparkRDDWriteClient(new 
HoodieSparkEngineContext(jsc), writeConfig)
+    try {
+      assertTrue(writeClient.rollback(instantTime))
+    } finally {
+      writeClient.close()
+    }
+  }
+
+  private def assertMetadataPartitions(
+      metaClient: HoodieTableMetaClient,
+      includePartitionStats: Boolean,
+      includeSecondaryIndex: Boolean): Unit = {
+    val partitions = metaClient.getTableConfig.getMetadataPartitions
+    
assertTrue(partitions.contains(MetadataPartitionType.FILES.getPartitionPath))
+    
assertTrue(partitions.contains(MetadataPartitionType.COLUMN_STATS.getPartitionPath))
+    
assertTrue(partitions.contains(MetadataPartitionType.RECORD_INDEX.getPartitionPath))
+    if (includePartitionStats) {
+      
assertTrue(partitions.contains(MetadataPartitionType.PARTITION_STATS.getPartitionPath))
+    }
+    if (includeSecondaryIndex) {
+      assertTrue(partitions.contains("secondary_index_idx_rider"))
+    }
+  }
+
+  private def assertCommonMetadataRecords(
+      tablePath: String,
+      expectedRecordIndexCount: Long,
+      includePartitionStats: Boolean): Unit = {
+    // Payload types 3, 6, and 5 represent column stats, partition stats, and 
record index.
+    assertTrue(spark.sql(s"select key from hudi_metadata('$tablePath') where 
type=3").count() > 0)
+    if (includePartitionStats) {
+      assertTrue(spark.sql(s"select key from hudi_metadata('$tablePath') where 
type=6").count() > 0)
+    }
+    assertEquals(
+      expectedRecordIndexCount,
+      spark.sql(s"select key from hudi_metadata('$tablePath') where 
type=5").count())
+  }
+
+  private def checkAnswer(query: String)(expected: Seq[Any]*): Unit = {
+    val expectedRows = expected.map(_.mkString("|")).sorted.toList
+    val actualRows = 
spark.sql(query).collect().map(_.toSeq.mkString("|")).sorted.toList
+    assertEquals(expectedRows, actualRows)
+  }
+}

Reply via email to