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

nsivabalan 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 4394513a8cb7 test(common): add log-format reader and scanner coverage 
(#19223)
4394513a8cb7 is described below

commit 4394513a8cb7a96d03e301255d6da39310d86b47
Author: Y Ethan Guo <[email protected]>
AuthorDate: Sun Jul 19 10:09:30 2026 -0700

    test(common): add log-format reader and scanner coverage (#19223)
    
    * test(common): add log-format reader and scanner coverage
    
    * test(common): fix scanner close/lifecycle and restrict BRAF tests to read 
path
    
    - Drop non-existent HoodieUnMergedLogRecordScanner.close() calls that
      caused Azure CI to fail compilation of hudi-hadoop-common tests.
    - Refactor TestBufferedRandomAccessFile to seed fixtures via Files.write
      and only exercise the read path. Production only opens the class in
      read mode ("r") (BitCaskDiskMap / LazyFileIterable). Exercising its
      write path across buffer boundaries hangs in an infinite loop (dormant
      writer-side bug in expandBufferToCapacityIfNeeded not addressed here).
    
    ---------
    
    Co-authored-by: sivabalan <[email protected]>
    Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
---
 .../common/util/TestBufferedRandomAccessFile.java  | 132 ++++++++++++++
 .../common/functional/TestHoodieLogFormat.java     | 202 +++++++++++++++++++++
 2 files changed, 334 insertions(+)

diff --git 
a/hudi-common/src/test/java/org/apache/hudi/common/util/TestBufferedRandomAccessFile.java
 
b/hudi-common/src/test/java/org/apache/hudi/common/util/TestBufferedRandomAccessFile.java
new file mode 100644
index 000000000000..5dc1fab9c86d
--- /dev/null
+++ 
b/hudi-common/src/test/java/org/apache/hudi/common/util/TestBufferedRandomAccessFile.java
@@ -0,0 +1,132 @@
+/*
+ * 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.util;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.File;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Random;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * Unit tests for {@link BufferedRandomAccessFile}, the buffered wrapper used 
to
+ * back the log-file read path (via BitCaskDiskMap / LazyFileIterable). The 
buffer
+ * capacity is clamped to a 64K minimum, so the payloads here are deliberately
+ * larger than that to force reads and seeks that cross buffer boundaries.
+ *
+ * <p>All fixtures are seeded via {@link Files#write} rather than the class's 
own
+ * write path since Hudi opens this class only in read mode ("r") in 
production.
+ */
+public class TestBufferedRandomAccessFile {
+
+  // Larger than the internal 64K minimum buffer so that access crosses buffer 
boundaries.
+  private static final int PAYLOAD_SIZE = (1 << 16) * 3 + 517;
+
+  private static byte[] deterministicBytes(int size) {
+    byte[] bytes = new byte[size];
+    new Random(42).nextBytes(bytes);
+    return bytes;
+  }
+
+  private static File seedFile(Path dir, String name, byte[] contents) throws 
Exception {
+    File file = new File(dir.toFile(), name);
+    Files.write(file.toPath(), contents);
+    return file;
+  }
+
+  @Test
+  public void testReadFullyAcrossBufferBoundaries(@TempDir Path tempDir) 
throws Exception {
+    byte[] expected = deterministicBytes(PAYLOAD_SIZE);
+    File file = seedFile(tempDir, "read.bin", expected);
+
+    byte[] readBack = new byte[PAYLOAD_SIZE];
+    try (BufferedRandomAccessFile raf = new BufferedRandomAccessFile(file, 
"r")) {
+      // readFully loops over read(...) so partial reads at buffer boundaries 
are handled.
+      raf.readFully(readBack);
+      assertEquals(PAYLOAD_SIZE, raf.getFilePointer(), "Read should consume 
the whole file");
+      assertEquals(-1, raf.read(), "Reading past EOF should return -1");
+    }
+    assertArrayEquals(expected, readBack, "Bytes read back should match bytes 
written");
+  }
+
+  @Test
+  public void testSeekRandomAccessCrossingBoundaries(@TempDir Path tempDir) 
throws Exception {
+    byte[] expected = deterministicBytes(PAYLOAD_SIZE);
+    File file = seedFile(tempDir, "seek.bin", expected);
+
+    try (BufferedRandomAccessFile raf = new BufferedRandomAccessFile(file, 
"r")) {
+      // Probe positions that fall in the first, second, third and last 
logical buffer blocks,
+      // out of order, so both forward and backward seeks are exercised.
+      int[] probes = {0, (1 << 16) + 7, (1 << 16) * 2 + 100, 5, PAYLOAD_SIZE - 
1};
+      for (int pos : probes) {
+        raf.seek(pos);
+        assertEquals(pos, raf.getFilePointer(), "getFilePointer should reflect 
the seek target");
+        int actual = raf.read();
+        assertEquals(expected[pos] & 0xFF, actual, "Byte at position " + pos + 
" should match");
+      }
+
+      // A ranged read that starts mid-buffer and spans a boundary must return 
the correct slice.
+      int start = (1 << 16) - 10;
+      int len = 40;
+      raf.seek(start);
+      byte[] slice = new byte[len];
+      raf.readFully(slice);
+      byte[] expectedSlice = new byte[len];
+      System.arraycopy(expected, start, expectedSlice, 0, len);
+      assertArrayEquals(expectedSlice, slice, "Ranged read across a buffer 
boundary should match");
+    }
+  }
+
+  @Test
+  public void testReadIntoOffsetAndLength(@TempDir Path tempDir) throws 
Exception {
+    byte[] expected = deterministicBytes(1024);
+    File file = seedFile(tempDir, "offset.bin", expected);
+
+    try (BufferedRandomAccessFile raf = new BufferedRandomAccessFile(file, 
"r")) {
+      byte[] target = new byte[1024 + 8];
+      // Leave a 4-byte prefix untouched and read the payload into the middle 
of the array.
+      raf.readFully(target, 4, 1024);
+      byte[] payload = new byte[1024];
+      System.arraycopy(target, 4, payload, 0, 1024);
+      assertArrayEquals(expected, payload, "readFully into an offset should 
place bytes at that offset");
+      assertEquals(0, target[0], "Bytes before the offset must remain 
untouched");
+      assertEquals(0, target[3], "Byte just before the offset must remain 
untouched");
+      assertEquals(0, target[1028], "Byte just after the range must remain 
untouched");
+      assertEquals(0, target[1031], "Last byte after the range must remain 
untouched");
+    }
+  }
+
+  @Test
+  public void testLengthAndEofAfterFullRead(@TempDir Path tempDir) throws 
Exception {
+    byte[] expected = deterministicBytes(PAYLOAD_SIZE);
+    File file = seedFile(tempDir, "length.bin", expected);
+
+    try (BufferedRandomAccessFile raf = new BufferedRandomAccessFile(file, 
"r")) {
+      assertEquals(PAYLOAD_SIZE, raf.length(), "length should report the 
on-disk size");
+      raf.seek(PAYLOAD_SIZE);
+      assertEquals(-1, raf.read(), "Reading at EOF should return -1");
+      assertEquals(PAYLOAD_SIZE, raf.getFilePointer(), "File pointer should 
stay at EOF");
+    }
+  }
+}
diff --git 
a/hudi-hadoop-common/src/test/java/org/apache/hudi/common/functional/TestHoodieLogFormat.java
 
b/hudi-hadoop-common/src/test/java/org/apache/hudi/common/functional/TestHoodieLogFormat.java
index f4b3732a17a9..ca7893266062 100755
--- 
a/hudi-hadoop-common/src/test/java/org/apache/hudi/common/functional/TestHoodieLogFormat.java
+++ 
b/hudi-hadoop-common/src/test/java/org/apache/hudi/common/functional/TestHoodieLogFormat.java
@@ -47,6 +47,8 @@ import org.apache.hudi.common.table.log.HoodieLogFormat;
 import org.apache.hudi.common.table.log.HoodieLogFormat.Reader;
 import org.apache.hudi.common.table.log.HoodieLogFormatWriter;
 import org.apache.hudi.common.table.log.HoodieMergedLogRecordScanner;
+import org.apache.hudi.common.table.log.HoodieUnMergedLogRecordScanner;
+import org.apache.hudi.common.table.log.InstantRange;
 import org.apache.hudi.common.table.log.TestLogReaderUtils;
 import org.apache.hudi.common.table.log.block.HoodieAvroDataBlock;
 import org.apache.hudi.common.table.log.block.HoodieCommandBlock;
@@ -3039,6 +3041,206 @@ public class TestHoodieLogFormat extends 
HoodieCommonTestHarness {
     assertEquals(expectedPositions, new 
HashSet<>(dataBlock.getRecordPositionList()));
   }
 
+  @Test
+  public void testUnMergedLogRecordScannerCallbacksWithDeletes()
+      throws IOException, URISyntaxException, InterruptedException {
+    HoodieSchema schema = 
HoodieSchemaUtils.addMetadataFields(getSimpleSchema());
+    HoodieLogFormat.Writer writer =
+        HoodieLogFormatWriter.builder().withParentPath(partitionPath)
+            .withFileExtension(HoodieLogFile.DELTA_EXTENSION)
+            .withLogFileId("test-fileid1")
+            .withInstantTime("100")
+            .withStorage(storage)
+            .build();
+
+    SchemaTestUtil testUtil = new SchemaTestUtil();
+    List<IndexedRecord> records = testUtil.generateHoodieTestRecords(0, 100);
+    List<IndexedRecord> copyOfRecords = records.stream()
+        .map(record -> HoodieAvroUtils.rewriteRecord((GenericRecord) record, 
schema.toAvroSchema()))
+        .collect(Collectors.toList());
+    Map<HoodieLogBlock.HeaderMetadataType, String> header = new HashMap<>();
+    header.put(HoodieLogBlock.HeaderMetadataType.INSTANT_TIME, "100");
+    header.put(HoodieLogBlock.HeaderMetadataType.SCHEMA, schema.toString());
+    writer.appendBlock(getDataBlock(DEFAULT_DATA_BLOCK_TYPE, records, header));
+
+    // Delete the first 20 keys via a delete block.
+    List<String> allKeys = copyOfRecords.stream()
+        .map(r -> ((GenericRecord) 
r).get(HoodieRecord.RECORD_KEY_METADATA_FIELD).toString())
+        .collect(Collectors.toList());
+    List<String> deletedKeys = new ArrayList<>(allKeys.subList(0, 20));
+    List<Pair<DeleteRecord, Long>> deleteRecordList = copyOfRecords.subList(0, 
20).stream()
+        .map(r -> Pair.of(DeleteRecord.create(
+                ((GenericRecord) 
r).get(HoodieRecord.RECORD_KEY_METADATA_FIELD).toString(),
+                ((GenericRecord) 
r).get(HoodieRecord.PARTITION_PATH_METADATA_FIELD).toString()),
+            -1L))
+        .collect(Collectors.toList());
+    header.put(HoodieLogBlock.HeaderMetadataType.INSTANT_TIME, "100");
+    writer.appendBlock(new HoodieDeleteBlock(deleteRecordList, header));
+    writer.close();
+
+    FileCreateUtilsLegacy.createDeltaCommit(basePath, "100", storage);
+
+    List<String> insertedKeys = new ArrayList<>();
+    List<String> deletedKeysSeen = new ArrayList<>();
+    HoodieUnMergedLogRecordScanner scanner = 
HoodieUnMergedLogRecordScanner.newBuilder()
+        .withStorage(storage)
+        .withBasePath(basePath)
+        
.withLogFilePaths(Collections.singletonList(writer.getLogFile().getPath().toString()))
+        .withReaderSchema(schema)
+        .withLatestInstantTime("100")
+        .withReverseReader(false)
+        .withBufferSize(BUFFER_SIZE)
+        .withLogRecordScannerCallback(record -> 
insertedKeys.add(record.getRecordKey()))
+        .withRecordDeletionCallback(key -> 
deletedKeysSeen.add(key.getRecordKey()))
+        .build();
+    scanner.scan();
+
+    // The un-merged scanner streams every data record and every deleted key 
through the callbacks
+    // without merging them, so all 100 inserts and all 20 deletes should be 
observed exactly once.
+    assertEquals(100, insertedKeys.size(), "Callback should see every appended 
data record");
+    assertEquals(new HashSet<>(allKeys), new HashSet<>(insertedKeys),
+        "Callback keys should match every appended record key");
+    assertEquals(20, deletedKeysSeen.size(), "Deletion callback should see 
every deleted key");
+    assertEquals(new HashSet<>(deletedKeys), new HashSet<>(deletedKeysSeen),
+        "Deletion callback keys should match the delete block keys");
+  }
+
+  @Test
+  public void testUnMergedLogRecordScannerInstantRangeFiltering()
+      throws IOException, URISyntaxException, InterruptedException {
+    HoodieSchema schema = 
HoodieSchemaUtils.addMetadataFields(getSimpleSchema());
+    HoodieLogFormat.Writer writer =
+        HoodieLogFormatWriter.builder().withParentPath(partitionPath)
+            .withFileExtension(HoodieLogFile.DELTA_EXTENSION)
+            .withLogFileId("test-fileid1")
+            .withInstantTime("100")
+            .withStorage(storage)
+            .build();
+
+    SchemaTestUtil testUtil = new SchemaTestUtil();
+    // First block belongs to instant 100, second block to instant 101.
+    List<IndexedRecord> recordsAt100 = testUtil.generateHoodieTestRecords(0, 
60);
+    List<String> keysAt100 = recordsAt100.stream()
+        .map(record -> HoodieAvroUtils.rewriteRecord((GenericRecord) record, 
schema.toAvroSchema()))
+        .map(r -> ((GenericRecord) 
r).get(HoodieRecord.RECORD_KEY_METADATA_FIELD).toString())
+        .collect(Collectors.toList());
+    Map<HoodieLogBlock.HeaderMetadataType, String> header = new HashMap<>();
+    header.put(HoodieLogBlock.HeaderMetadataType.INSTANT_TIME, "100");
+    header.put(HoodieLogBlock.HeaderMetadataType.SCHEMA, schema.toString());
+    writer.appendBlock(getDataBlock(DEFAULT_DATA_BLOCK_TYPE, recordsAt100, 
header));
+
+    List<IndexedRecord> recordsAt101 = testUtil.generateHoodieTestRecords(0, 
40);
+    header.put(HoodieLogBlock.HeaderMetadataType.INSTANT_TIME, "101");
+    writer.appendBlock(getDataBlock(DEFAULT_DATA_BLOCK_TYPE, recordsAt101, 
header));
+    writer.close();
+
+    FileCreateUtilsLegacy.createDeltaCommit(basePath, "100", storage);
+    FileCreateUtilsLegacy.createDeltaCommit(basePath, "101", storage);
+
+    // A closed range of [100, 100] should keep only the first block and drop 
the instant 101 block.
+    InstantRange instantRange = InstantRange.builder()
+        .startInstant("100")
+        .endInstant("100")
+        .rangeType(InstantRange.RangeType.CLOSED_CLOSED)
+        .build();
+
+    List<String> seenKeys = new ArrayList<>();
+    HoodieUnMergedLogRecordScanner scanner = 
HoodieUnMergedLogRecordScanner.newBuilder()
+        .withStorage(storage)
+        .withBasePath(basePath)
+        
.withLogFilePaths(Collections.singletonList(writer.getLogFile().getPath().toString()))
+        .withReaderSchema(schema)
+        .withLatestInstantTime("101")
+        .withReverseReader(false)
+        .withBufferSize(BUFFER_SIZE)
+        .withInstantRange(Option.of(instantRange))
+        .withLogRecordScannerCallback(record -> 
seenKeys.add(record.getRecordKey()))
+        .build();
+    scanner.scan();
+
+    assertEquals(60, seenKeys.size(), "Only the instant 100 block should pass 
the instant range");
+    assertEquals(new HashSet<>(keysAt100), new HashSet<>(seenKeys),
+        "Records outside the instant range should be filtered out");
+  }
+
+  @Test
+  public void testLogFileReaderReadsPastCorruptBlock()
+      throws IOException, URISyntaxException, InterruptedException {
+    HoodieSchema schema = getSimpleSchema();
+    HoodieLogFormat.Writer writer =
+        HoodieLogFormatWriter.builder().withParentPath(partitionPath)
+            .withFileExtension(HoodieLogFile.DELTA_EXTENSION)
+            .withLogFileId("test-fileid1")
+            .withInstantTime("100")
+            .withStorage(storage)
+            .build();
+    List<IndexedRecord> records1 = SchemaTestUtil.generateTestRecords(0, 100);
+    List<IndexedRecord> copyOfRecords1 = records1.stream()
+        .map(record -> HoodieAvroUtils.rewriteRecord((GenericRecord) record, 
schema.toAvroSchema()))
+        .collect(Collectors.toList());
+    Map<HoodieLogBlock.HeaderMetadataType, String> header = new HashMap<>();
+    header.put(HoodieLogBlock.HeaderMetadataType.INSTANT_TIME, "100");
+    header.put(HoodieLogBlock.HeaderMetadataType.SCHEMA, schema.toString());
+    writer.appendBlock(getDataBlock(DEFAULT_DATA_BLOCK_TYPE, records1, 
header));
+    writer.close();
+
+    // Append a block whose declared length does not match its content, 
mimicking a partial write.
+    FSDataOutputStream outputStream = (FSDataOutputStream) 
storage.append(writer.getLogFile().getPath());
+    outputStream.write(HoodieLogFormat.MAGIC);
+    outputStream.writeLong(474);
+    outputStream.writeInt(HoodieLogBlockType.AVRO_DATA_BLOCK.ordinal());
+    outputStream.writeInt(HoodieLogFormat.INLINE_LOG_FORMAT_VERSION);
+    outputStream.writeLong(400);
+    outputStream.write(getUTF8Bytes("truncated-block-content"));
+    outputStream.flush();
+    outputStream.close();
+
+    // Append a valid trailing block so the reader has a real block to recover 
to after the corruption.
+    HoodieLogFormat.Writer appendWriter =
+        HoodieLogFormatWriter.builder().withParentPath(partitionPath)
+            .withFileExtension(HoodieLogFile.DELTA_EXTENSION)
+            .withLogFileId("test-fileid1")
+            .withInstantTime("100")
+            .withStorage(storage)
+            .build();
+    ((HoodieLogFormatWriter) appendWriter).withOutputStream(
+        (FSDataOutputStream) storage.append(writer.getLogFile().getPath()));
+    List<IndexedRecord> records2 = SchemaTestUtil.generateTestRecords(0, 10);
+    List<IndexedRecord> copyOfRecords2 = records2.stream()
+        .map(record -> HoodieAvroUtils.rewriteRecord((GenericRecord) record, 
schema.toAvroSchema()))
+        .collect(Collectors.toList());
+    appendWriter.appendBlock(getDataBlock(DEFAULT_DATA_BLOCK_TYPE, records2, 
header));
+    appendWriter.close();
+
+    HoodieLogFile logFile = new 
HoodieLogFile(appendWriter.getLogFile().getPath(),
+        storage.getPathInfo(appendWriter.getLogFile().getPath()).getLength());
+    try (HoodieLogFileReader reader =
+             new HoodieLogFileReader(storage, logFile, 
SchemaTestUtil.getSimpleSchema(), BUFFER_SIZE)) {
+      // First a valid data block.
+      assertTrue(reader.hasNext(), "First data block should be available");
+      HoodieLogBlock firstBlock = reader.next();
+      assertEquals(HoodieLogBlockType.AVRO_DATA_BLOCK, 
firstBlock.getBlockType(), "First block should be a data block");
+      List<IndexedRecord> firstRead = getRecords((HoodieDataBlock) firstBlock);
+      assertEquals(convertAvroToSerializableIndexedRecords(copyOfRecords1), 
firstRead,
+          "First block contents should match the written records");
+
+      // The reader seeks past the bad magic/length and surfaces a corrupt 
block.
+      assertTrue(reader.hasNext(), "Corrupt block should be surfaced");
+      HoodieLogBlock corruptBlock = reader.next();
+      assertEquals(HoodieLogBlockType.CORRUPT_BLOCK, 
corruptBlock.getBlockType(), "Second block should be a corrupt block");
+
+      // The valid trailing block should still be readable after recovery.
+      assertTrue(reader.hasNext(), "Trailing data block should be available 
after the corrupt block");
+      HoodieLogBlock lastBlock = reader.next();
+      assertEquals(HoodieLogBlockType.AVRO_DATA_BLOCK, 
lastBlock.getBlockType(), "Third block should be a data block");
+      List<IndexedRecord> lastRead = getRecords((HoodieDataBlock) lastBlock);
+      assertEquals(convertAvroToSerializableIndexedRecords(copyOfRecords2), 
lastRead,
+          "Trailing block contents should match the written records");
+
+      assertFalse(reader.hasNext(), "There should be no more blocks");
+    }
+  }
+
   private static Stream<Arguments> testArguments() {
     // Arg1: ExternalSpillableMap Type, Arg2: isDiskMapCompressionEnabled
     return Stream.of(

Reply via email to