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 29b42cbae382 refactor(reader): seal the shared merge path in
KeyBasedFileGroupRecordBuffer (#19415)
29b42cbae382 is described below
commit 29b42cbae382a27a157d46c1c10da382ac7c9cac
Author: Ranga Reddy <[email protected]>
AuthorDate: Fri Jul 31 15:37:05 2026 +0530
refactor(reader): seal the shared merge path in
KeyBasedFileGroupRecordBuffer (#19415)
* refactor(reader): seal the shared merge path in
KeyBasedFileGroupRecordBuffer
Mark processNextDataRecord and isPartialMergingEnabled as final so that
subclasses cannot replace the merge-then-store behavior shared by every
buffer in this hierarchy. Subclasses still choose the identifier a record
is buffered under, and still specialize block processing and base-record
advancement.
Adds a reflection-based regression test so the modifiers cannot be dropped
silently.
Closes #16920
* docs(reader): scope the sealed-method claim to what final actually
guarantees
Review feedback: the test name, javadoc and failure message claimed every
buffer funnels records through a single merge-then-put path. That is not
true
and the test did not check it. PositionBasedFileGroupRecordBuffer writes to
the protected records map directly when it re-keys entries in
fallbackToKeyBasedBuffer and when it overwrites delete markers under
COMMIT_TIME_ORDERING, and subclasses can still override block processing.
Narrow the claim instead of inventing the invariant: those two paths must
not
merge, so routing them through processNextDataRecord would change behaviour.
Renamed the test to sealedMethodsCannotBeOverridden, reworded its javadoc
and
message to the exact guarantee (the two methods keep their final modifier),
and corrected the same overstatement in the production javadoc, including
that
a subclass may still set enablePartialMerging while processing a data block.
No behaviour change.
---
.../read/buffer/KeyBasedFileGroupRecordBuffer.java | 20 ++++++++++++--
.../buffer/TestKeyBasedFileGroupRecordBuffer.java | 31 ++++++++++++++++++++++
2 files changed, 49 insertions(+), 2 deletions(-)
diff --git
a/hudi-common/src/main/java/org/apache/hudi/common/table/read/buffer/KeyBasedFileGroupRecordBuffer.java
b/hudi-common/src/main/java/org/apache/hudi/common/table/read/buffer/KeyBasedFileGroupRecordBuffer.java
index f40e5206e946..0238a7ed107c 100644
---
a/hudi-common/src/main/java/org/apache/hudi/common/table/read/buffer/KeyBasedFileGroupRecordBuffer.java
+++
b/hudi-common/src/main/java/org/apache/hudi/common/table/read/buffer/KeyBasedFileGroupRecordBuffer.java
@@ -98,8 +98,19 @@ public class KeyBasedFileGroupRecordBuffer<T> extends
FileGroupRecordBuffer<T> {
}
}
+ /**
+ * Merges the incoming record with whatever is already buffered under {@code
recordKey} and stores the
+ * result. Subclasses choose the identifier a record is buffered under (a
record key here, a record
+ * position in {@link PositionBasedFileGroupRecordBuffer}), but cannot
replace this merge-then-store
+ * step itself, hence the {@code final}.
+ *
+ * <p>This is not the only way {@code records} is mutated. The map is {@code
protected}, and
+ * {@link PositionBasedFileGroupRecordBuffer} writes to it directly where
merging would be wrong: when
+ * re-keying already-merged entries in its key-based fallback, and when
overwriting delete markers under
+ * commit-time ordering.
+ */
@Override
- public void processNextDataRecord(BufferedRecord<T> record, Serializable
recordKey) throws IOException {
+ public final void processNextDataRecord(BufferedRecord<T> record,
Serializable recordKey) throws IOException {
BufferedRecord<T> existingRecord = records.get(recordKey);
totalLogRecords++;
bufferedRecordMerger.deltaMerge(record,
existingRecord).ifPresent(bufferedRecord ->
@@ -139,7 +150,12 @@ public class KeyBasedFileGroupRecordBuffer<T> extends
FileGroupRecordBuffer<T> {
return hasNextLogRecord();
}
- public boolean isPartialMergingEnabled() {
+ /**
+ * Whether partial merging has been switched on for this buffer. Subclasses
cannot replace this accessor,
+ * hence the {@code final}; they may still set the underlying {@code
enablePartialMerging} flag while
+ * processing a data block, as {@link PositionBasedFileGroupRecordBuffer}
does.
+ */
+ public final boolean isPartialMergingEnabled() {
return enablePartialMerging;
}
}
diff --git
a/hudi-common/src/test/java/org/apache/hudi/common/table/read/buffer/TestKeyBasedFileGroupRecordBuffer.java
b/hudi-common/src/test/java/org/apache/hudi/common/table/read/buffer/TestKeyBasedFileGroupRecordBuffer.java
index 560e0020228b..b2138b9a495c 100644
---
a/hudi-common/src/test/java/org/apache/hudi/common/table/read/buffer/TestKeyBasedFileGroupRecordBuffer.java
+++
b/hudi-common/src/test/java/org/apache/hudi/common/table/read/buffer/TestKeyBasedFileGroupRecordBuffer.java
@@ -31,6 +31,7 @@ import org.apache.hudi.common.table.HoodieTableConfig;
import org.apache.hudi.common.table.HoodieTableVersion;
import org.apache.hudi.common.table.log.block.HoodieDataBlock;
import org.apache.hudi.common.table.log.block.HoodieDeleteBlock;
+import org.apache.hudi.common.table.read.BufferedRecord;
import org.apache.hudi.common.table.read.FileGroupReaderSchemaHandler;
import org.apache.hudi.common.table.read.HoodieReadStats;
import org.apache.hudi.common.util.Option;
@@ -42,6 +43,9 @@ import org.apache.avro.generic.IndexedRecord;
import org.junit.jupiter.api.Test;
import java.io.IOException;
+import java.io.Serializable;
+import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
@@ -50,6 +54,7 @@ import java.util.stream.Stream;
import static
org.apache.hudi.common.model.DefaultHoodieRecordPayload.DELETE_KEY;
import static
org.apache.hudi.common.model.DefaultHoodieRecordPayload.DELETE_MARKER;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
@@ -74,6 +79,32 @@ class TestKeyBasedFileGroupRecordBuffer extends
BaseTestFileGroupRecordBuffer {
private final IndexedRecord testRecord6DeleteByCustomMarker =
createTestRecord("6", 3, 2L);
private final IndexedRecord testRecord7 = createTestRecord("7", 1, 5L);
+ /**
+ * Asserts that {@code processNextDataRecord} and {@code
isPartialMergingEnabled} keep their
+ * {@code final} modifier, so a subclass cannot substitute its own
implementation of either.
+ *
+ * <p>That is the entire guarantee, and it is deliberately narrow. It does
<em>not</em> mean all buffer
+ * mutations go through {@code processNextDataRecord}: {@code records} and
{@code enablePartialMerging}
+ * are {@code protected}, and {@code PositionBasedFileGroupRecordBuffer}
legitimately writes to
+ * {@code records} directly when it re-keys entries in its key-based
fallback and when it overwrites
+ * delete markers under commit-time ordering, as well as overriding block
processing. Those paths must
+ * not merge, so routing them through this method would be wrong.
+ *
+ * <p>The modifier itself is compiler-enforced; this test exists only to
catch it being dropped.
+ */
+ @Test
+ void sealedMethodsCannotBeOverridden() throws NoSuchMethodException {
+ assertMethodIsFinal(KeyBasedFileGroupRecordBuffer.class
+ .getDeclaredMethod("processNextDataRecord", BufferedRecord.class,
Serializable.class));
+
assertMethodIsFinal(KeyBasedFileGroupRecordBuffer.class.getDeclaredMethod("isPartialMergingEnabled"));
+ }
+
+ private static void assertMethodIsFinal(Method method) {
+ assertTrue(Modifier.isFinal(method.getModifiers()),
+ () -> String.format("%s#%s must remain final so subclasses cannot
override it",
+ method.getDeclaringClass().getSimpleName(), method.getName()));
+ }
+
@Test
void readWithEventTimeOrdering() throws IOException {
HoodieReadStats readStats = new HoodieReadStats();