This is an automated email from the ASF dual-hosted git repository.
SaketaChalamchala pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/ozone.git
The following commit(s) were added to refs/heads/master by this push:
new 4f5ae5454fe HDDS-15388. K way merge iterator over native SST file
reader to emit latest tombstone and latest value of a key. (#10484)
4f5ae5454fe is described below
commit 4f5ae5454fef0ecb9fbec8750af6204f4ee28540
Author: SaketaChalamchala <[email protected]>
AuthorDate: Fri Jul 24 13:06:44 2026 -0700
HDDS-15388. K way merge iterator over native SST file reader to emit latest
tombstone and latest value of a key. (#10484)
---
.../dev-support/findbugsExcludeFile.xml | 9 +
.../utils/db/LatestVersionedKWayMergeIterator.java | 504 +++++++++++++++++++++
.../db/TestLatestVersionedKWayMergeIterator.java | 253 +++++++++++
...estLatestVersionedKWayMergeIteratorOverSst.java | 204 +++++++++
.../hdds/utils/db/TestRawSstFileRecords.java | 121 +++++
5 files changed, 1091 insertions(+)
diff --git a/hadoop-hdds/rocks-native/dev-support/findbugsExcludeFile.xml
b/hadoop-hdds/rocks-native/dev-support/findbugsExcludeFile.xml
index 40d78d0cd6c..9b97abe8e46 100644
--- a/hadoop-hdds/rocks-native/dev-support/findbugsExcludeFile.xml
+++ b/hadoop-hdds/rocks-native/dev-support/findbugsExcludeFile.xml
@@ -15,4 +15,13 @@
limitations under the License.
-->
<FindBugsFilter>
+ <!-- MergedKeyValue owns key/value arrays copied once at emit time; callers
must not mutate. -->
+ <Match>
+ <Class
name="org.apache.hadoop.hdds.utils.db.LatestVersionedKWayMergeIterator$MergedKeyValue"/>
+ <Or>
+ <Method name="getUserKey"/>
+ <Method name="getValue"/>
+ </Or>
+ <Bug pattern="EI_EXPOSE_REP"/>
+ </Match>
</FindBugsFilter>
diff --git
a/hadoop-hdds/rocks-native/src/main/java/org/apache/hadoop/hdds/utils/db/LatestVersionedKWayMergeIterator.java
b/hadoop-hdds/rocks-native/src/main/java/org/apache/hadoop/hdds/utils/db/LatestVersionedKWayMergeIterator.java
new file mode 100644
index 00000000000..fc072c7fde6
--- /dev/null
+++
b/hadoop-hdds/rocks-native/src/main/java/org/apache/hadoop/hdds/utils/db/LatestVersionedKWayMergeIterator.java
@@ -0,0 +1,504 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hadoop.hdds.utils.db;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.primitives.UnsignedLong;
+import java.nio.ByteBuffer;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.List;
+import java.util.NoSuchElementException;
+import java.util.Objects;
+import java.util.PriorityQueue;
+import org.apache.hadoop.hdds.utils.IOUtils;
+import org.apache.hadoop.hdds.utils.db.managed.ManagedOptions;
+import org.apache.hadoop.ozone.util.ClosableIterator;
+
+/**
+ * K-way merge over RocksDB SST files for snapshot diff.
+ * <p>
+ * A single min-heap orders source heads by user key. For each user key, all
versions
+ * are drained and the latest value and latest tombstone are tracked, then
snapshot-diff
+ * emit rules apply: emit the latest tombstone and/or latest value, including
both when
+ * a delete is followed by a newer recreate.
+ * <p>
+ * When constructed with an exclusive minimum sequence number {@code S}, each
SST
+ * source skips entries whose sequence is {@code <= S} while advancing.
+ */
+public final class LatestVersionedKWayMergeIterator implements
+ ClosableIterator<LatestVersionedKWayMergeIterator.MergedKeyValue> {
+
+ /** RocksDB {@code ValueType::kTypeValue}. */
+ public static final int ROCKS_TYPE_VALUE = 1;
+ private static final int DEFAULT_READ_AHEAD_SIZE = 2 * 1024 * 1024;
+
+ private final ManagedOptions options;
+ private final List<ClosableIterator<? extends MergeHead>> iterators;
+ private final Long exclusiveMinSequenceNumber;
+
+ private final PriorityQueue<HeapEntry> sourceHeads;
+
+ private List<MergedKeyValue> emitQueue;
+ private boolean initialized;
+
+ public static LatestVersionedKWayMergeIterator
overRawSstFiles(Collection<Path> sstFiles) {
+ return overRawSstFiles(sstFiles, DEFAULT_READ_AHEAD_SIZE, null);
+ }
+
+ public static LatestVersionedKWayMergeIterator
overRawSstFiles(Collection<Path> sstFiles,
+ int readAheadSizePerFile) {
+ return overRawSstFiles(sstFiles, readAheadSizePerFile, null);
+ }
+
+ /**
+ * Opens one iterator per SST file and merges them.
+ *
+ * @param exclusiveMinSequenceNumber when non-null, each source skips
entries with
+ * sequence {@code <=} this value while advancing; when null, no
entries are skipped
+ */
+ public static LatestVersionedKWayMergeIterator
overRawSstFiles(Collection<Path> sstFiles,
+ int readAheadSizePerFile, Long exclusiveMinSequenceNumber) {
+ Objects.requireNonNull(sstFiles, "sstFiles cannot be null");
+ ManagedOptions options = new ManagedOptions();
+ List<ClosableIterator<? extends MergeHead>> sources = new
ArrayList<>(sstFiles.size());
+ try {
+ for (Path file : sstFiles) {
+ sources.add(new RawSstIterator(options, file, readAheadSizePerFile));
+ }
+ return new LatestVersionedKWayMergeIterator(options, sources,
exclusiveMinSequenceNumber);
+ } catch (RuntimeException e) {
+ IOUtils.closeQuietly(sources);
+ options.close();
+ throw e;
+ }
+ }
+
+ public static LatestVersionedKWayMergeIterator
overRawSstFilesFromSequence(Collection<Path> sstFiles,
+ long exclusiveMinSequenceNumber) {
+ return overRawSstFiles(sstFiles, DEFAULT_READ_AHEAD_SIZE,
exclusiveMinSequenceNumber);
+ }
+
+ @VisibleForTesting
+ public static LatestVersionedKWayMergeIterator forTest(
+ List<ClosableIterator<MergedKeyValue>> iterators, Long
exclusiveMinSequenceNumber) {
+ List<ClosableIterator<? extends MergeHead>> sources = new
ArrayList<>(iterators.size());
+ sources.addAll(iterators);
+ return new LatestVersionedKWayMergeIterator(null, sources,
exclusiveMinSequenceNumber);
+ }
+
+ private LatestVersionedKWayMergeIterator(
+ ManagedOptions options,
+ List<ClosableIterator<? extends MergeHead>> iterators,
+ Long exclusiveMinSequenceNumber) {
+ this.options = options;
+ this.iterators = new ArrayList<>(Objects.requireNonNull(iterators,
"iterators cannot be null"));
+ this.exclusiveMinSequenceNumber = exclusiveMinSequenceNumber;
+ this.sourceHeads = new PriorityQueue<>(Math.max(this.iterators.size(), 1));
+ this.emitQueue = new ArrayList<>();
+ }
+
+ @Override
+ public boolean hasNext() {
+ if (!emitQueue.isEmpty()) {
+ return true;
+ }
+ return advance();
+ }
+
+ @Override
+ public MergedKeyValue next() {
+ if (!hasNext()) {
+ throw new NoSuchElementException("No more elements found.");
+ }
+ return emitQueue.remove(0);
+ }
+
+ private boolean advance() {
+ if (!initialized) {
+ initHeap();
+ initialized = true;
+ }
+
+ while (emitQueue.isEmpty() && !sourceHeads.isEmpty()) {
+ processNextUserKey();
+ }
+
+ return !emitQueue.isEmpty();
+ }
+
+ private void processNextUserKey() {
+ if (sourceHeads.isEmpty()) {
+ return;
+ }
+
+ byte[] nextKey = sourceHeads.peek().current.getUserKey();
+ MergeHead latestValue = null;
+ long latestValueSeq = -1L;
+ MergeHead latestTombstone = null;
+ long latestTombstoneSeq = -1L;
+
+ while (heapHasUserKey(nextKey)) {
+ List<HeapEntry> polled = new ArrayList<>();
+ while (heapHasUserKey(nextKey)) {
+ HeapEntry entry = sourceHeads.poll();
+ MergeHead head = entry.current;
+ if (head.isTombstone()) {
+ if (head.getSequence() > latestTombstoneSeq) {
+ latestTombstone = head;
+ latestTombstoneSeq = head.getSequence();
+ }
+ } else if (head.getSequence() > latestValueSeq) {
+ latestValue = head;
+ latestValueSeq = head.getSequence();
+ }
+ polled.add(entry);
+ }
+ for (HeapEntry entry : polled) {
+ if (entry.current == latestValue && entry.current instanceof
RawSstHeapHead) {
+ ((RawSstHeapHead) entry.current).snapshotValue();
+ }
+ entry.advance();
+ if (entry.current != null) {
+ sourceHeads.offer(entry);
+ }
+ }
+ }
+
+ emitForUserKey(latestValue, latestTombstone);
+ }
+
+ private void emitForUserKey(MergeHead latestValue, MergeHead
latestTombstone) {
+ if (latestValue != null && latestTombstone != null) {
+ if (latestValue.getSequence() > latestTombstone.getSequence()) {
+ emitQueue.add(latestTombstone.toMergedKeyValue());
+ emitQueue.add(latestValue.toMergedKeyValue());
+ } else {
+ emitQueue.add(latestTombstone.toMergedKeyValue());
+ }
+ } else if (latestValue != null) {
+ emitQueue.add(latestValue.toMergedKeyValue());
+ } else if (latestTombstone != null) {
+ emitQueue.add(latestTombstone.toMergedKeyValue());
+ }
+ }
+
+ private boolean heapHasUserKey(byte[] userKey) {
+ return !sourceHeads.isEmpty()
+ && compareUserKeys(sourceHeads.peek().current.getUserKey(), userKey)
== 0;
+ }
+
+ private void initHeap() {
+ for (int idx = 0; idx < iterators.size(); idx++) {
+ ClosableIterator<? extends MergeHead> iterator = iterators.get(idx);
+ HeapEntry entry = new HeapEntry(idx, iterator);
+ if (entry.current != null) {
+ sourceHeads.offer(entry);
+ }
+ }
+ }
+
+ @Override
+ public void close() {
+ IOUtils.closeQuietly(iterators);
+ if (options != null) {
+ options.close();
+ }
+ }
+
+ private static int compareUserKeys(byte[] left, byte[] right) {
+ if (left == right) {
+ return 0;
+ }
+ if (left == null) {
+ return -1;
+ }
+ if (right == null) {
+ return 1;
+ }
+ int minLength = Math.min(left.length, right.length);
+ for (int i = 0; i < minLength; i++) {
+ int l = left[i] & 0xff;
+ int r = right[i] & 0xff;
+ if (l != r) {
+ return Integer.compare(l, r);
+ }
+ }
+ return Integer.compare(left.length, right.length);
+ }
+
+ private static int compareHeapOrder(MergeHead left, int leftIndex,
+ MergeHead right, int rightIndex) {
+ int keyCompare = compareUserKeys(left.getUserKey(), right.getUserKey());
+ if (keyCompare != 0) {
+ return keyCompare;
+ }
+ int seqCompare = Long.compare(right.getSequence(), left.getSequence());
+ if (seqCompare != 0) {
+ return seqCompare;
+ }
+ int tombstoneCompare = Boolean.compare(right.isTombstone(),
left.isTombstone());
+ if (tombstoneCompare != 0) {
+ return tombstoneCompare;
+ }
+ return Integer.compare(leftIndex, rightIndex);
+ }
+
+ private interface MergeHead {
+ byte[] getUserKey();
+
+ long getSequence();
+
+ boolean isTombstone();
+
+ MergedKeyValue toMergedKeyValue();
+ }
+
+ private static final class RawSstHeapHead implements MergeHead {
+ private final byte[] key;
+ private final long sequence;
+ private final int type;
+ private final CodecBuffer valueBuffer;
+ private byte[] snapshottedValue;
+
+ RawSstHeapHead(byte[] key, long sequence, int type, CodecBuffer
valueBuffer) {
+ this.key = Objects.requireNonNull(key, "key cannot be null");
+ this.sequence = sequence;
+ this.type = type;
+ this.valueBuffer = valueBuffer;
+ }
+
+ void snapshotValue() {
+ if (snapshottedValue != null || isTombstone() || valueBuffer == null) {
+ return;
+ }
+ snapshottedValue = copyBuffer(valueBuffer);
+ }
+
+ @Override
+ public byte[] getUserKey() {
+ return key;
+ }
+
+ @Override
+ public long getSequence() {
+ return sequence;
+ }
+
+ @Override
+ public boolean isTombstone() {
+ return type != ROCKS_TYPE_VALUE;
+ }
+
+ @Override
+ public MergedKeyValue toMergedKeyValue() {
+ byte[] value = snapshottedValue;
+ if (value == null && !isTombstone() && valueBuffer != null) {
+ value = copyBuffer(valueBuffer);
+ }
+ return new MergedKeyValue(key, UnsignedLong.fromLongBits(sequence),
type, value);
+ }
+ }
+
+ private final class HeapEntry implements Comparable<HeapEntry> {
+ private final int index;
+ private final ClosableIterator<? extends MergeHead> iterator;
+ private MergeHead current;
+
+ private HeapEntry(int index, ClosableIterator<? extends MergeHead>
iterator) {
+ this.index = index;
+ this.iterator = iterator;
+ advance();
+ }
+
+ private void advance() {
+ while (true) {
+ if (!iterator.hasNext()) {
+ current = null;
+ iterator.close();
+ return;
+ }
+ MergeHead next = iterator.next();
+ if (exclusiveMinSequenceNumber == null
+ || next.getSequence() > exclusiveMinSequenceNumber) {
+ current = next;
+ return;
+ }
+ }
+ }
+
+ @Override
+ public int compareTo(HeapEntry other) {
+ return compareHeapOrder(
+ this.current, this.index, other.current, other.index);
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (!(o instanceof HeapEntry)) {
+ return false;
+ }
+ HeapEntry other = (HeapEntry) o;
+ return index == other.index;
+ }
+
+ @Override
+ public int hashCode() {
+ return index;
+ }
+ }
+
+ private static final class RawSstIterator implements
ClosableIterator<RawSstHeapHead> {
+ private final ManagedRawSSTFileReader reader;
+ private final
ManagedRawSSTFileIterator<ManagedRawSSTFileIterator.KeyValue> iterator;
+ private boolean closed;
+
+ private RawSstIterator(ManagedOptions options, Path file, int
readAheadSize) {
+ ManagedRawSSTFileReader openedReader = new ManagedRawSSTFileReader(
+ options, file.toAbsolutePath().toString(), readAheadSize);
+ ManagedRawSSTFileIterator<ManagedRawSSTFileIterator.KeyValue>
openedIterator;
+ try {
+ openedIterator = openedReader.newIterator(
+ kv -> kv, null, null, IteratorType.KEY_AND_VALUE);
+ } catch (RuntimeException e) {
+ openedReader.close();
+ throw e;
+ }
+ this.reader = openedReader;
+ this.iterator = openedIterator;
+ }
+
+ @Override
+ public boolean hasNext() {
+ return iterator.hasNext();
+ }
+
+ @Override
+ public RawSstHeapHead next() {
+ ManagedRawSSTFileIterator.KeyValue keyValue = iterator.next();
+ int type = keyValue.getType();
+ CodecBuffer valueBuffer = type == ROCKS_TYPE_VALUE ? keyValue.getValue()
: null;
+ return new RawSstHeapHead(
+ copyBuffer(keyValue.getKey()),
+ keyValue.getSequence().longValue(),
+ type,
+ valueBuffer);
+ }
+
+ @Override
+ public void close() {
+ if (closed) {
+ return;
+ }
+ closed = true;
+ iterator.close();
+ reader.close();
+ }
+ }
+
+ /**
+ * A merged RocksDB internal key after applying snapshot-diff emit rules
across
+ * multiple SST sources.
+ * <p>
+ * Each instance represents one emitted version for a user key: either a
value
+ * ({@link #ROCKS_TYPE_VALUE}) or a tombstone (any other RocksDB value type).
+ * When the latest value has a higher sequence than the latest tombstone for
+ * the same user key, both are emitted (delete followed by recreate).
Otherwise
+ * only the winning tombstone or value is emitted.
+ * <p>
+ * Key and value arrays are owned by this object. Callers must treat returned
+ * {@code byte[]} references as read-only; snapshot-diff integration should
+ * consume them once without retaining mutable aliases.
+ */
+ public static final class MergedKeyValue implements MergeHead {
+ private final byte[] key;
+ private final UnsignedLong sequence;
+ private final int type;
+ private final byte[] value;
+
+ /** Creates a merged entry for unit tests. */
+ @VisibleForTesting
+ public static MergedKeyValue of(byte[] key, long sequence, int type,
byte[] value) {
+ return new MergedKeyValue(
+ Arrays.copyOf(key, key.length),
+ UnsignedLong.fromLongBits(sequence),
+ type,
+ value == null ? null : Arrays.copyOf(value, value.length));
+ }
+
+ private MergedKeyValue(byte[] key, UnsignedLong sequence, int type, byte[]
value) {
+ this.key = key;
+ this.sequence = Objects.requireNonNull(sequence, "sequence cannot be
null");
+ this.type = type;
+ this.value = value;
+ }
+
+ /** Returns the user key bytes shared by all versions merged for this
emit. */
+ @Override
+ public byte[] getUserKey() {
+ return key;
+ }
+
+ /** Returns the RocksDB sequence number as a signed {@code long}. */
+ @Override
+ public long getSequence() {
+ return sequence.longValue();
+ }
+
+ /** Returns the RocksDB internal value type for this record. */
+ public int getValueType() {
+ return type;
+ }
+
+ /** Returns the RocksDB sequence number. */
+ public UnsignedLong getSequenceNumber() {
+ return sequence;
+ }
+
+ /** Returns the value bytes, or {@code null} for tombstones. */
+ public byte[] getValue() {
+ return value;
+ }
+
+ /** Returns {@code true} when this record is a tombstone rather than a
value. */
+ @Override
+ public boolean isTombstone() {
+ return type != ROCKS_TYPE_VALUE;
+ }
+
+ @Override
+ public MergedKeyValue toMergedKeyValue() {
+ return this;
+ }
+ }
+
+ private static byte[] copyBuffer(CodecBuffer buffer) {
+ if (buffer == null) {
+ return null;
+ }
+ ByteBuffer byteBuffer = buffer.asReadOnlyByteBuffer();
+ byte[] bytes = new byte[byteBuffer.remaining()];
+ byteBuffer.get(bytes);
+ return bytes;
+ }
+}
diff --git
a/hadoop-hdds/rocks-native/src/test/java/org/apache/hadoop/hdds/utils/db/TestLatestVersionedKWayMergeIterator.java
b/hadoop-hdds/rocks-native/src/test/java/org/apache/hadoop/hdds/utils/db/TestLatestVersionedKWayMergeIterator.java
new file mode 100644
index 00000000000..76c79362a46
--- /dev/null
+++
b/hadoop-hdds/rocks-native/src/test/java/org/apache/hadoop/hdds/utils/db/TestLatestVersionedKWayMergeIterator.java
@@ -0,0 +1,253 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hadoop.hdds.utils.db;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Iterator;
+import java.util.List;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+import
org.apache.hadoop.hdds.utils.db.LatestVersionedKWayMergeIterator.MergedKeyValue;
+import org.apache.hadoop.ozone.util.ClosableIterator;
+import org.junit.jupiter.api.Named;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+class TestLatestVersionedKWayMergeIterator {
+
+ private static Stream<Arguments> mergeScenarios() {
+ return Stream.of(
+ Named.of("recreate: tombstone then newer value emits both",
+ scenario(
+ expected(kv("k1", 2, 0, null), kv("k1", 3, 1, "v3")),
+ source(kv("k1", 1, 1, "v1"), kv("k1", 2, 0, null)),
+ source(kv("k1", 3, 1, "v3")))),
+ Named.of("delete only: latest tombstone wins",
+ scenario(
+ expected(kv("k1", 5, 0, null)),
+ source(kv("k1", 1, 1, "v1")),
+ source(kv("k1", 5, 0, null)))),
+ Named.of("value only: latest value wins",
+ scenario(
+ expected(kv("k1", 5, 1, "v5")),
+ source(kv("k1", 1, 1, "v1")),
+ source(kv("k1", 5, 1, "v5")))),
+ Named.of("three-file worked example",
+ scenario(
+ expected(kv("k1", 30, 1, "v30"), kv("k2", 15, 0, null),
kv("k2", 25, 1, "v25")),
+ source(kv("k1", 10, 1, "v10"), kv("k1", 5, 1, "v5"), kv("k2",
20, 1, "v20")),
+ source(kv("k1", 3, 1, "v3"), kv("k1", 15, 1, "v15"), kv("k2",
15, 0, null)),
+ source(kv("k1", 1, 1, "v1"), kv("k1", 30, 1, "v30"), kv("k2",
25, 1, "v25")))),
+ Named.of("multi-key: recreate on k1, delete-only on k2",
+ scenario(
+ expected(kv("k1", 3, 0, null), kv("k1", 10, 1, "v10"),
kv("k2", 15, 0, null)),
+ source(kv("k1", 10, 1, "v10"), kv("k1", 3, 0, null), kv("k2",
10, 1, "v10")),
+ source(kv("k2", 15, 0, null)))),
+ Named.of("duplicate tombstones deduped to highest sequence",
+ scenario(
+ expected(kv("k1", 7, 0, null)),
+ source(kv("k1", 4, 0, null), kv("k1", 2, 0, null)),
+ source(kv("k1", 7, 0, null)))),
+ Named.of("multiple recreate cycles on same key",
+ scenario(
+ expected(kv("k1", 4, 0, null), kv("k1", 6, 1, "v6")),
+ source(
+ kv("k1", 1, 1, "v1"),
+ kv("k1", 2, 0, null),
+ kv("k1", 3, 1, "v3"),
+ kv("k1", 4, 0, null),
+ kv("k1", 5, 1, "v5"),
+ kv("k1", 6, 1, "v6")))),
+ Named.of("interleaved keys preserve user-key order",
+ scenario(
+ expected(kv("a", 1, 1, "a1"), kv("b", 2, 1, "b1"), kv("c", 3,
1, "c1")),
+ source(kv("a", 1, 1, "a1"), kv("c", 3, 1, "c1")),
+ source(kv("b", 2, 1, "b1")))),
+ Named.of("empty source files are ignored",
+ scenario(
+ expected(kv("k1", 2, 1, "v2")),
+ source(kv("k1", 1, 1, "v1")),
+ source(),
+ source(kv("k1", 2, 1, "v2"))))
+ ).map(Arguments::of);
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("mergeScenarios")
+ void testMergeMatchesExpectedOutput(MergeScenario scenario) {
+ assertResultsEqual(scenario.expected, merge(scenario.sources));
+ }
+
+ @Test
+ void testExclusiveMinSequenceNumberFiltersPerKey() {
+ long exclusiveMinSequenceNumber = 5L;
+ List<List<MergedKeyValue>> sources = Arrays.asList(
+ Arrays.asList(
+ kv("k1", 7, 1, "v7"),
+ kv("k2", 1, 1, "v1"),
+ kv("k3", 6, 0, null),
+ kv("k4", 10, 1, "v10")),
+ Arrays.asList(
+ kv("k1", 3, 0, null),
+ kv("k2", 3, 0, null),
+ kv("k3", 8, 1, "v8")));
+ List<MergedKeyValue> expected = Arrays.asList(
+ kv("k1", 7, 1, "v7"),
+ kv("k3", 6, 0, null),
+ kv("k3", 8, 1, "v8"),
+ kv("k4", 10, 1, "v10"));
+
+ assertResultsEqual(expected, merge(sources, exclusiveMinSequenceNumber));
+ }
+
+ private static List<MergedKeyValue> merge(List<List<MergedKeyValue>>
sources) {
+ return merge(sources, null);
+ }
+
+ private static List<MergedKeyValue> merge(List<List<MergedKeyValue>> sources,
+ Long exclusiveMinSequenceNumber) {
+ List<ClosableIterator<MergedKeyValue>> iterators = sources.stream()
+ .map(ListIterator::new)
+ .collect(Collectors.toList());
+
+ List<MergedKeyValue> results = new ArrayList<>();
+ try (LatestVersionedKWayMergeIterator iterator =
+ LatestVersionedKWayMergeIterator.forTest(iterators,
exclusiveMinSequenceNumber)) {
+ while (iterator.hasNext()) {
+ results.add(iterator.next());
+ }
+ }
+ return results;
+ }
+
+ private static void assertResultsEqual(List<MergedKeyValue> expected,
List<MergedKeyValue> actual) {
+ assertEquals(expected.size(), actual.size(),
+ () -> "expected=" + describe(expected) + " actual=" +
describe(actual));
+
+ for (int i = 0; i < expected.size(); i++) {
+ MergedKeyValue exp = expected.get(i);
+ MergedKeyValue act = actual.get(i);
+ assertArrayEquals(exp.getUserKey(), act.getUserKey(), "key mismatch at
index " + i);
+ assertEquals(exp.getSequence(), act.getSequence(), "sequence mismatch at
index " + i);
+ assertEquals(exp.getValueType(), act.getValueType(), "type mismatch at
index " + i);
+ if (exp.getValue() == null) {
+ assertNull(act.getValue(), "value should be null at index " + i);
+ } else {
+ assertArrayEquals(exp.getValue(), act.getValue(), "value mismatch at
index " + i);
+ }
+ }
+ }
+
+ private static String describe(List<MergedKeyValue> entries) {
+ StringBuilder sb = new StringBuilder("[");
+ for (MergedKeyValue entry : entries) {
+ sb.append('{')
+ .append(asString(entry.getUserKey()))
+ .append(", seq=").append(entry.getSequence())
+ .append(", type=").append(entry.getValueType())
+ .append("} ");
+ }
+ return sb.append(']').toString();
+ }
+
+ private static MergeScenario scenario(Expected expected, Source... sources) {
+ List<List<MergedKeyValue>> sourceKvs = new ArrayList<>();
+ for (Source source : sources) {
+ sourceKvs.add(source.entries);
+ }
+ return new MergeScenario(sourceKvs, expected.entries);
+ }
+
+ private static Expected expected(MergedKeyValue... entries) {
+ return new Expected(Arrays.asList(entries));
+ }
+
+ private static Source source(MergedKeyValue... entries) {
+ return new Source(Arrays.asList(entries));
+ }
+
+ private static MergedKeyValue kv(String key, long sequence, int type, String
value) {
+ return MergedKeyValue.of(
+ key.getBytes(StandardCharsets.UTF_8),
+ sequence,
+ type,
+ value == null ? null : value.getBytes(StandardCharsets.UTF_8));
+ }
+
+ private static String asString(byte[] bytes) {
+ return new String(bytes, StandardCharsets.UTF_8);
+ }
+
+ private static final class MergeScenario {
+ private final List<List<MergedKeyValue>> sources;
+ private final List<MergedKeyValue> expected;
+
+ private MergeScenario(List<List<MergedKeyValue>> sources,
+ List<MergedKeyValue> expected) {
+ this.sources = sources;
+ this.expected = expected;
+ }
+ }
+
+ private static final class Expected {
+ private final List<MergedKeyValue> entries;
+
+ private Expected(List<MergedKeyValue> entries) {
+ this.entries = entries;
+ }
+ }
+
+ private static final class Source {
+ private final List<MergedKeyValue> entries;
+
+ private Source(List<MergedKeyValue> entries) {
+ this.entries = entries;
+ }
+ }
+
+ private static final class ListIterator implements
ClosableIterator<MergedKeyValue> {
+ private final Iterator<MergedKeyValue> iterator;
+
+ private ListIterator(List<MergedKeyValue> entries) {
+ this.iterator = entries.iterator();
+ }
+
+ @Override
+ public boolean hasNext() {
+ return iterator.hasNext();
+ }
+
+ @Override
+ public MergedKeyValue next() {
+ return iterator.next();
+ }
+
+ @Override
+ public void close() {
+ // Nothing to close.
+ }
+ }
+}
diff --git
a/hadoop-hdds/rocks-native/src/test/java/org/apache/hadoop/hdds/utils/db/TestLatestVersionedKWayMergeIteratorOverSst.java
b/hadoop-hdds/rocks-native/src/test/java/org/apache/hadoop/hdds/utils/db/TestLatestVersionedKWayMergeIteratorOverSst.java
new file mode 100644
index 00000000000..3b7c9e4bed0
--- /dev/null
+++
b/hadoop-hdds/rocks-native/src/test/java/org/apache/hadoop/hdds/utils/db/TestLatestVersionedKWayMergeIteratorOverSst.java
@@ -0,0 +1,204 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hadoop.hdds.utils.db;
+
+import static
org.apache.hadoop.hdds.utils.NativeConstants.ROCKS_TOOLS_NATIVE_PROPERTY;
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+import org.apache.hadoop.hdds.utils.NativeLibraryNotLoadedException;
+import
org.apache.hadoop.hdds.utils.db.LatestVersionedKWayMergeIterator.MergedKeyValue;
+import org.apache.hadoop.hdds.utils.db.TestRawSstFileRecords.SourceRecord;
+import org.apache.hadoop.hdds.utils.db.managed.ManagedColumnFamilyOptions;
+import org.apache.hadoop.hdds.utils.db.managed.ManagedDBOptions;
+import org.apache.hadoop.hdds.utils.db.managed.ManagedRocksDB;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
+import org.junit.jupiter.api.io.TempDir;
+import org.rocksdb.ColumnFamilyDescriptor;
+import org.rocksdb.ColumnFamilyHandle;
+import org.rocksdb.FlushOptions;
+import org.rocksdb.RocksDB;
+import org.rocksdb.RocksDBException;
+
+/**
+ * End-to-end test over real SST files produced by RocksDB flush.
+ * <p>
+ * {@link org.apache.hadoop.hdds.utils.db.managed.ManagedSstFileWriter} is not
used here because
+ * it stores sequence number 0 on every key and rejects duplicate user keys
per file. Flushing a
+ * real RocksDB after each logical source batch yields SST files with global
sequence numbers.
+ * Memtable flushes retain only the latest value per user key within each SST;
competing versions
+ * appear across separate flushed files, matching production snapshot-diff
inputs.
+ */
+@EnabledIfSystemProperty(named = ROCKS_TOOLS_NATIVE_PROPERTY, matches = "true")
+class TestLatestVersionedKWayMergeIteratorOverSst {
+
+ @TempDir
+ private Path tempDir;
+
+ @BeforeAll
+ static void loadNativeLibrary() throws NativeLibraryNotLoadedException {
+ ManagedRawSSTFileReader.loadLibrary();
+ }
+
+ @Test
+ void testWorkedExampleOverRealSstFiles() throws Exception {
+ // Mirrors the unit-test "three-file worked example": cross-file k-way
merge with competing
+ // versions, k1 latest-value-only, k2 delete-then-recreate across files.
+ Path dbDir = tempDir.resolve("worked-example-db");
+ Files.createDirectories(dbDir);
+ Set<String> knownSstFiles = new HashSet<>();
+ List<Path> sstFiles = new ArrayList<>(3);
+
+ try (ManagedDBOptions dbOptions = new ManagedDBOptions();
+ ManagedColumnFamilyOptions cfOptions = new
ManagedColumnFamilyOptions();
+ FlushOptions flushOptions = new FlushOptions()) {
+ dbOptions.setCreateIfMissing(true);
+ List<ColumnFamilyDescriptor> columnFamilyDescriptors =
Collections.singletonList(
+ new ColumnFamilyDescriptor(RocksDB.DEFAULT_COLUMN_FAMILY,
cfOptions));
+ List<ColumnFamilyHandle> columnFamilyHandles = new ArrayList<>();
+ try (ManagedRocksDB db = ManagedRocksDB.open(
+ dbOptions, dbDir.toString(), columnFamilyDescriptors,
columnFamilyHandles);
+ ColumnFamilyHandle cf = columnFamilyHandles.get(0)) {
+
+ // Source A: latest k1 wins within the memtable before flush, plus k2.
+ rocksPut(db, cf, "k1", "v5");
+ rocksPut(db, cf, "k1", "v10");
+ rocksPut(db, cf, "k2", "v20");
+ sstFiles.add(flushAndCopySst(db, dbDir, cf, flushOptions,
knownSstFiles, "a"));
+
+ // Source B: competing k1 version and a k2 tombstone.
+ rocksPut(db, cf, "k1", "v3");
+ rocksPut(db, cf, "k1", "v15");
+ rocksDelete(db, cf, "k2");
+ sstFiles.add(flushAndCopySst(db, dbDir, cf, flushOptions,
knownSstFiles, "b"));
+
+ // Source C: winning k1/k2 values.
+ rocksPut(db, cf, "k1", "v1");
+ rocksPut(db, cf, "k1", "v30");
+ rocksPut(db, cf, "k2", "v25");
+ sstFiles.add(flushAndCopySst(db, dbDir, cf, flushOptions,
knownSstFiles, "c"));
+ }
+ }
+
+ List<List<SourceRecord>> perSource =
TestRawSstFileRecords.readFiles(sstFiles);
+ long k1VersionsAcrossFiles = perSource.stream()
+ .flatMap(List::stream)
+ .filter(record -> Arrays.equals(record.getUserKey(), keyBytes("k1")))
+ .count();
+ assertEquals(3, k1VersionsAcrossFiles,
+ "each flushed SST should contribute one surviving k1 version");
+ long distinctK1Sequences = perSource.stream()
+ .flatMap(List::stream)
+ .filter(record -> Arrays.equals(record.getUserKey(), keyBytes("k1")))
+ .mapToLong(SourceRecord::getSequence)
+ .distinct()
+ .count();
+ assertEquals(3, distinctK1Sequences,
+ "k1 versions across SST files should carry distinct RocksDB sequence
numbers");
+
+ List<MergedKeyValue> actual = mergeSstFiles(sstFiles.toArray(new Path[0]));
+ assertEquals(3, actual.size(), "expected k1 winner plus k2 tombstone and
recreate value");
+
+ MergedKeyValue k1Winner = actual.get(0);
+ assertArrayEquals(keyBytes("k1"), k1Winner.getUserKey());
+ assertEquals(LatestVersionedKWayMergeIterator.ROCKS_TYPE_VALUE,
k1Winner.getValueType());
+ assertArrayEquals(valueBytes("v30"), k1Winner.getValue());
+
+ MergedKeyValue k2Tombstone = actual.get(1);
+ assertArrayEquals(keyBytes("k2"), k2Tombstone.getUserKey());
+ assertNotEquals(LatestVersionedKWayMergeIterator.ROCKS_TYPE_VALUE,
k2Tombstone.getValueType());
+
+ MergedKeyValue k2Value = actual.get(2);
+ assertArrayEquals(keyBytes("k2"), k2Value.getUserKey());
+ assertEquals(LatestVersionedKWayMergeIterator.ROCKS_TYPE_VALUE,
k2Value.getValueType());
+ assertArrayEquals(valueBytes("v25"), k2Value.getValue());
+ assertTrue(k2Value.getSequence() > k2Tombstone.getSequence(),
+ "recreate value must be newer than the tombstone");
+ }
+
+ private Path flushAndCopySst(ManagedRocksDB db, Path dbDir,
ColumnFamilyHandle cf,
+ FlushOptions flushOptions, Set<String> knownSstFiles, String label)
+ throws RocksDBException, IOException {
+ db.get().flush(flushOptions, cf);
+ Path newSst = findNewSstFile(dbDir, knownSstFiles);
+ Path dest = tempDir.resolve(label + "-" + newSst.getFileName());
+ Files.copy(newSst, dest);
+ return dest;
+ }
+
+ private static Path findNewSstFile(Path dbDir, Set<String> knownSstFiles)
throws IOException {
+ try (Stream<Path> sstPaths = Files.list(dbDir)) {
+ List<Path> newFiles = sstPaths
+ .filter(path -> path.getFileName().toString().endsWith(".sst"))
+ .filter(path -> knownSstFiles.add(path.getFileName().toString()))
+ .sorted()
+ .collect(Collectors.toList());
+ if (newFiles.size() != 1) {
+ throw new IllegalStateException(
+ "Expected exactly one new SST file under " + dbDir + ", found " +
newFiles);
+ }
+ return newFiles.get(0);
+ }
+ }
+
+ private List<MergedKeyValue> mergeSstFiles(Path... sstFiles) throws
Exception {
+ List<MergedKeyValue> results = new ArrayList<>();
+ try (LatestVersionedKWayMergeIterator iterator =
+
LatestVersionedKWayMergeIterator.overRawSstFiles(Arrays.asList(sstFiles))) {
+ while (iterator.hasNext()) {
+ results.add(iterator.next());
+ }
+ }
+ return results;
+ }
+
+ private static void rocksPut(ManagedRocksDB db, ColumnFamilyHandle cf,
String key, String value)
+ throws RocksDBException {
+ db.get().put(cf, keyBytes(key), valueBytes(value));
+ }
+
+ private static void rocksDelete(ManagedRocksDB db, ColumnFamilyHandle cf,
String key)
+ throws RocksDBException {
+ db.get().delete(cf, keyBytes(key));
+ }
+
+ private static byte[] keyBytes(String key) {
+ return key.getBytes(StandardCharsets.UTF_8);
+ }
+
+ private static byte[] valueBytes(String value) {
+ return value.getBytes(StandardCharsets.UTF_8);
+ }
+
+}
diff --git
a/hadoop-hdds/rocks-native/src/test/java/org/apache/hadoop/hdds/utils/db/TestRawSstFileRecords.java
b/hadoop-hdds/rocks-native/src/test/java/org/apache/hadoop/hdds/utils/db/TestRawSstFileRecords.java
new file mode 100644
index 00000000000..cd2cdc3abf1
--- /dev/null
+++
b/hadoop-hdds/rocks-native/src/test/java/org/apache/hadoop/hdds/utils/db/TestRawSstFileRecords.java
@@ -0,0 +1,121 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hadoop.hdds.utils.db;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import org.apache.hadoop.hdds.utils.db.managed.ManagedOptions;
+
+/**
+ * Test helper that reads all key versions from raw SST files.
+ */
+final class TestRawSstFileRecords {
+
+ private static final int DEFAULT_READ_AHEAD_SIZE = 2 * 1024 * 1024;
+
+ private TestRawSstFileRecords() {
+ }
+
+ static List<SourceRecord> readFile(Path sstFile) throws IOException {
+ return readFile(sstFile, DEFAULT_READ_AHEAD_SIZE);
+ }
+
+ static List<SourceRecord> readFile(Path sstFile, int readAheadSize) throws
IOException {
+ List<SourceRecord> records = new ArrayList<>();
+ try (ManagedOptions options = new ManagedOptions();
+ ManagedRawSSTFileReader reader = new ManagedRawSSTFileReader(
+ options, sstFile.toAbsolutePath().toString(), readAheadSize);
+ ManagedRawSSTFileIterator<ManagedRawSSTFileIterator.KeyValue>
iterator =
+ reader.newIterator(kv -> kv, null, null,
IteratorType.KEY_AND_VALUE)) {
+ while (iterator.hasNext()) {
+ ManagedRawSSTFileIterator.KeyValue kv = iterator.next();
+ byte[] key = copyBuffer(kv.getKey());
+ byte[] value = kv.getType() ==
LatestVersionedKWayMergeIterator.ROCKS_TYPE_VALUE
+ ? copyBuffer(kv.getValue()) : null;
+ records.add(new SourceRecord(key, kv.getSequence().longValue(),
kv.getType(), value));
+ }
+ }
+ return records;
+ }
+
+ static List<List<SourceRecord>> readFiles(List<Path> sstFiles) throws
IOException {
+ return readFiles(sstFiles, DEFAULT_READ_AHEAD_SIZE);
+ }
+
+ static List<List<SourceRecord>> readFiles(List<Path> sstFiles, int
readAheadSize)
+ throws IOException {
+ List<List<SourceRecord>> perSource = new ArrayList<>(sstFiles.size());
+ for (Path sstFile : sstFiles) {
+ perSource.add(readFile(sstFile, readAheadSize));
+ }
+ return perSource;
+ }
+
+ private static byte[] copyBuffer(CodecBuffer buffer) {
+ if (buffer == null) {
+ return null;
+ }
+ ByteBuffer byteBuffer = buffer.asReadOnlyByteBuffer();
+ byte[] bytes = new byte[byteBuffer.remaining()];
+ byteBuffer.get(bytes);
+ return bytes;
+ }
+
+ static final class SourceRecord {
+ private final byte[] userKey;
+ private final long sequence;
+ private final int type;
+ private final byte[] value;
+
+ SourceRecord(byte[] userKey, long sequence, int type, byte[] value) {
+ this.userKey = userKey;
+ this.sequence = sequence;
+ this.type = type;
+ this.value = value;
+ }
+
+ byte[] getUserKey() {
+ return userKey;
+ }
+
+ long getSequence() {
+ return sequence;
+ }
+
+ int getType() {
+ return type;
+ }
+
+ byte[] getValue() {
+ return value;
+ }
+
+ @Override
+ public String toString() {
+ return "SourceRecord{key=" + Arrays.toString(userKey)
+ + ", seq=" + sequence
+ + ", type=" + type
+ + ", value=" + (value == null ? null : Arrays.toString(value))
+ + '}';
+ }
+ }
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]