This is an automated email from the ASF dual-hosted git repository.
xiangfu0 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/pinot.git
The following commit(s) were added to refs/heads/master by this push:
new 5c0c74d5308 Optimize consuming MAP key access (#19168)
5c0c74d5308 is described below
commit 5c0c74d5308e237b0b5b0659118f62af8a186481
Author: Xiang Fu <[email protected]>
AuthorDate: Wed Aug 12 23:03:37 2026 -0700
Optimize consuming MAP key access (#19168)
Avoid deserializing complete consuming-segment MAP values for selective key
lookups by adding backward-compatible forward-index defaults and an optimized
mutable off-heap implementation. Cache encoded keys, validate malformed frames
before allocation, preserve fallback behavior, and benchmark the actual
forward-index path.
---
.../apache/pinot/perf/BenchmarkMapKeyAccess.java | 132 +++++++++++++++++++++
.../writer/impl/MutableOffHeapByteArrayStore.java | 25 ++++
.../impl/forward/VarByteSVMutableForwardIndex.java | 8 ++
.../local/segment/index/map/MapKeyIndexReader.java | 25 ++--
.../impl/MutableOffHeapByteArrayStoreTest.java | 102 ++++++++++++++++
.../mutable/VarByteSVMutableForwardIndexTest.java | 22 ++++
.../segment/index/map/MapKeyIndexReaderTest.java | 112 +++++++++++++++++
.../spi/index/reader/ForwardIndexReader.java | 20 ++++
.../java/org/apache/pinot/spi/utils/MapUtils.java | 109 +++++++++++++++++
.../org/apache/pinot/spi/utils/MapUtilsTest.java | 91 ++++++++++++++
10 files changed, 633 insertions(+), 13 deletions(-)
diff --git
a/pinot-perf/src/main/java/org/apache/pinot/perf/BenchmarkMapKeyAccess.java
b/pinot-perf/src/main/java/org/apache/pinot/perf/BenchmarkMapKeyAccess.java
new file mode 100644
index 00000000000..2ba7aa04a20
--- /dev/null
+++ b/pinot-perf/src/main/java/org/apache/pinot/perf/BenchmarkMapKeyAccess.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.pinot.perf;
+
+import java.io.IOException;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+import org.apache.pinot.segment.local.io.writer.impl.DirectMemoryManager;
+import
org.apache.pinot.segment.local.realtime.impl.forward.VarByteSVMutableForwardIndex;
+import org.apache.pinot.segment.spi.memory.PinotDataBufferMemoryManager;
+import org.apache.pinot.spi.data.FieldSpec.DataType;
+import org.apache.pinot.spi.utils.MapUtils;
+import org.apache.pinot.spi.utils.MapUtils.PreparedMapKey;
+import org.openjdk.jmh.annotations.Benchmark;
+import org.openjdk.jmh.annotations.BenchmarkMode;
+import org.openjdk.jmh.annotations.Fork;
+import org.openjdk.jmh.annotations.Level;
+import org.openjdk.jmh.annotations.Measurement;
+import org.openjdk.jmh.annotations.Mode;
+import org.openjdk.jmh.annotations.OutputTimeUnit;
+import org.openjdk.jmh.annotations.Param;
+import org.openjdk.jmh.annotations.Scope;
+import org.openjdk.jmh.annotations.Setup;
+import org.openjdk.jmh.annotations.State;
+import org.openjdk.jmh.annotations.TearDown;
+import org.openjdk.jmh.annotations.Warmup;
+import org.openjdk.jmh.runner.Runner;
+import org.openjdk.jmh.runner.options.ChainedOptionsBuilder;
+import org.openjdk.jmh.runner.options.CommandLineOptions;
+import org.openjdk.jmh.runner.options.OptionsBuilder;
+
+
+/// Measures selective MAP key access against the current full-map read path.
+///
+/// Keys are fixed-length and share a common prefix, modelling dotted
OpenTelemetry-style attribute names
+/// (`k8s.workload.name`, `k8s.namespace.name`, ...). That is the honest case
for a scanning extractor: every entry
+/// clears the key-length check, so the key bytes are actually compared rather
than skipped on a length mismatch.
+/// Each benchmark thread owns its forward index and off-heap memory manager.
+@BenchmarkMode(Mode.AverageTime)
+@OutputTimeUnit(TimeUnit.MICROSECONDS)
+@Fork(value = 2)
+@Warmup(iterations = 3, time = 1)
+@Measurement(iterations = 5, time = 1)
+@State(Scope.Thread)
+public class BenchmarkMapKeyAccess {
+
+ public static void main(String[] args)
+ throws Exception {
+ // Inherit the command line so `-p`, `-prof`, `-f` and friends take
effect. Without the parent options they are
+ // parsed and then silently dropped, and the run quietly falls back to the
annotated defaults.
+ ChainedOptionsBuilder opt = new OptionsBuilder().parent(new
CommandLineOptions(args))
+ .include(BenchmarkMapKeyAccess.class.getSimpleName());
+ new Runner(opt.build()).run();
+ }
+
+ @Param({"4", "16", "64"})
+ private int _numEntries;
+
+ @Param({"first", "last"})
+ private String _targetPosition;
+
+ /// `flat` models scalar attribute maps (the common ingestion shape);
`nested` models maps whose values are
+ /// themselves objects, where the full-map path pays to materialize a
container per value.
+ @Param({"flat", "nested"})
+ private String _valueShape;
+
+ private String _targetKey;
+ private PreparedMapKey _targetMapKey;
+ private PinotDataBufferMemoryManager _memoryManager;
+ private VarByteSVMutableForwardIndex _forwardIndex;
+
+ @Setup(Level.Trial)
+ public void setUp() {
+ boolean nested = "nested".equals(_valueShape);
+ Map<String, Object> map = new LinkedHashMap<>();
+ for (int i = 0; i < _numEntries; i++) {
+ String value = "value-with-enough-bytes-to-exercise-json-parsing-" + i;
+ map.put(key(i), nested ? Map.of("value", value) : value);
+ }
+ _targetKey = key("first".equals(_targetPosition) ? 0 : _numEntries - 1);
+ _targetMapKey = new PreparedMapKey(_targetKey);
+ byte[] serialized = MapUtils.serializeMap(map, false);
+ _memoryManager = new
DirectMemoryManager(BenchmarkMapKeyAccess.class.getSimpleName());
+ _forwardIndex =
+ new VarByteSVMutableForwardIndex(DataType.MAP, _memoryManager,
"mapColumn", 1, serialized.length);
+ _forwardIndex.setBytes(0, serialized);
+ }
+
+ @TearDown(Level.Trial)
+ public void tearDown()
+ throws IOException {
+ try {
+ _forwardIndex.close();
+ } finally {
+ _memoryManager.close();
+ }
+ }
+
+ private static String key(int i) {
+ return String.format("k8s.attribute.%03d.name", i);
+ }
+
+ /// Runs the existing consuming path: copy the complete off-heap value,
deserialize every MAP entry, then select the
+ /// requested key.
+ @Benchmark
+ public Object fullMapValue() {
+ return _forwardIndex.getMap(0, null).get(_targetKey);
+ }
+
+ /// Runs the optimized consuming path, including store lookup and creation
of the read-only direct view.
+ @Benchmark
+ public Object selectiveMapValue() {
+ return _forwardIndex.getMapEntryValue(0, null, _targetMapKey);
+ }
+}
diff --git
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/io/writer/impl/MutableOffHeapByteArrayStore.java
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/io/writer/impl/MutableOffHeapByteArrayStore.java
index aad235a2212..8a8d7c05009 100644
---
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/io/writer/impl/MutableOffHeapByteArrayStore.java
+++
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/io/writer/impl/MutableOffHeapByteArrayStore.java
@@ -21,6 +21,7 @@ package org.apache.pinot.segment.local.io.writer.impl;
import com.google.common.annotations.VisibleForTesting;
import java.io.Closeable;
import java.io.IOException;
+import java.nio.ByteBuffer;
import java.util.LinkedList;
import java.util.List;
import org.apache.pinot.segment.spi.memory.PinotDataBuffer;
@@ -142,6 +143,17 @@ public class MutableOffHeapByteArrayStore implements
Closeable {
return value;
}
+ private ByteBuffer getByteBuffer(int index) {
+ int startOffset = _pinotDataBuffer.getInt(index * Integer.BYTES);
+ int endOffset;
+ if (index != 0) {
+ endOffset = _pinotDataBuffer.getInt((index - 1) * Integer.BYTES);
+ } else {
+ endOffset = _size;
+ }
+ return _pinotDataBuffer.toDirectByteBuffer(startOffset, endOffset -
startOffset);
+ }
+
private int getValueSize(int index) {
int startOffset = _pinotDataBuffer.getInt(index * Integer.BYTES);
int endOffset;
@@ -221,6 +233,19 @@ public class MutableOffHeapByteArrayStore implements
Closeable {
throw new RuntimeException("dictionary ID '" + index + "' too low");
}
+ /// Returns a read-only view of the value at the given index without copying
it.
+ /// The returned buffer must not be used after this store is closed.
+ public ByteBuffer getByteBuffer(int index) {
+ List<Buffer> bufList = _buffers;
+ for (int x = bufList.size() - 1; x >= 0; x--) {
+ Buffer buffer = bufList.get(x);
+ if (index >= buffer.getStartIndex()) {
+ return buffer.getByteBuffer(index -
buffer.getStartIndex()).asReadOnlyBuffer();
+ }
+ }
+ throw new RuntimeException("dictionary ID '" + index + "' too low");
+ }
+
public int getValueSize(int index) {
List<Buffer> bufList = _buffers;
for (int x = bufList.size() - 1; x >= 0; x--) {
diff --git
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/realtime/impl/forward/VarByteSVMutableForwardIndex.java
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/realtime/impl/forward/VarByteSVMutableForwardIndex.java
index d6e29bd0496..6672e392e0f 100644
---
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/realtime/impl/forward/VarByteSVMutableForwardIndex.java
+++
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/realtime/impl/forward/VarByteSVMutableForwardIndex.java
@@ -21,6 +21,7 @@ package org.apache.pinot.segment.local.realtime.impl.forward;
import java.io.IOException;
import java.math.BigDecimal;
import java.util.Map;
+import javax.annotation.Nullable;
import
org.apache.pinot.segment.local.io.writer.impl.MutableOffHeapByteArrayStore;
import org.apache.pinot.segment.spi.index.mutable.MutableForwardIndex;
import org.apache.pinot.segment.spi.index.reader.ForwardIndexReaderContext;
@@ -28,6 +29,7 @@ import
org.apache.pinot.segment.spi.memory.PinotDataBufferMemoryManager;
import org.apache.pinot.spi.data.FieldSpec.DataType;
import org.apache.pinot.spi.utils.BigDecimalUtils;
import org.apache.pinot.spi.utils.MapUtils;
+import org.apache.pinot.spi.utils.MapUtils.PreparedMapKey;
import org.apache.pinot.spi.utils.Utf8Utils;
import static java.nio.charset.StandardCharsets.UTF_8;
@@ -100,6 +102,12 @@ public class VarByteSVMutableForwardIndex implements
MutableForwardIndex {
return MapUtils.deserializeMap(getBytes(docId));
}
+ @Nullable
+ @Override
+ public Object getMapEntryValue(int docId, ForwardIndexReaderContext context,
PreparedMapKey key) {
+ return
MapUtils.deserializeMapEntryValue(_byteArrayStore.getByteBuffer(docId), key);
+ }
+
@Override
public void setBigDecimal(int docId, BigDecimal value) {
setBytes(docId, BigDecimalUtils.serialize(value));
diff --git
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/map/MapKeyIndexReader.java
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/map/MapKeyIndexReader.java
index 3d987b5c103..dca7a8d2aed 100644
---
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/map/MapKeyIndexReader.java
+++
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/map/MapKeyIndexReader.java
@@ -20,23 +20,23 @@ package org.apache.pinot.segment.local.segment.index.map;
import java.io.IOException;
import java.math.BigDecimal;
-import java.util.Map;
import javax.annotation.Nullable;
import org.apache.pinot.segment.spi.index.reader.ForwardIndexReader;
import org.apache.pinot.segment.spi.index.reader.ForwardIndexReaderContext;
import org.apache.pinot.spi.data.FieldSpec;
import org.apache.pinot.spi.utils.BigDecimalUtils;
+import org.apache.pinot.spi.utils.MapUtils.PreparedMapKey;
public class MapKeyIndexReader implements ForwardIndexReader {
private final ForwardIndexReader _forwardIndexReader;
private final FieldSpec _keyFieldSpec;
- private final String _keyName;
+ private final PreparedMapKey _mapKey;
private final Object _defaultNullValue;
public MapKeyIndexReader(ForwardIndexReader forwardIndexReader, String
keyName, FieldSpec keyFieldSpec) {
_forwardIndexReader = forwardIndexReader;
- _keyName = keyName;
+ _mapKey = new PreparedMapKey(keyName);
_keyFieldSpec = keyFieldSpec;
_defaultNullValue = keyFieldSpec.getDefaultNullValue();
}
@@ -58,42 +58,41 @@ public class MapKeyIndexReader implements
ForwardIndexReader {
@Override
public int getInt(int docId, ForwardIndexReaderContext context) {
- return Integer.parseInt(extractMapValue(docId, context,
_keyName).toString());
+ return Integer.parseInt(extractMapValue(docId, context).toString());
}
@Override
public long getLong(int docId, ForwardIndexReaderContext context) {
- return Long.parseLong(extractMapValue(docId, context,
_keyName).toString());
+ return Long.parseLong(extractMapValue(docId, context).toString());
}
@Override
public float getFloat(int docId, ForwardIndexReaderContext context) {
- return Float.parseFloat(extractMapValue(docId, context,
_keyName).toString());
+ return Float.parseFloat(extractMapValue(docId, context).toString());
}
@Override
public double getDouble(int docId, ForwardIndexReaderContext context) {
- return Double.parseDouble(extractMapValue(docId, context,
_keyName).toString());
+ return Double.parseDouble(extractMapValue(docId, context).toString());
}
@Override
public String getString(int docId, ForwardIndexReaderContext context) {
- return extractMapValue(docId, context, _keyName).toString();
+ return extractMapValue(docId, context).toString();
}
@Override
public byte[] getBytes(int docId, ForwardIndexReaderContext context) {
- return (byte[]) extractMapValue(docId, context, _keyName);
+ return (byte[]) extractMapValue(docId, context);
}
@Override
public BigDecimal getBigDecimal(int docId, ForwardIndexReaderContext
context) {
- return BigDecimalUtils.deserialize((byte[]) extractMapValue(docId,
context, _keyName));
+ return BigDecimalUtils.deserialize((byte[]) extractMapValue(docId,
context));
}
- private Object extractMapValue(int docId, ForwardIndexReaderContext context,
String key) {
- Map map = _forwardIndexReader.getMap(docId, context);
- Object object = map.get(key);
+ private Object extractMapValue(int docId, ForwardIndexReaderContext context)
{
+ Object object = _forwardIndexReader.getMapEntryValue(docId, context,
_mapKey);
if (object == null) {
return _defaultNullValue;
}
diff --git
a/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/io/writer/impl/MutableOffHeapByteArrayStoreTest.java
b/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/io/writer/impl/MutableOffHeapByteArrayStoreTest.java
index c316992c034..e817598ce3b 100644
---
a/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/io/writer/impl/MutableOffHeapByteArrayStoreTest.java
+++
b/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/io/writer/impl/MutableOffHeapByteArrayStoreTest.java
@@ -18,7 +18,15 @@
*/
package org.apache.pinot.segment.local.io.writer.impl;
+import java.nio.ByteBuffer;
+import java.nio.ReadOnlyBufferException;
import java.util.Arrays;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
import org.apache.pinot.segment.local.PinotBuffersAfterClassCheckRule;
import org.apache.pinot.segment.spi.memory.PinotDataBufferMemoryManager;
import org.testng.Assert;
@@ -61,6 +69,100 @@ public class MutableOffHeapByteArrayStoreTest implements
PinotBuffersAfterClassC
}
}
+ @Test
+ public void byteBufferTest()
+ throws Exception {
+ try (MutableOffHeapByteArrayStore store =
+ new MutableOffHeapByteArrayStore(_memoryManager, "bytesColumn", 1, 1))
{
+ byte[] firstValue = {1};
+ byte[] secondValue = {2, 3, 4};
+ int firstIndex = store.add(firstValue);
+ int secondIndex = store.add(secondValue);
+
+ ByteBuffer firstBuffer = store.getByteBuffer(firstIndex);
+ byte[] firstResult = new byte[firstBuffer.remaining()];
+ firstBuffer.get(firstResult);
+ Assert.assertEquals(firstResult, firstValue);
+
+ ByteBuffer secondBuffer = store.getByteBuffer(secondIndex);
+ Assert.assertTrue(secondBuffer.isReadOnly());
+ byte[] secondResult = new byte[secondBuffer.remaining()];
+ secondBuffer.get(secondResult);
+ Assert.assertEquals(secondResult, secondValue);
+ Assert.assertThrows(ReadOnlyBufferException.class, () ->
store.getByteBuffer(secondIndex).put((byte) 0));
+ }
+ }
+
+ @Test
+ public void getByteBufferDuringConcurrentAppendTest()
+ throws Exception {
+ int numReaders = 4;
+ int numValues = 2_048;
+ ExecutorService executor = Executors.newFixedThreadPool(numReaders + 1);
+ AtomicInteger publishedCount = new AtomicInteger();
+ CountDownLatch start = new CountDownLatch(1);
+ CountDownLatch firstValueRead = new CountDownLatch(numReaders);
+ try (MutableOffHeapByteArrayStore store =
+ new MutableOffHeapByteArrayStore(_memoryManager,
"concurrentBytesColumn", 1, 1)) {
+ Future<?> writer = executor.submit(() -> {
+ await(start);
+ Assert.assertEquals(store.add(valueForIndex(0)), 0);
+ publishedCount.set(1);
+ await(firstValueRead);
+ for (int i = 1; i < numValues; i++) {
+ Assert.assertEquals(store.add(valueForIndex(i)), i);
+ // Publish only after the value and any expanded buffer are visible
to readers.
+ publishedCount.set(i + 1);
+ }
+ });
+
+ Future<?>[] readers = new Future<?>[numReaders];
+ for (int i = 0; i < numReaders; i++) {
+ readers[i] = executor.submit(() -> {
+ await(start);
+ int nextIndex = 0;
+ while (nextIndex < numValues) {
+ int readableCount = publishedCount.get();
+ while (nextIndex < readableCount) {
+ ByteBuffer byteBuffer = store.getByteBuffer(nextIndex);
+ Assert.assertTrue(byteBuffer.isReadOnly());
+ byte[] actual = new byte[byteBuffer.remaining()];
+ byteBuffer.get(actual);
+ Assert.assertEquals(actual, valueForIndex(nextIndex));
+ nextIndex++;
+ if (nextIndex == 1) {
+ firstValueRead.countDown();
+ }
+ }
+ Thread.yield();
+ }
+ });
+ }
+
+ start.countDown();
+ writer.get(30, TimeUnit.SECONDS);
+ for (Future<?> reader : readers) {
+ reader.get(30, TimeUnit.SECONDS);
+ }
+ } finally {
+ executor.shutdownNow();
+ Assert.assertTrue(executor.awaitTermination(30, TimeUnit.SECONDS));
+ }
+ }
+
+ private static byte[] valueForIndex(int index) {
+ return ByteBuffer.allocate(2 *
Integer.BYTES).putInt(index).putInt(~index).array();
+ }
+
+ private static void await(CountDownLatch latch) {
+ try {
+ Assert.assertTrue(latch.await(30, TimeUnit.SECONDS));
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new RuntimeException(e);
+ }
+ }
+
@Test
public void startSizeTest() {
Assert.assertEquals(MutableOffHeapByteArrayStore.getStartSize(1, ONE_GB),
ONE_GB + 4);
diff --git
a/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/index/forward/mutable/VarByteSVMutableForwardIndexTest.java
b/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/index/forward/mutable/VarByteSVMutableForwardIndexTest.java
index 08561c19795..72d5feee947 100644
---
a/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/index/forward/mutable/VarByteSVMutableForwardIndexTest.java
+++
b/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/index/forward/mutable/VarByteSVMutableForwardIndexTest.java
@@ -19,6 +19,8 @@
package org.apache.pinot.segment.local.segment.index.forward.mutable;
import java.io.IOException;
+import java.util.LinkedHashMap;
+import java.util.Map;
import java.util.Random;
import org.apache.commons.lang3.RandomStringUtils;
import org.apache.pinot.segment.local.PinotBuffersAfterClassCheckRule;
@@ -26,6 +28,8 @@ import
org.apache.pinot.segment.local.io.writer.impl.DirectMemoryManager;
import
org.apache.pinot.segment.local.realtime.impl.forward.VarByteSVMutableForwardIndex;
import org.apache.pinot.segment.spi.memory.PinotDataBufferMemoryManager;
import org.apache.pinot.spi.data.FieldSpec.DataType;
+import org.apache.pinot.spi.utils.MapUtils;
+import org.apache.pinot.spi.utils.MapUtils.PreparedMapKey;
import org.testng.Assert;
import org.testng.annotations.AfterClass;
import org.testng.annotations.BeforeClass;
@@ -98,4 +102,22 @@ public class VarByteSVMutableForwardIndexTest implements
PinotBuffersAfterClassC
Assert.assertTrue(readerWriter.canAddMore());
}
}
+
+ @Test
+ public void testMapValue()
+ throws IOException {
+ Map<String, Object> map = new LinkedHashMap<>();
+ map.put("first", "value");
+ map.put("k8s.workload.name", "pinot-server");
+ map.put("last", 42);
+ try (VarByteSVMutableForwardIndex readerWriter = new
VarByteSVMutableForwardIndex(DataType.MAP, _memoryManager,
+ "MapColumn", 1, 64)) {
+ readerWriter.setBytes(0, MapUtils.serializeMap(map));
+
+ Assert.assertEquals(readerWriter.getMapEntryValue(0, null, new
PreparedMapKey("k8s.workload.name")),
+ "pinot-server");
+ Assert.assertNull(readerWriter.getMapEntryValue(0, null, new
PreparedMapKey("missing")));
+ Assert.assertEquals(readerWriter.getMap(0, null), map);
+ }
+ }
}
diff --git
a/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/index/map/MapKeyIndexReaderTest.java
b/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/index/map/MapKeyIndexReaderTest.java
new file mode 100644
index 00000000000..f83f8c3457c
--- /dev/null
+++
b/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/index/map/MapKeyIndexReaderTest.java
@@ -0,0 +1,112 @@
+/**
+ * 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.pinot.segment.local.segment.index.map;
+
+import java.util.Map;
+import javax.annotation.Nullable;
+import org.apache.pinot.segment.spi.index.reader.ForwardIndexReader;
+import org.apache.pinot.segment.spi.index.reader.ForwardIndexReaderContext;
+import org.apache.pinot.spi.data.DimensionFieldSpec;
+import org.apache.pinot.spi.data.FieldSpec;
+import org.apache.pinot.spi.data.FieldSpec.DataType;
+import org.apache.pinot.spi.utils.MapUtils.PreparedMapKey;
+import org.testng.annotations.Test;
+
+import static org.testng.Assert.assertEquals;
+
+
+/// Covers [MapKeyIndexReader] over both shapes of underlying reader: one that
implements the selective
+/// [ForwardIndexReader#getMapEntryValue] override (as the mutable forward
index does), and one that only implements
+/// [ForwardIndexReader#getMap] and therefore falls through to the default.
Both must agree.
+public class MapKeyIndexReaderTest {
+ private static final Map<String, Object> MAP =
+ Map.of("k8s.workload.name", "pinot-server", "k8s.workload.replicas", 3);
+
+ @Test
+ public void testSelectiveReader() {
+ assertReaderBehavior(new SelectiveReader());
+ }
+
+ /// The immutable sparse-key path inherits the default `getMapEntryValue`.
It has to keep working unchanged.
+ @Test
+ public void testReaderWithoutSelectiveOverride() {
+ assertReaderBehavior(new FullMapOnlyReader());
+ }
+
+ @Test
+ public void testStringOverloadDelegatesToSelectiveLookup() {
+ assertEquals(new SelectiveReader().getMapEntryValue(0, null,
"k8s.workload.name"), "pinot-server");
+ }
+
+ private static void
assertReaderBehavior(ForwardIndexReader<ForwardIndexReaderContext> reader) {
+ FieldSpec stringSpec = new DimensionFieldSpec("value", DataType.STRING,
true);
+ assertEquals(new MapKeyIndexReader(reader, "k8s.workload.name",
stringSpec).getString(0, null), "pinot-server");
+
+ // A key that is absent from the map resolves to the field spec's default
null value, not to null.
+ assertEquals(new MapKeyIndexReader(reader, "missing",
stringSpec).getString(0, null),
+ stringSpec.getDefaultNullValue());
+
+ FieldSpec intSpec = new DimensionFieldSpec("value", DataType.INT, true);
+ assertEquals(new MapKeyIndexReader(reader, "k8s.workload.replicas",
intSpec).getInt(0, null), 3);
+ }
+
+ /// Mirrors the mutable forward index: answers a single key without
materializing the map.
+ private static class SelectiveReader extends BaseReader {
+ @Nullable
+ @Override
+ public Object getMapEntryValue(int docId, ForwardIndexReaderContext
context, PreparedMapKey key) {
+ return MAP.get(key.getKey());
+ }
+
+ @Override
+ public Map<String, Object> getMap(int docId, ForwardIndexReaderContext
context) {
+ throw new AssertionError("Selective lookup must not materialize the full
map");
+ }
+ }
+
+ /// Mirrors a reader that only knows how to hand back the whole map.
+ private static class FullMapOnlyReader extends BaseReader {
+ }
+
+ private abstract static class BaseReader implements
ForwardIndexReader<ForwardIndexReaderContext> {
+ @Override
+ public Map<String, Object> getMap(int docId, ForwardIndexReaderContext
context) {
+ return MAP;
+ }
+
+ @Override
+ public boolean isDictionaryEncoded() {
+ return false;
+ }
+
+ @Override
+ public boolean isSingleValue() {
+ return true;
+ }
+
+ @Override
+ public DataType getStoredType() {
+ return DataType.MAP;
+ }
+
+ @Override
+ public void close() {
+ }
+ }
+}
diff --git
a/pinot-segment-spi/src/main/java/org/apache/pinot/segment/spi/index/reader/ForwardIndexReader.java
b/pinot-segment-spi/src/main/java/org/apache/pinot/segment/spi/index/reader/ForwardIndexReader.java
index 2beba6d0b1f..66749669d94 100644
---
a/pinot-segment-spi/src/main/java/org/apache/pinot/segment/spi/index/reader/ForwardIndexReader.java
+++
b/pinot-segment-spi/src/main/java/org/apache/pinot/segment/spi/index/reader/ForwardIndexReader.java
@@ -30,6 +30,7 @@ import org.apache.pinot.spi.data.FieldSpec.DataType;
import org.apache.pinot.spi.utils.BigDecimalUtils;
import org.apache.pinot.spi.utils.BytesUtils;
import org.apache.pinot.spi.utils.MapUtils;
+import org.apache.pinot.spi.utils.MapUtils.PreparedMapKey;
import org.apache.pinot.spi.utils.hash.MurmurHashFunctions;
@@ -466,6 +467,25 @@ public interface ForwardIndexReader<T extends
ForwardIndexReaderContext> extends
+ "ForwardIndexReader is being created to read this column.");
}
+ /// Reads a value for a key from a MAP type single-value column at the given
document id.
+ /// Implementations can override this method to avoid deserializing the
entire map when only one key is needed.
+ ///
+ /// @param docId Document id
+ /// @param context Reader context
+ /// @param key Map key
+ /// @return Value for the key, or `null` if the key is missing or its value
is null
+ @Nullable
+ default Object getMapEntryValue(int docId, T context, String key) {
+ return getMapEntryValue(docId, context, new PreparedMapKey(key));
+ }
+
+ /// Variant of [#getMapEntryValue(int, ForwardIndexReaderContext, String)]
that reuses a pre-encoded MAP key.
+ /// Implementations can override this method to avoid repeated key encoding
as well as full-map deserialization.
+ @Nullable
+ default Object getMapEntryValue(int docId, T context, PreparedMapKey key) {
+ return getMap(docId, context).get(key.getKey());
+ }
+
default int get32BitsMurmur3Hash(int docId, T context) {
return MurmurHashFunctions.murmurHash3X64Bit32(getBytes(docId, context),
0);
}
diff --git a/pinot-spi/src/main/java/org/apache/pinot/spi/utils/MapUtils.java
b/pinot-spi/src/main/java/org/apache/pinot/spi/utils/MapUtils.java
index b7bf71bd08c..a6e5011e384 100644
--- a/pinot-spi/src/main/java/org/apache/pinot/spi/utils/MapUtils.java
+++ b/pinot-spi/src/main/java/org/apache/pinot/spi/utils/MapUtils.java
@@ -25,12 +25,15 @@ import com.google.common.base.Preconditions;
import com.google.common.collect.Maps;
import java.io.IOException;
import java.io.OutputStream;
+import java.nio.BufferUnderflowException;
import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
import java.util.Arrays;
import java.util.Collection;
import java.util.Map;
import java.util.Map.Entry;
import java.util.SortedMap;
+import javax.annotation.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -48,6 +51,21 @@ public class MapUtils {
private MapUtils() {
}
+ /// Immutable MAP key with its UTF-8 representation cached for selective
lookup hot paths.
+ public static final class PreparedMapKey {
+ private final String _key;
+ private final byte[] _utf8Bytes;
+
+ public PreparedMapKey(String key) {
+ _key = key;
+ _utf8Bytes = Utf8Utils.encode(key);
+ }
+
+ public String getKey() {
+ return _key;
+ }
+ }
+
private static final Logger LOGGER = LoggerFactory.getLogger(MapUtils.class);
// Pinot's standard ObjectMapper config (JSR-310 / ISO-8601 for LocalDate /
LocalTime — see JsonUtils),
@@ -183,6 +201,91 @@ public class MapUtils {
return map;
}
+ /// Deserializes only the value for the requested key from a length-prefixed
MAP frame.
+ /// Non-matching keys and values are skipped without allocating byte arrays
or invoking Jackson.
+ ///
+ /// @param bytes Serialized MAP frame
+ /// @param key Key whose value should be deserialized
+ /// @return Deserialized value, or `null` if the key is missing, has a null
value, or its JSON value cannot be
+ /// deserialized
+ /// @throws BufferUnderflowException if the MAP frame is malformed or
truncated
+ @Nullable
+ public static Object deserializeMapEntryValue(byte[] bytes, String key) {
+ return deserializeMapEntryValue(ByteBuffer.wrap(bytes), new
PreparedMapKey(key));
+ }
+
+ /// Variant of [#deserializeMapEntryValue(byte[], String)] that reuses a
pre-encoded MAP key.
+ @Nullable
+ public static Object deserializeMapEntryValue(byte[] bytes, PreparedMapKey
key) {
+ return deserializeMapEntryValue(ByteBuffer.wrap(bytes), key);
+ }
+
+ /// Variant of [#deserializeMapEntryValue(byte[], String)] that reads from
the supplied buffer without copying the
+ /// complete MAP frame.
+ ///
+ /// Consumes the buffer from its current position and forces
[ByteOrder#BIG_ENDIAN] on it — the write path frames
+ /// lengths through a big-endian [ByteBuffer], while an off-heap view
inherits the platform's native order.
+ ///
+ /// @throws BufferUnderflowException if the MAP frame is malformed or
truncated
+ @Nullable
+ public static Object deserializeMapEntryValue(ByteBuffer byteBuffer, String
key) {
+ return deserializeMapEntryValue(byteBuffer, new PreparedMapKey(key));
+ }
+
+ /// Variant of [#deserializeMapEntryValue(ByteBuffer, String)] that reuses a
pre-encoded MAP key.
+ ///
+ /// @throws BufferUnderflowException if the MAP frame is malformed or
truncated
+ @Nullable
+ public static Object deserializeMapEntryValue(ByteBuffer byteBuffer,
PreparedMapKey key) {
+ byteBuffer.order(ByteOrder.BIG_ENDIAN);
+ int size = byteBuffer.getInt();
+ if (size < 0) {
+ throw new BufferUnderflowException();
+ }
+ if (size == 0) {
+ return null;
+ }
+ byte[] keyBytes = key._utf8Bytes;
+ int keyBytesLength = keyBytes.length;
+ for (int i = 0; i < size; i++) {
+ int keyLength = byteBuffer.getInt();
+ // Bounds-check up front so the absolute gets below are provably in
range, and so a truncated frame still
+ // surfaces as BufferUnderflowException rather than
IndexOutOfBoundsException.
+ checkLength(byteBuffer, keyLength);
+ // Compare through absolute gets so a length mismatch or a differing
byte skips the rest of the key outright,
+ // rather than walking it one relative get at a time just to advance the
position.
+ boolean matches = keyLength == keyBytesLength;
+ if (matches) {
+ int keyOffset = byteBuffer.position();
+ for (int j = 0; j < keyLength; j++) {
+ if (byteBuffer.get(keyOffset + j) != keyBytes[j]) {
+ matches = false;
+ break;
+ }
+ }
+ }
+ byteBuffer.position(byteBuffer.position() + keyLength);
+
+ int valueLength = byteBuffer.getInt();
+ checkLength(byteBuffer, valueLength);
+ if (!matches) {
+ byteBuffer.position(byteBuffer.position() + valueLength);
+ continue;
+ }
+ // Keys within a frame are unique - the write path iterates a Map - so
the first match is the only match and
+ // the remaining entries never need to be scanned.
+ byte[] valueBytes = new byte[valueLength];
+ byteBuffer.get(valueBytes);
+ try {
+ return JsonUtils.bytesToObject(valueBytes, Object.class);
+ } catch (IOException e) {
+ LOGGER.error("Caught exception while deserializing value for key: {}",
key.getKey(), e);
+ return null;
+ }
+ }
+ return null;
+ }
+
private static byte[] readLengthPrefixed(ByteBuffer byteBuffer) {
int length = byteBuffer.getInt();
byte[] bytes = new byte[length];
@@ -190,6 +293,12 @@ public class MapUtils {
return bytes;
}
+ private static void checkLength(ByteBuffer byteBuffer, int length) {
+ if (length < 0 || length > byteBuffer.remaining()) {
+ throw new BufferUnderflowException();
+ }
+ }
+
public static String toString(Map<String, Object> map) {
return toString(map, true);
}
diff --git
a/pinot-spi/src/test/java/org/apache/pinot/spi/utils/MapUtilsTest.java
b/pinot-spi/src/test/java/org/apache/pinot/spi/utils/MapUtilsTest.java
index a2ee66ef2a3..4b90ba8ee97 100644
--- a/pinot-spi/src/test/java/org/apache/pinot/spi/utils/MapUtilsTest.java
+++ b/pinot-spi/src/test/java/org/apache/pinot/spi/utils/MapUtilsTest.java
@@ -19,6 +19,8 @@
package org.apache.pinot.spi.utils;
import java.nio.BufferUnderflowException;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
import java.time.LocalDate;
import java.time.LocalTime;
import java.util.HashMap;
@@ -66,6 +68,95 @@ public class MapUtilsTest {
assertNull(deserialized.get("nullValue"), "Null value should be
preserved");
}
+ @Test
+ void testDeserializeMapEntryValue() {
+ Map<String, Object> map = new LinkedHashMap<>();
+ map.put("first", Map.of("nested", List.of(1, 2, 3)));
+ map.put("k8s.workload.name", "pinot-server");
+ map.put("nullValue", null);
+ byte[] serialized = MapUtils.serializeMap(map);
+
+ assertEquals(MapUtils.deserializeMapEntryValue(serialized,
"k8s.workload.name"), "pinot-server");
+
assertEquals(MapUtils.deserializeMapEntryValue(ByteBuffer.wrap(serialized),
"first"),
+ Map.of("nested", List.of(1, 2, 3)));
+ assertNull(MapUtils.deserializeMapEntryValue(serialized, "missing"));
+ assertNull(MapUtils.deserializeMapEntryValue(serialized, "nullValue"));
+
assertNull(MapUtils.deserializeMapEntryValue(MapUtils.serializeMap(Map.of()),
"any"));
+ }
+
+ /// Keys that are the same length and differ only in their trailing bytes
are the case the scanning extractor can
+ /// get wrong: the length check passes for every entry, so the match has to
come from the byte comparison alone.
+ @Test
+ void testDeserializeMapEntryValueWithCollidingKeyShapes() {
+ Map<String, Object> map = new LinkedHashMap<>();
+ map.put("k8s.workload.name", "workload");
+ map.put("k8s.workload.kind", "kind");
+ map.put("k8s.namespace.nam", "namespace");
+ map.put("k8s.workload", "prefix-of-another-key");
+ map.put("k8s.workload.name.suffixed", "longer-than-another-key");
+ byte[] serialized = MapUtils.serializeMap(map, false);
+
+ for (Map.Entry<String, Object> entry : map.entrySet()) {
+ assertEquals(MapUtils.deserializeMapEntryValue(serialized,
entry.getKey()), entry.getValue(),
+ "Value should match for key: " + entry.getKey());
+ }
+ assertNull(MapUtils.deserializeMapEntryValue(serialized,
"k8s.workload.nam"));
+ assertNull(MapUtils.deserializeMapEntryValue(serialized,
"k8s.workload.names"));
+ }
+
+ /// The extractor matches on encoded UTF-8 bytes rather than decoding each
key, so multi-byte keys - and keys whose
+ /// character count differs from their byte count - have to resolve
correctly.
+ @Test
+ void testDeserializeMapEntryValueWithNonAsciiKeys() {
+ Map<String, Object> map = new LinkedHashMap<>();
+ map.put("hôte", "host");
+ map.put("hote", "ascii-host");
+ map.put("命名空间", "namespace");
+ byte[] serialized = MapUtils.serializeMap(map, false);
+
+ for (Map.Entry<String, Object> entry : map.entrySet()) {
+ assertEquals(MapUtils.deserializeMapEntryValue(serialized,
entry.getKey()), entry.getValue(),
+ "Value should match for key: " + entry.getKey());
+ }
+ assertNull(MapUtils.deserializeMapEntryValue(serialized, "命名"));
+ }
+
+ /// An off-heap forward-index view inherits the platform's native byte
order, while the frame is always written
+ /// big-endian. The extractor has to force the order rather than trust the
incoming buffer.
+ @Test
+ void testDeserializeMapEntryValueForcesBigEndian() {
+ byte[] serialized = MapUtils.serializeMap(Map.of("k8s.workload.name",
"pinot-server"));
+ ByteBuffer littleEndian =
ByteBuffer.wrap(serialized).order(ByteOrder.LITTLE_ENDIAN);
+
+ assertEquals(MapUtils.deserializeMapEntryValue(littleEndian,
"k8s.workload.name"), "pinot-server");
+ }
+
+ @Test
+ void testDeserializeMapEntryValueRejectsInvalidValueLength() {
+ String key = "key";
+ byte[] serialized = MapUtils.serializeMap(Map.of(key, "value"));
+ ByteBuffer frame = ByteBuffer.wrap(serialized);
+ frame.position(Integer.BYTES);
+ int keyLength = frame.getInt();
+ int valueLengthOffset = frame.position() + keyLength;
+
+ byte[] negativeLength = serialized.clone();
+ ByteBuffer.wrap(negativeLength).putInt(valueLengthOffset, -1);
+ assertThrows(BufferUnderflowException.class, () ->
MapUtils.deserializeMapEntryValue(negativeLength, key));
+ assertThrows(BufferUnderflowException.class, () ->
MapUtils.deserializeMapEntryValue(negativeLength, "missing"));
+
+ byte[] truncatedValue = serialized.clone();
+ ByteBuffer.wrap(truncatedValue).putInt(valueLengthOffset,
serialized.length);
+ assertThrows(BufferUnderflowException.class, () ->
MapUtils.deserializeMapEntryValue(truncatedValue, key));
+ }
+
+ @Test
+ void testDeserializeMapEntryValueRejectsNegativeMapSize() {
+ byte[] negativeSize =
ByteBuffer.allocate(Integer.BYTES).putInt(-1).array();
+
+ assertThrows(BufferUnderflowException.class, () ->
MapUtils.deserializeMapEntryValue(negativeSize, "key"));
+ }
+
@Test
void testSerializeAndDeserializeWithSpecialCharacters() {
Map<String, Object> map = new HashMap<>();
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]