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

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


The following commit(s) were added to refs/heads/master by this push:
     new 55c7a308094c fix(timeline): do not NPE on archived instants without a 
completion time (#19452)
55c7a308094c is described below

commit 55c7a308094c2330189584d9df4cf03f85edd56a
Author: Ranga Reddy <[email protected]>
AuthorDate: Mon Aug 3 18:45:29 2026 +0530

    fix(timeline): do not NPE on archived instants without a completion time 
(#19452)
    
    * fix(timeline): do not NPE on archived instants without a completion time
    
    Upgrading a table written by 0.x fails while polling the archived timeline:
    
      java.lang.NullPointerException: Cannot invoke "Object.toString()" because 
the
      return value of "org.apache.avro.generic.GenericRecord.get(String)" is 
null
        at CompletionTimeQueryViewV2.readCompletionTime
    
    completionTime is declared ["null","string"] with a null default in
    HoodieLSMTimelineInstant, and instants archived before that field existed 
carry
    no value for it. setCompletionTime already handles the null case by falling 
back
    to the instant time, with a comment saying so, but readCompletionTime called
    toString() on the raw field before reaching it.
    
    Read the field as an Object and let the existing fallback apply. Adds unit 
tests
    for both the missing and present cases; readCompletionTime is widened to
    package-private with @VisibleForTesting, matching the annotation already 
used in
    this class.
    
    The same unguarded toString() on this field also appears in
    ArchivedTimelineV2#readCommit and MetadataConversionUtils, where the right
    behaviour for a null value is less obvious. Left alone here and called out 
in the
    PR instead.
    
    Closes #17095
    
    * fix(timeline): use StringUtils.objToString and tidy the regression test
    
    Review feedback.
    
    - readCompletionTime now uses StringUtils.objToString, the existing 
null-safe
      toString that HoodieAvroUtils.getNullableValAsString is built on, instead 
of a
      local variable and a ternary.
    - Test: added the missing class javadoc, renamed to match the convention in 
this
      area (testReadCompletionTime / 
testReadCompletionTimeWithoutCompletionTime),
      dropped the instantTime and action fields that readCompletionTime never 
reads
      and which implied a coupling that is not there, and made the assertion
      messages consistent across both cases.
    
    On moving the test onto the real archiving harness in hudi-client-common: 
tried
    it, and it does not reproduce this bug. Details in the review thread.
    
    * test(timeline): cover the null completion time on the real archived read 
path
    
    Adds testReadCompletionTimeWithoutCompletionTime to 
TestCompletionTimeQueryView.
    It archives an instant carrying no completion time through 
LSMTimelineWriter and
    reads it back through the archived timeline, so the fallback in 
readCompletionTime
    is exercised on the path that actually broke. Reverting the fix makes it 
fail with
    the HUDI-9655 NPE.
    
    The test asserts LSMTimelineWriter's exception handler collected nothing. 
That
    handler is optional and the write loop swallows per-instant failures, so 
without
    the assertion a failed archive write would leave the test passing against an
    empty archive.
    
    With real-path coverage the mocked TestCompletionTimeQueryViewV2 is 
redundant, so
    it goes, and readCompletionTime returns to private.
    
    * fix(timeline): null-safe the other two reads of the archived 
completionTime
    
    Review question: the same raw 
record.get(COMPLETION_TIME_ARCHIVED_META_FIELD)
    .toString() also lives in ArchivedTimelineV2#readCommit and
    MetadataConversionUtils#createMetaWrapper. Checked, and both do NPE on the 
same
    record shape - createMetaWrapper demonstrably, at line 174, on a record 
with the
    field left unset. Both read the same LSM records as the query view, so the 
trigger
    is identical: a table archived before completionTime existed.
    
    Add ArchivedTimelineV2#completionTimeOrInstantTime so the two sites cannot 
drift,
    and route both through it. Falling back to the instant time is the behaviour
    CompletionTimeQueryViewV2#setCompletionTime already documents for these 
records, so
    this follows existing precedent rather than inventing a rule. Both sites 
build a
    COMPLETED HoodieInstant, and leaving the completion time null there would 
only move
    the failure to whatever compares it.
    
    CompletionTimeQueryViewV2#readCompletionTime is left as is: it hands a 
possibly-null
    value to setCompletionTime, which owns the fallback, so it needs nothing 
further.
    
    Note the two sites already null-check the neighbouring nullable fields, 
metadata and
    plan, so completionTime was the odd one out rather than a deliberate choice.
    
    ---------
    
    Co-authored-by: voon <[email protected]>
---
 .../timeline/TestCompletionTimeQueryView.java      |  53 ++++++++++
 .../table/timeline/MetadataConversionUtils.java    |   2 +-
 .../timeline/versioning/v2/ArchivedTimelineV2.java |  21 +++-
 .../versioning/v2/CompletionTimeQueryViewV2.java   |   6 +-
 .../TestArchivedInstantCompletionTime.java         | 116 +++++++++++++++++++++
 5 files changed, 194 insertions(+), 4 deletions(-)

diff --git 
a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/client/timeline/TestCompletionTimeQueryView.java
 
b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/client/timeline/TestCompletionTimeQueryView.java
index f007854a0d4e..93656debb7b3 100644
--- 
a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/client/timeline/TestCompletionTimeQueryView.java
+++ 
b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/client/timeline/TestCompletionTimeQueryView.java
@@ -126,6 +126,59 @@ public class TestCompletionTimeQueryView {
     }
   }
 
+  /**
+   * The {@code completionTime} field of {@code HoodieLSMTimelineInstant} is 
declared
+   * {@code ["null","string"]} with a null default, and instants archived 
before the field existed carry
+   * no value for it. Reading such an instant must fall back to the instant 
time rather than throwing.
+   *
+   * <p>See HUDI-9655: upgrading a table written by 0.x produced
+   * {@code NullPointerException: Cannot invoke "Object.toString()" because 
the return value of
+   * "org.apache.avro.generic.GenericRecord.get(String)" is null} while 
loading the archived timeline.
+   */
+  @Test
+  void testReadCompletionTimeWithoutCompletionTime() throws Exception {
+    String tableName = "testTable";
+    String tablePath = tempFile.getAbsolutePath() + StoragePath.SEPARATOR + 
tableName;
+    HoodieTableMetaClient metaClient = HoodieTestUtils.init(
+        HoodieTestUtils.getDefaultStorageConf(), tablePath, 
HoodieTableType.COPY_ON_WRITE, tableName);
+    HoodieWriteConfig writeConfig = 
HoodieWriteConfig.newBuilder().withPath(tablePath)
+        
.withIndexConfig(HoodieIndexConfig.newBuilder().withIndexType(HoodieIndex.IndexType.INMEMORY).build())
+        .withMarkersType("DIRECT")
+        .build();
+    HoodieTestTable testTable = HoodieTestTable.of(metaClient);
+
+    // instant 1 only ever exists on the LSM timeline, as an instant archived 
by an older writer would.
+    String archivedInstantTime = String.format("%08d", 1);
+    HoodieCommitMetadata archivedMetadata = testTable.createCommitMetadata(
+        archivedInstantTime, WriteOperationType.INSERT, Arrays.asList("par1", 
"par2"), 10, false);
+    // instants 2..4 stay active, so that the query for instant 1 falls 
through to the archive.
+    for (int i = 2; i < 5; i++) {
+      String instantTime = String.format("%08d", i);
+      HoodieCommitMetadata metadata = testTable.createCommitMetadata(
+          instantTime, WriteOperationType.INSERT, Arrays.asList("par1", 
"par2"), 10, false);
+      testTable.addCommit(instantTime, Option.of(String.format("%08d", i + 
1000)), Option.of(metadata));
+    }
+
+    // archive instant 1 with no completion time at all
+    ActiveAction activeAction = new DummyActiveAction(
+        INSTANT_GENERATOR.createNewInstant(HoodieInstant.State.COMPLETED, 
"commit", archivedInstantTime, null),
+        convertMetadataToByteArray(archivedMetadata));
+    List<Exception> archiveFailures = new ArrayList<>();
+    // LSMTimelineWriter#write swallows per-instant failures, so surface them 
rather than
+    // silently archiving nothing and leaving the assertion below to pass 
vacuously.
+    LSMTimelineWriter.getInstance(writeConfig, getMockHoodieTable(metaClient))
+        .write(Collections.singletonList(activeAction), Option.empty(), 
Option.of(archiveFailures::add));
+    assertTrue(archiveFailures.isEmpty(),
+        "Archiving an instant without a completion time should not fail: " + 
archiveFailures);
+
+    metaClient.reloadActiveTimeline();
+    try (CompletionTimeQueryView view =
+             
metaClient.getTableFormat().getTimelineFactory().createCompletionTimeQueryView(metaClient))
 {
+      assertThat("An archived instant without a completion time should fall 
back to its instant time",
+          view.getCompletionTime(archivedInstantTime).orElse(""), 
is(archivedInstantTime));
+    }
+  }
+
   @Test
   void testReadStartTime() throws Exception {
     String tableName = "testTable";
diff --git 
a/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/MetadataConversionUtils.java
 
b/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/MetadataConversionUtils.java
index 8ea9ccc03fb9..b7f4019dfacd 100644
--- 
a/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/MetadataConversionUtils.java
+++ 
b/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/MetadataConversionUtils.java
@@ -171,7 +171,7 @@ public class MetadataConversionUtils {
     Option<byte[]> planBytes = planBuffer != null ? 
Option.of(planBuffer.array()) : Option.empty();
 
     String instantTime = 
lsmTimelineRecord.get(ArchivedTimelineV2.INSTANT_TIME_ARCHIVED_META_FIELD).toString();
-    String completionTime = 
lsmTimelineRecord.get(ArchivedTimelineV2.COMPLETION_TIME_ARCHIVED_META_FIELD).toString();
+    String completionTime = 
ArchivedTimelineV2.completionTimeOrInstantTime(lsmTimelineRecord, instantTime);
 
     HoodieArchivedMetaEntry archivedMetaWrapper = new 
HoodieArchivedMetaEntry();
     archivedMetaWrapper.setCommitTime(instantTime);
diff --git 
a/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/versioning/v2/ArchivedTimelineV2.java
 
b/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/versioning/v2/ArchivedTimelineV2.java
index f88168ea985c..2f8cdfe7d4e9 100644
--- 
a/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/versioning/v2/ArchivedTimelineV2.java
+++ 
b/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/versioning/v2/ArchivedTimelineV2.java
@@ -27,6 +27,7 @@ import org.apache.hudi.common.table.timeline.HoodieTimeline;
 import org.apache.hudi.common.table.timeline.InstantComparison;
 import org.apache.hudi.common.util.CollectionUtils;
 import org.apache.hudi.common.util.Option;
+import org.apache.hudi.common.util.StringUtils;
 
 import org.apache.avro.generic.GenericRecord;
 import org.slf4j.Logger;
@@ -217,9 +218,27 @@ public class ArchivedTimelineV2 extends BaseTimelineV2 
implements HoodieArchived
     }
   }
 
+  /**
+   * The completion time of an archived instant, falling back to its instant 
time when the record carries
+   * none.
+   *
+   * <p>{@code completionTime} is declared {@code ["null","string"]} with a 
null default in
+   * {@code HoodieLSMTimelineInstant} and has no value for instants archived 
before the field existed, so it
+   * must not be dereferenced. Defaulting to the instant time is the fallback
+   * {@code CompletionTimeQueryViewV2#setCompletionTime} already documents for 
the same records.
+   *
+   * @param record       an LSM timeline record.
+   * @param instantTime  the instant time to fall back to.
+   * @return the completion time, never null as long as {@code instantTime} is 
not.
+   */
+  public static String completionTimeOrInstantTime(GenericRecord record, 
String instantTime) {
+    String completionTime = 
StringUtils.objToString(record.get(COMPLETION_TIME_ARCHIVED_META_FIELD));
+    return completionTime != null ? completionTime : instantTime;
+  }
+
   private HoodieInstant readCommit(String instantTime, GenericRecord record, 
Option<BiConsumer<String, GenericRecord>> instantDetailsConsumer) {
     final String action = record.get(ACTION_ARCHIVED_META_FIELD).toString();
-    final String completionTime = 
record.get(COMPLETION_TIME_ARCHIVED_META_FIELD).toString();
+    final String completionTime = completionTimeOrInstantTime(record, 
instantTime);
     instantDetailsConsumer.ifPresent(consumer -> consumer.accept(instantTime, 
record));
     return instantGenerator.createNewInstant(HoodieInstant.State.COMPLETED, 
action, instantTime, completionTime);
   }
diff --git 
a/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/versioning/v2/CompletionTimeQueryViewV2.java
 
b/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/versioning/v2/CompletionTimeQueryViewV2.java
index 516b4ad4cedd..be8dbd0462b1 100644
--- 
a/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/versioning/v2/CompletionTimeQueryViewV2.java
+++ 
b/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/versioning/v2/CompletionTimeQueryViewV2.java
@@ -27,6 +27,7 @@ import 
org.apache.hudi.common.table.timeline.HoodieInstantTimeGenerator;
 import org.apache.hudi.common.table.timeline.HoodieTimeline;
 import org.apache.hudi.common.table.timeline.InstantComparison;
 import org.apache.hudi.common.util.Option;
+import org.apache.hudi.common.util.StringUtils;
 import org.apache.hudi.common.util.VisibleForTesting;
 
 import lombok.Getter;
@@ -303,8 +304,9 @@ public class CompletionTimeQueryViewV2 implements 
CompletionTimeQueryView, Seria
   }
 
   private void readCompletionTime(String instantTime, GenericRecord record) {
-    final String completionTime = 
record.get(COMPLETION_TIME_ARCHIVED_META_FIELD).toString();
-    setCompletionTime(instantTime, completionTime);
+    // The field is nullable in HoodieLSMTimelineInstant and is absent for 
instants archived before it
+    // existed, so leave the fallback to setCompletionTime rather than 
dereferencing here.
+    setCompletionTime(instantTime, 
StringUtils.objToString(record.get(COMPLETION_TIME_ARCHIVED_META_FIELD)));
   }
 
   private void setCompletionTime(String beginInstantTime, String 
completionTime) {
diff --git 
a/hudi-common/src/test/java/org/apache/hudi/common/table/timeline/TestArchivedInstantCompletionTime.java
 
b/hudi-common/src/test/java/org/apache/hudi/common/table/timeline/TestArchivedInstantCompletionTime.java
new file mode 100644
index 000000000000..cb6356aa807f
--- /dev/null
+++ 
b/hudi-common/src/test/java/org/apache/hudi/common/table/timeline/TestArchivedInstantCompletionTime.java
@@ -0,0 +1,116 @@
+/*
+ * 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.common.table.timeline;
+
+import org.apache.hudi.avro.model.HoodieArchivedMetaEntry;
+import org.apache.hudi.avro.model.HoodieLSMTimelineInstant;
+import org.apache.hudi.common.model.HoodieCommitMetadata;
+import org.apache.hudi.common.table.HoodieTableConfig;
+import org.apache.hudi.common.table.HoodieTableMetaClient;
+import org.apache.hudi.common.table.HoodieTableVersion;
+import org.apache.hudi.common.table.timeline.versioning.v2.ArchivedTimelineV2;
+import org.apache.hudi.common.table.timeline.versioning.v2.InstantGeneratorV2;
+
+import org.apache.avro.generic.GenericData;
+import org.apache.avro.generic.GenericRecord;
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.ByteBuffer;
+import java.util.function.BooleanSupplier;
+
+import static 
org.apache.hudi.common.table.timeline.versioning.v2.ArchivedTimelineV2.ACTION_ARCHIVED_META_FIELD;
+import static 
org.apache.hudi.common.table.timeline.versioning.v2.ArchivedTimelineV2.COMPLETION_TIME_ARCHIVED_META_FIELD;
+import static 
org.apache.hudi.common.table.timeline.versioning.v2.ArchivedTimelineV2.INSTANT_TIME_ARCHIVED_META_FIELD;
+import static 
org.apache.hudi.common.table.timeline.versioning.v2.ArchivedTimelineV2.METADATA_ARCHIVED_META_FIELD;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * The {@code completionTime} field of {@code HoodieLSMTimelineInstant} is 
declared
+ * {@code ["null","string"]} with a null default, and carries no value for 
instants archived before the
+ * field existed. Every read of it therefore has to be null-safe; 
dereferencing it produced
+ * {@code NullPointerException: Cannot invoke "Object.toString()" because the 
return value of
+ * "GenericRecord.get(String)" is null} on a table upgraded from 0.x 
(HUDI-9655).
+ *
+ * <p>This covers the two readers that build a completed {@link HoodieInstant} 
from such a record, both of
+ * which fall back to the instant time.
+ */
+class TestArchivedInstantCompletionTime {
+
+  private static final String INSTANT_TIME = "00000001";
+
+  @Test
+  void completionTimeFallsBackToTheInstantTimeWhenAbsent() {
+    GenericRecord record = new 
GenericData.Record(HoodieLSMTimelineInstant.getClassSchema());
+    // completionTime deliberately left unset, as it is for an instant 
archived before the field existed
+
+    assertEquals(INSTANT_TIME, 
ArchivedTimelineV2.completionTimeOrInstantTime(record, INSTANT_TIME),
+        "An archived instant without a completion time should fall back to its 
instant time");
+  }
+
+  @Test
+  void completionTimeIsUsedWhenPresent() {
+    GenericRecord record = new 
GenericData.Record(HoodieLSMTimelineInstant.getClassSchema());
+    record.put(COMPLETION_TIME_ARCHIVED_META_FIELD, "00001001");
+
+    assertEquals("00001001", 
ArchivedTimelineV2.completionTimeOrInstantTime(record, INSTANT_TIME),
+        "A present completion time should be used as-is");
+  }
+
+  /**
+   * The same field read on the way to a {@code HoodieArchivedMetaEntry}, 
which is the path a CLI or
+   * metadata-conversion caller takes rather than the query view.
+   */
+  @Test
+  void createMetaWrapperFallsBackToTheInstantTimeWhenCompletionTimeIsAbsent() 
throws IOException {
+    GenericRecord record = new 
GenericData.Record(HoodieLSMTimelineInstant.getClassSchema());
+    record.put(INSTANT_TIME_ARCHIVED_META_FIELD, INSTANT_TIME);
+    record.put(ACTION_ARCHIVED_META_FIELD, HoodieTimeline.COMMIT_ACTION);
+    record.put(METADATA_ARCHIVED_META_FIELD, ByteBuffer.wrap(new byte[0]));
+    // completionTime deliberately left unset
+
+    HoodieArchivedMetaEntry entry =
+        
MetadataConversionUtils.createMetaWrapper(mockMetaClientReturningEmptyCommitMetadata(),
 record);
+
+    assertEquals(INSTANT_TIME, entry.getStateTransitionTime(),
+        "The archived entry should carry the instant time when the record has 
no completion time");
+    assertEquals(INSTANT_TIME, entry.getCommitTime());
+  }
+
+  private static HoodieTableMetaClient 
mockMetaClientReturningEmptyCommitMetadata() throws IOException {
+    HoodieTableMetaClient metaClient = mock(HoodieTableMetaClient.class);
+    HoodieTableConfig tableConfig = mock(HoodieTableConfig.class);
+    when(metaClient.getTableConfig()).thenReturn(tableConfig);
+    when(tableConfig.getTableVersion()).thenReturn(HoodieTableVersion.EIGHT);
+    when(metaClient.getInstantGenerator()).thenReturn(new 
InstantGeneratorV2());
+
+    CommitMetadataSerDe serDe = mock(CommitMetadataSerDe.class);
+    when(serDe.<HoodieCommitMetadata>deserialize(any(HoodieInstant.class), 
any(InputStream.class),
+        any(BooleanSupplier.class), eq(HoodieCommitMetadata.class)))
+        .thenReturn(new HoodieCommitMetadata());
+    when(metaClient.getCommitMetadataSerDe()).thenReturn(serDe);
+    return metaClient;
+  }
+}

Reply via email to