tarun11Mavani commented on code in PR #19608:
URL: https://github.com/apache/pinot/pull/19608#discussion_r4102762211
##########
pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/openstruct/MutableKeyColumn.java:
##########
Review Comment:
a multi-value key cannot be sealed.
getValue() calls the single-value _forwardIndex.getDictId(docId, null) with
no shape branch. For an MV key _forwardIndex is the
FixedByteMVMutableForwardIndex allocated at L114, which implements only
getDictIdMV (L253, L267). So the call resolves through
MutableForwardIndex.getDictId(int, ctx) → getDictId(int), whose default is
throw new UnsupportedOperationException().
Let's handle this path and also add a test to pin it.
##########
pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/openstruct/MutableKeyColumn.java:
##########
@@ -168,6 +191,26 @@ public void setValue(int docId, Object value) {
_lastIndexedDocId = docId;
}
+ /// Indexes a list of values at `docId`. Elements must already be coerced to
the stored type. Throws
+ /// [IllegalArgumentException] when the list is longer than
[#MAX_NUM_MULTI_VALUES]; the caller drops and meters
+ /// it, the same as a value that cannot be coerced.
+ public void setValues(int docId, Object[] values) {
+ int[] dictIds = new int[values.length];
+ for (int i = 0; i < values.length; i++) {
+ dictIds[i] = _dictionary.index(values[i]);
Review Comment:
setValues mutates the dictionary before the length check runs, so a rejected
row leaves the dictionary ahead of the inverted index permanently.
Can add a check here for `values.length <= MAX_NUM_MULTI_VALUES`
##########
pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/openstruct/MutableOpenStructIndex.java:
##########
@@ -294,7 +373,9 @@ public ColumnMetadata getColumnMetadata(String key) {
}
FieldSpec spec = _childFieldSpecs.get(key);
if (spec == null) {
- spec = new DimensionFieldSpec(key, col.getStoredType(), true);
+ // Shape comes from the column, not a fixed single-value assumption: a
key holding lists has a multi-value
+ // forward index, and metadata that disagreed with it would tell the
query planner the wrong thing.
+ spec = new DimensionFieldSpec(key, col.getStoredType(),
col.isSingleValue());
}
return new SimpleColumnMetadata(spec, _capacity);
Review Comment:
**Blocking: a range predicate on a multi-value key throws
`NegativeArraySizeException`.**
`SimpleColumnMetadata.getMaxNumberOfMultiValues()` returns `UNAVAILABLE`
(`-1`). For an MV key that flows through `ImmutableDataSourceMetadata` L53-57
into `MVScanDocIdIterator` L56, whose matchers allocate `new
int[_maxNumValuesPerMVEntry]` in field initializers (L172, L184, …) — so `new
int[-1]`, thrown from the constructor.
Reachable because of the fix on L378: the shape is now honest (correct), so
`ScanBasedFilterOperator` L60-65 takes the MV branch for the first time. The
`-1` was always wrong, just unreachable. Applies to declared and undeclared MV
keys alike.
Unconditional for range: `getIndexes` L361-364 gives a consuming key only
forward/dictionary/inverted — no sorted or range index — so per
`FilterOperatorUtils` L116-126 a `RANGE` predicate always falls to the scan.
`col['scores'] > 100` throws. `EQ`/`IN` are fine, served by the inverted index.
**Fix — `SimpleColumnMetadata`**: Add an overloaded ctor, defaulting to
`UNAVAILABLE` so the MAP caller is unaffected:
##########
pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/creator/impl/openstruct/OpenStructColumnSplitter.java:
##########
@@ -196,62 +212,133 @@ public Set<String> classify() {
private void addMap(@Nullable Map<String, Object> map) {
if (map != null && !map.isEmpty()) {
- for (Map.Entry<String, Object> entry : map.entrySet()) {
- String key = entry.getKey();
- Object rawValue = entry.getValue();
- if (rawValue == null) {
- continue;
- }
- if (_config.isIgnoredKey(key)) {
- _ignoredKeyDropCount++;
- continue;
- }
- FieldSpec keySpec = _childFieldSpecs.get(key);
- DataType valueType;
- if (keySpec != null) {
- valueType = keySpec.getDataType();
+ OpenStructKeyFlattener.flatten(map, _maxNestedKeyDepth, this::addEntry);
+ }
+ _numDocs++;
+ }
+
+ /// Accumulates one flat key of the current document. `container` marks a
key whose value is a nested object
+ /// rendered as JSON text; see [#classify()] for why those are held out of
automatic dense selection.
+ private void addEntry(String key, @Nullable Object rawValue, boolean
container) {
+ if (rawValue == null) {
+ return;
+ }
+ if (_config.isIgnoredKey(key)) {
+ _ignoredKeyDropCount++;
+ return;
+ }
+ if (container) {
+ _containerKeys.add(key);
+ }
+ FieldSpec keySpec = _childFieldSpecs.get(key);
+ // Shape is decided by the first value the key presents and then sticks,
exactly as its type does. A
+ // collection arriving on a key whose shape is already scalar is handled
as any other value it cannot
+ // represent -- stringified on a STRING key, a coercion failure on a typed
one -- rather than reshaping a
+ // column other documents already wrote to.
+ boolean multiValueKey;
+ if (_presenceBitmaps.containsKey(key)) {
+ multiValueKey = _multiValueKeys.contains(key);
+ } else {
+ // A declaration decides the shape in both directions -- a key declared
single-value stays single-value
+ // even when its values are collections, because the declaration is what
the user asked for. Only an
+ // undeclared key takes its shape from the data.
+ multiValueKey = keySpec != null
+ ? !keySpec.isSingleValueField()
+ : OpenStructTypeInference.asMultiValue(rawValue) != null;
+ if (multiValueKey) {
+ _multiValueKeys.add(key);
Review Comment:
**An undeclared key whose first value is an empty list ends up multi-value
sealed and single-value consuming.**
`_multiValueKeys.add(key)` runs before the empty-list return just below it,
so the emptiness is discarded but the shape decision is kept.
`writeDenseKeyColumn` L500 then reads `singleValue =
!_multiValueKeys.contains(key)`, and L507 wraps the stored scalars into
one-element arrays. `MutableOpenStructIndex.indexEntry` L136 returns before
recording anything, so on that side the next value decides the shape.
Reproduced with docs `{"tags":[]}`, then `{"tags":"x"}`, then docs carrying
only `host`:
```
SHAPE consuming singleValue=true sealed singleValue=false
```
The comment on the mutable side says it matches the splitter, which is the
intent, but the splitter's `_multiValueKeys.add` sits above its early return
and the mutable path has no equivalent side table.
Worth checking the splitter on its own terms as well. Because the empty list
never populates `_presenceBitmaps`, doc 1 re-enters the else branch at L240 and
is stored as a scalar; only the `replaceAll` at L507 rescues it into
multi-value shape at write time. The per-document decision and the final column
shape disagree until that fixup runs.
**Fix:** record the shape only alongside a stored value, on both paths, or
give the mutable index the same sticky side table the splitter has. I am able
to reproduce this with a UT.
##########
pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/openstruct/ImmutableOpenStructDataSource.java:
##########
@@ -106,7 +115,33 @@ public DataSource getDataSource(String key) {
return new NullDataSource(getValueFieldSpec(key),
getDataSourceMetadata().getNumDocs());
}
return _sparseKeyDataSourceCache.computeIfAbsent(key,
- k -> new SparseKeyDataSource(getValueFieldSpec(k), _sparseBlobReader));
+ k -> new SparseKeyDataSource(getValueFieldSpec(k), _sparseBlobReader,
maxNumValues(k)));
+ }
+
+ /// Field spec for a key's values, with an undeclared sparse key's shape
taken from the segment's sparse
+ /// multi-value manifest. Which tier a key lands on is a tuning decision, so
it must not decide the key's
+ /// shape: without this, the same rows would report `STRING[]` on a segment
that materialized the key and a
+ /// scalar `STRING` holding `["a","b"]` on one that put it in the blob, and
a query fanning out over both
+ /// would see two shapes for one column.
+ @Override
+ public FieldSpec getValueFieldSpec(String key) {
+ FieldSpec childFieldSpec = _fieldSpec.getChildFieldSpec(key);
+ if (childFieldSpec != null) {
+ return childFieldSpec;
+ }
+ boolean singleValue = _sparseMultiValueKeys == null ||
!_sparseMultiValueKeys.containsKey(key);
+ return new DimensionFieldSpec(key, FieldSpec.DataType.STRING, singleValue);
Review Comment:
**Question: is the type asymmetry between tiers intended for now?**
Same undeclared key holding `[1, 2, 3]`, same rows, built twice: once pinned
dense, once with `maxDenseKeys=0` forcing it into the blob.
```
dense materialized=true singleValue=false INT
sparse materialized=false singleValue=false STRING
```
Shape agrees, so `sparseMultiValueKeys` is doing its job. Type does not:
`writeDenseKeyColumn` L493 uses the inferred element type, while this line
hardcodes STRING.
The STRING fallback predates this PR, since the old `OpenStructDataSource`
L53-56 default did the same, so the line itself is not a regression. What is
new is the disagreement. Before fix 5 an undeclared list was a STRING scalar on
both tiers, so they matched.
Given this method's javadoc says "Which tier a key lands on is a tuning
decision, so it must not decide the key's shape", is type deliberately left for
a follow-up, or worth persisting alongside the shape in the same manifest?
##########
pinot-core/src/main/java/org/apache/pinot/core/operator/blocks/ProjectionBlock.java:
##########
@@ -61,15 +70,99 @@ public BlockValSet getBlockValueSet(ExpressionContext
expression) {
public BlockValSet getBlockValueSet(String column) {
DataSource dataSource = _dataSourceMap.get(column);
// An OPEN_STRUCT parent is only a handle for per-key resolution — it has
no forward index, so DataFetcher does
- // not register it and it cannot be read as a column. Reject it here
rather than letting the missing
- // ColumnValueReader surface as an NPE.
- if (dataSource instanceof OpenStructDataSource) {
- throw new BadQueryRequestException(
- "OPEN_STRUCT column: " + column + " cannot be selected directly; use
" + column + "['key']");
+ // not register it and it cannot be read through the block cache. Assemble
its document here instead, which is
+ // what the storage layer's contract defers to the query layer. Without
this `SELECT col` and, worse, `SELECT *`
+ // both failed outright on any table carrying one.
+ if (dataSource instanceof OpenStructDataSource openStructDataSource) {
+ return openStructDocuments(column, openStructDataSource);
}
return new ProjectionBlockValSet(_dataBlockCache, column, dataSource);
}
+ /// The column's whole document per row, as JSON text.
+ ///
+ /// Assembled through [OpenStructDataSource#openMapValueReader()], the
reconstruction the storage layer already
+ /// owns and the seal path already uses. Going key by key over
[OpenStructDataSource#getDataSources()] instead
+ /// reads only the materialized keys -- sparse keys share one JSON column
and have no DataSource of their own --
+ /// so every unmaterialized key would silently vanish from the document.
+ private BlockValSet openStructDocuments(String column, OpenStructDataSource
openStructDataSource) {
+ int numDocs = getNumDocs();
+ int[] docIds = getDocIds();
+ String[] documents = new String[numDocs];
+ try (MapValueReader reader = openStructDataSource.openMapValueReader()) {
+ for (int i = 0; i < numDocs; i++) {
+ Map<String, Object> document = reader.getMapValue(docIds[i]);
+ documents[i] = document == null ? "{}" :
JsonUtils.objectToString(renderDocument(document));
+ }
+ } catch (IOException e) {
+ throw new RuntimeException("Failed to read OPEN_STRUCT column: " +
column, e);
+ }
+ return new OpenStructDocumentBlockValSet(documents);
+ }
+
+ /// The document as it should read back: nested, and with no key spelled
twice.
+ ///
+ /// A key nested inside an object is materialized under its path --
`configApi.timeTaken` -- while the object it
+ /// came from stays in the document whole, so the reconstruction carries the
same value both ways. The object is
+ /// the shape the source had, so it wins and the paths into it are dropped.
`.` is an ordinary key character with
+ /// no escape, and that is exactly what makes the container's own entry the
thing that disambiguates: a dotted key
+ /// whose prefix is not itself a key was never a path, so it stays a key
spelled with a dot.
+ private static Map<String, Object> renderDocument(Map<String, Object>
document) {
+ Map<String, Object> rendered = new LinkedHashMap<>(document.size());
+ for (Map.Entry<String, Object> entry : document.entrySet()) {
+ if (!isPathIntoPresentObject(entry.getKey(), document)) {
+ rendered.put(entry.getKey(), renderValue(entry.getValue()));
+ }
+ }
+ return rendered;
+ }
+
+ /// Whether `key` is a path into an object that the document also carries
whole. `configApi.timeTaken` is, when
+ /// `configApi` is a key; a key the document literally spells with a dot is
not, because no prefix of it is a key.
+ private static boolean isPathIntoPresentObject(String key, Map<String,
Object> document) {
+ int dot = key.indexOf(OpenStructKeyFlattener.PATH_SEPARATOR);
+ while (dot >= 0) {
+ if (document.get(key.substring(0, dot)) instanceof Map) {
Review Comment:
I think this guard can't fire: a container key is never a `Map` here.
A nested object is stored as JSON text. At depth >= 2 the flattener does it
outright (`toJson` at `OpenStructKeyFlattener` L148, and `addEntry`'s javadoc
says "rendered as JSON text"); at depth 1 `inferDataType` has no `MAP` case, so
the value falls back to `STRING` via `MapUtils.toString`. The read path keeps
it text deliberately (`SparseKeyDataSource` L172/L328, which is fix #1). So
`document.get(prefix)` is a `String`, the guard returns false, and
`renderValue` L141 falls through the same way.
At depth >= 2 that means the container renders as an escaped string with
every flattened path beside it, the double-render this commit set out to
remove. At depth 1 nothing duplicates, but the object still comes back escaped
rather than nested. The tests miss it because they stub `openMapValueReader()`
with hand-built maps: `testNestedObjectReadsBackNested` builds `configApi` as a
literal `LinkedHashMap`, which reconstruction never produces.
##########
pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/openstruct/SparseKeyDataSource.java:
##########
@@ -153,6 +187,187 @@ public byte[] getBytes(int docId,
ForwardIndexReaderContext context) {
}, FieldSpec.DEFAULT_DIMENSION_NULL_VALUE_OF_BYTES);
Review Comment:
getBytes L187 and getBytesMV L344 fall back to the hardcoded type default,
while the other twelve typed getters route theirs through declaredOr(...).
##########
pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/openstruct/MutableKeyColumn.java:
##########
@@ -243,6 +286,25 @@ public void readDictIds(int[] docIds, int length, int[]
dictIdBuffer, ForwardInd
}
}
+ @Override
+ public int getDictIdMV(int docId, int[] dictIdBuffer,
ForwardIndexReaderContext context) {
+ if (docId > _lastIndexedDocId) {
Review Comment:
**An absent doc below the watermark reads as empty here; the sealed side
reads one default value.**
The guard catches only `docId > _lastIndexedDocId`, so a doc lacking this
key while later docs carry it falls through to `FixedByteMVMutableForwardIndex`
L253, which reads `length` from a zero-initialized header row and returns 0
values. `writeDenseKeyColumn` writes `new
Object[]{childFieldSpec.getDefaultNullValue()}` for that same doc. Ingest
`{"tags":["a"]}`, `{}`, `{"tags":["b"]}` and doc 1 reads `[]` consuming,
`["null"]` sealed.
The single-value twin at L268 survives only because
`FixedByteSVMutableForwardIndex` is a flat array whose unwritten slots read
dictId 0, the reserved default, which the constructor comment relies on by
name. A header-based index breaks that assumption.
`testConsumingMatchesSealedForMultiValueKey` misses it because `mvDoc` puts
`tags` on docs 0 to 4 only, so every absent doc sits past the watermark and
takes the guarded branch.
**Fix:** consult `_presenceBitmap`, the way `getValue` L224 already does. An
empty list never reaches storage, so length 0 always means absent. Both
overloads need it, L290 and L301. Surfaces with null handling off, since both
tiers also mark the doc null.
##########
pinot-core/src/main/java/org/apache/pinot/core/operator/transform/function/ItemTransformFunction.java:
##########
@@ -121,13 +122,71 @@ public long[] transformToLongValuesSV(ValueBlock
valueBlock) {
return valueBlock.getBlockValueSet(_keyPath).getLongValuesSV();
}
+ @Override
+ public float[] transformToFloatValuesSV(ValueBlock valueBlock) {
+ return valueBlock.getBlockValueSet(_keyPath).getFloatValuesSV();
+ }
+
@Override
public double[] transformToDoubleValuesSV(ValueBlock valueBlock) {
return valueBlock.getBlockValueSet(_keyPath).getDoubleValuesSV();
}
+ /// Without this the base class has no way to produce a BIG_DECIMAL: its
conversion switch widens INT, LONG, FLOAT,
+ /// DOUBLE, STRING and BYTES into one, but a key whose own type is already
BIG_DECIMAL matches no case and throws
+ /// `Cannot read SV BIG_DECIMAL as BIG_DECIMAL`. Reading it straight off the
key's value source is both the fix and
+ /// the cheaper path.
+ @Override
+ public BigDecimal[] transformToBigDecimalValuesSV(ValueBlock valueBlock) {
+ return valueBlock.getBlockValueSet(_keyPath).getBigDecimalValuesSV();
+ }
+
@Override
public String[] transformToStringValuesSV(ValueBlock valueBlock) {
return valueBlock.getBlockValueSet(_keyPath).getStringValuesSV();
}
+
+ @Override
+ public byte[][] transformToBytesValuesSV(ValueBlock valueBlock) {
+ return valueBlock.getBlockValueSet(_keyPath).getBytesValuesSV();
+ }
+
+ // A key can hold a list, in which case its value source is multi-value and
the engine asks for the values that
+ // way. The result metadata above already reports the key's own shape, so
these are the reads that shape implies;
+ // without them a multi-value key would be storable and describable but not
readable.
+
+ @Override
+ public int[][] transformToDictIdsMV(ValueBlock valueBlock) {
+ return valueBlock.getBlockValueSet(_keyPath).getDictionaryIdsMV();
+ }
+
+ @Override
+ public int[][] transformToIntValuesMV(ValueBlock valueBlock) {
+ return valueBlock.getBlockValueSet(_keyPath).getIntValuesMV();
+ }
+
+ @Override
+ public long[][] transformToLongValuesMV(ValueBlock valueBlock) {
+ return valueBlock.getBlockValueSet(_keyPath).getLongValuesMV();
+ }
+
+ @Override
+ public float[][] transformToFloatValuesMV(ValueBlock valueBlock) {
+ return valueBlock.getBlockValueSet(_keyPath).getFloatValuesMV();
+ }
+
+ @Override
+ public double[][] transformToDoubleValuesMV(ValueBlock valueBlock) {
+ return valueBlock.getBlockValueSet(_keyPath).getDoubleValuesMV();
+ }
+
+ @Override
+ public String[][] transformToStringValuesMV(ValueBlock valueBlock) {
+ return valueBlock.getBlockValueSet(_keyPath).getStringValuesMV();
+ }
+
+ @Override
+ public byte[][][] transformToBytesValuesMV(ValueBlock valueBlock) {
+ return valueBlock.getBlockValueSet(_keyPath).getBytesValuesMV();
Review Comment:
**`transformToBigDecimalValuesMV` is missing from this block.**
A sparse key is never dictionary-encoded, and
`BaseTransformFunction.transformToBigDecimalValuesMV` has no `BIG_DECIMAL` case
in its no-dictionary switch, so a declared BIG_DECIMAL multi-value key throws
on read:
```
java.lang.IllegalStateException: Cannot read MV BIG_DECIMAL as BIG_DECIMAL
```
Reachable because this PR adds `SparseKeyDataSource.getBigDecimalMV`. The SV
override at L140 exists for the same gap on the other side, so this is its
twin: `return valueBlock.getBlockValueSet(_keyPath).getBigDecimalValuesMV();`
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]