Skip to content

Support flat_object fields as Parquet MAP columns - #22741

Draft
Bukhtawar wants to merge 12 commits into
opensearch-project:mainfrom
Bukhtawar:flat-object-map-support
Draft

Bukhtawar wants to merge 12 commits into
opensearch-project:mainfrom
Bukhtawar:flat-object-map-support

Conversation

@Bukhtawar

Copy link
Copy Markdown
Contributor

Description

Stacked on #22703 (itself stacked on #22713). This PR builds on the multi_value work in that stack and should be reviewed/merged after it: it reuses ParquetField's write plumbing and the schema-reconcile and per-leaf-settings fixes introduced there. The diff shown here is against main; the incremental change is the flat_object mapper wiring, the new FlatObjectParquetField, the MAP-aware leaf resolution in the native writer, and the tests.

A flat_object field on a composite parquet+lucene index is now stored as a single Arrow/Parquet MAP<utf8, utf8> column instead of being silently dropped. This makes OpenTelemetry-style attribute bags indexable in the Parquet primary format:

"ResourceAttributes": { "type": "flat_object" },
"ScopeAttributes":    { "type": "flat_object" },
"LogAttributes":      { "type": "flat_object" }

Each document contributes one map cell holding however many leaves its object had, so a per-document key set is representable in a column whose type is fixed for the whole file. Before this change ArrowSchemaBuilder skipped the unregistered type and the field produced no column at all — the values were accepted and then lost.

This PR is indexing-side only: write path, storage encoding, schema, and durability. Projecting or filtering map keys is deliberately excluded and needs the columnar read path; see Known gap below.

Why MAP rather than parallel LIST columns

MAP is a repeated key/value group that shares repetition levels, so entries stay associated and duplicate keys survive. Flattening into independent parallel LIST columns would need positional correlation, which is a writer contract rather than a schema guarantee — and DocumentInput.addField(fieldType, value) carries no element ordinal, so ragged objects would silently misalign. Sub-columns per key were also rejected: the key set is per-document and unbounded, while a Parquet schema freezes on first flush.

Write path

  • FlatObjectParquetField builds ArrowType.Map(false) with a non-nullable entries: STRUCT<key, value> child, via a new overridable ParquetField#getChildren() hook. getArrowType() stays primitive-only for every other type.
  • One map cell is written per row: startNewValue / endValue, and for each entry entriesVector.setIndexDefined(i) (the entries struct is non-nullable per the Arrow MAP spec — without this the child values read back null) plus key/value setSafe.
  • Null vs empty vs absent are distinct: an absent field is a null cell, "attrs": {} is a zero-entry non-null cell.
  • ParquetField#createField routes MapVector to addToGroup rather than the LIST writer. MapVector extends ListVector, so the guard is explicitly instanceof ListVector && !(instanceof MapVector) — without it a map would be written through the list protocol.
  • FlatObjectFieldMapper wires the previously-dead createPathFieldsForPluggableFormat into a real parseCreateFieldForPluggableFormat, handing the whole object over in one addField call as an ordered List<Map.Entry> of (relative path, value). Keys are relative to the field (http.status, since the column name already carries the prefix); values are stringified, matching flat_object's Lucene representation. Duplicate keys are preserved, so {"a": [1, 2]} yields two a entries.
  • flat_object is single-arity and rejects multi_value: true: one map cell already holds many entries, and LIST<MAP> is not built.

The actual blocker was derived source, not the column

Enabling index.pluggable.dataformat.enabled also enables derived source, and mapping creation calls canDeriveSource() on every field. flat_object cannot rebuild its nested object from the flattened _valueAndPath doc values through the generic fetcher framework, so it failed there — meaning the whole mapping was rejected before any column work mattered. FlatObjectFieldType now declares searchCapability() (previously requestedCapabilities() threw for any searchable flat_object field in a pluggable index), and canDeriveSource() passes only when a pluggable data format is configured, where the columnar store owns _source. A plain Lucene derived_source index still rejects it, unchanged.

deriveSource omits the field rather than throwing. The leaves live only in the primary format's column — LuceneDocumentInput strips doc values and stored fields for a field the primary format owns — so there is nothing in the Lucene reader to rebuild from. Throwing was reachable through TranslogLeafReader when index.derived_source.translog.enabled is set, where it would have failed realtime GET for the whole document rather than just this field.

Native writer: per-leaf settings resolution

A nested column has more than one parquet leaf, so column_path_for/effective_type (LIST-only, single leaf) are replaced by leaves_for/collect_leaves, which walk LIST, MAP and STRUCT and return every leaf with its own type:

Arrow field parquet leaves
primitive n n
LIST<element> n n.list.element
MAP<k,v> n n.entries.key, n.entries.value

Field-level and type-level encoding/compression/bloom settings now apply to each leaf independently, and the type key is derived from the leaf type — so a MAP's utf8 leaves pick up the utf8 defaults instead of being keyed off the Debug string of the whole Map type and matching nothing. nested_leaf_paths_match_arrow_rs_writer pins the derived paths against what ArrowSchemaConverter actually produces, so a change in arrow-rs's nested naming fails loudly instead of silently turning every nested per-column setting back into a no-op.

Validation

index.sort.field now rejects a nested column at creation time. The native k-way merge can only build a sort key from a primitive leaf and would otherwise fail on the first merge — long after the index started accepting writes.

Testing

  • FlatObjectMapColumnIT (new, 8 tests) indexes the OTel-logs mapping on a composite parquet+lucene index: mapping acceptance, documents whose attribute key sets differ per document, the empty / absent / duplicate-key / null-value / deeply-nested leaf shapes, durability across refresh → flush → force-merge (the native merge must resolve this column to two leaves rather than one), a flat_object added by a mapping update on a live index, and the two negative boundaries.
  • VSRManagerTests — end-to-end MAP column through the C Data Interface export and the native arrow-rs writer, including the absent and empty cases.
  • FlatObjectParquetFieldTests — Arrow MAP shape and nullability per the spec, order and duplicate-key preservation, null value inside an entry, and rejection of multi_value.
  • FlatObjectFieldMapperTests — the pluggable parse path emits exactly one addField call carrying every leaf; a null object is skipped.
  • Rust — 4 tests covering both leaves receiving field settings, leaf-type-keyed defaults, leaf-type encoding validation, and the arrow-rs path pinning.
  • CompositeFieldCapabilityIT.testFlatObjectFieldUnsupported becomes testFlatObjectFieldSupported.

Known gap (deliberate, asserted)

Attribute values are written and durable but not yet returned in _source, because derived source is rebuilt from the Lucene secondary, which intentionally holds nothing for a parquet-owned field. testAttributesAreNotYetReturnedInSource asserts this so the state is visible rather than a silent surprise, and must be inverted when the read path lands. Reading a MAP column will also need the DataFusion page-index resolver to map nested columns to their real leaves — the same defect fixed for LIST columns in #22685.

Related Issues

Part of the composite/Parquet data format effort. Stacked on #22703 and #22713.

Check List

  • Functionality includes testing.
  • Commits are signed per the DCO using --signoff.

By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.

Introduce a shared, reusable multi_value mapping parameter in core so
field arity — whether a field holds a single value or an array — is a
first-class mapping concept rather than being defined inside a specific
data-format plugin. This is the primitive requested in opensearch-project#16420, useful to
any consumer that projects a fixed schema (columnar formats, SQL/PPL,
join).

Note on naming: this captures arity, which is distinct from the number
of distinct values a field has and unrelated to the cardinality
aggregation; hence multi_value rather than "cardinality".

- Parameter.multiValueParam() factory on ParametrizedFieldMapper.Builder,
  so any array-capable field type adopts it in one line and stamping is
  consistent. Defaults to false; not updateable (arity is fixed once data
  is written).
- MappedFieldType carries the arity via isMultiValued()/setMultiValued(),
  with FilterFieldType delegating.
- KeywordFieldMapper wires the shared factory and stamps the field type.

Storage/query consumers (e.g. the Parquet LIST encoding) read the flag
back via MappedFieldType#isMultiValued() and are added separately.

Signed-off-by: Bukhtawar Khan <bukhtawa@amazon.com>
Add a `multi_value: true` mapping parameter to keyword fields so a
composite parquet+lucene index stores them as an Arrow/Parquet
LIST<element> column instead of a flat column, preserving order and
duplicates through the full indexing lifecycle.

Write path: KeywordParquetField declares list support and writes each
value into the ListVector (startNewValue/setSafe/endValue); the Arrow
schema builder emits a LIST field when the mapper's field type is
multi-valued; ParquetDocumentInput accumulates repeated values per
field rather than deduplicating them; _source is reconstructed from the
LIST column on the get-by-id path.

Three latent defects that only nested columns expose are fixed:
VSRManager passed the schema field through so list children survive
schema reconcile; per-column Parquet encoding/compression settings
resolve to the list leaf path; and encoding validation checks the
element type rather than the LIST wrapper. Index creation rejects a
multi_value field used as index.sort.field.

Covered by a 9-test durability IT (refresh/flush/force-merge,
replication, node-restart recovery, concurrent indexing during peer
recovery, cross-format partial failure, bulk mixed valid/invalid, null
elements dropped, 50k-element array, dynamic mapping addition), unit
tests, and 4 Rust merge tests.

Signed-off-by: Bukhtawar Khan <bukhtawa@amazon.com>

# Conflicts:
#	server/src/main/java/org/opensearch/index/mapper/KeywordFieldMapper.java
An explicit empty array (`"field": []`) on a `multi_value` keyword field
was reconstructed as a missing field: the document parser's element loop
never fires for an empty array, so no value reached the document input
and the LIST column cell was written null. Reconstructed _source then
could not tell `[]` apart from an absent field — a standalone reader
(datafusion-cli) sees the cell as NULL rather than an empty list.

Register an empty-list entry for an empty array, strictly gated to the
pluggable data format and multi_value leaves so stock indexing is
unchanged. It flows through FieldValuePair.emptyMultiValued into the
writer's existing zero-length-list branch, so the cell is written
empty-but-non-null and reads back as [].

Tighten the durability IT to require every indexed array — including the
empty d3 fixture — to reconstruct as a present non-null list, and add a
ParquetDocumentInput unit test for the empty-array path.

Signed-off-by: Bukhtawar Khan <bukhtawa@amazon.com>
Address review feedback on the multi-value indexing path:

- Key the per-document field accumulator by field name instead of
  MappedFieldType instance identity. Identity is sound today (the parser
  hands back one cached field-type instance per array element) but would
  silently degrade to last-value-wins if a wrapper were ever returned
  per element; name keying is robust to that. Field names are unique per
  logical field within a document, including the _ignored_source.*
  derived-source companion.

- Document that a multi-valued FieldValuePair is mutable and must only
  be read after the document is finalized; the sole consumer reads
  through getFinalInput() once parsing completes.

- Note in registerEmptyMultiValueArray that every scalar-leaf array
  route funnels through parseNonDynamicArray, and that a future
  multi_value type with parsesArrayValue()==true would need equivalent
  empty-array handling.

Tests: pin absent-vs-empty as distinct on-disk states
(testAbsentFieldIsDistinctFromEmptyArray) and name-keyed accumulation
across distinct same-named instances
(testMultiValueAccumulationKeyedByNameNotInstanceIdentity).

Signed-off-by: Bukhtawar Khan <bukhtawa@amazon.com>
A flat_object field in a composite (pluggable data format) index is now
stored as a single Arrow MAP<utf8, utf8> column instead of being
silently skipped, making OTel-style attribute maps (ResourceAttributes,
LogAttributes, ...) indexable in the Parquet primary format.

- FlatObjectParquetField builds MAP(entries: STRUCT<key, value>) via a
  new ParquetField.getChildren() hook and writes one map cell per
  document, preserving document order and duplicate keys; an absent
  field stays a null cell while an explicit empty object becomes a
  zero-entry non-null map.
- FlatObjectFieldMapper wires the previously-dead pluggable-format
  parse path: the whole object is handed over in one addField call as
  ordered (relative path, value) entries. It declares FULL_TEXT_SEARCH
  capability, and accepts derived source only under a pluggable data
  format (the columnar store owns _source reconstruction) while still
  rejecting it for plain Lucene indices.
- ParquetField.createField routes MapVector to addToGroup rather than
  the LIST writer (MapVector extends ListVector).
- The native writer's per-column settings resolver now derives every
  parquet leaf of a nested column (a MAP has key and value leaves), so
  encoding/compression/bloom settings and type-level defaults reach
  nested columns; a test pins the derived paths against what arrow-rs
  actually writes.
- Index creation rejects nested (MAP) columns in index.sort.field up
  front instead of failing at first merge.

Signed-off-by: Bukhtawar Khan <bukhtawa@amazon.com>
…iveSource

FlatObjectMapColumnIT indexes the OpenTelemetry-logs mapping (three
flat_object attribute maps alongside date_nanos/byte/text/keyword) on a
composite parquet+lucene index and covers the write path end to end:
mapping acceptance, documents whose attribute key sets differ per doc,
the empty/absent/duplicate-key/null and deeply-nested leaf shapes,
durability across refresh, flush and force-merge (the native merge must
resolve this column to two leaves rather than one), and a flat_object
field added by a mapping update on a live index.

Negative tests pin the boundaries: flat_object cannot be multi_value
(one map cell already holds many entries) and cannot be an
index.sort.field (a nested column has no single sort key).

deriveSource for flat_object now omits the field instead of throwing.
The field's leaves live only in the primary format's column, so there is
nothing in the Lucene reader to rebuild the object from. Throwing was
reachable through TranslogLeafReader when
index.derived_source.translog.enabled is set, where it would have failed
realtime GET for the whole document rather than just this field.
testAttributesAreNotYetReturnedInSource asserts the resulting gap so it
stays visible and must be inverted when the MAP read path lands.

Signed-off-by: Bukhtawar Khan <bukhtawa@amazon.com>
@github-actions

github-actions Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

PR Reviewer Guide 🔍

(Review updated until commit 375fe95)

Here are some key observations to aid the review process:

🧪 PR contains tests
🔒 No security concerns identified
📝 TODO sections

🔀 Multiple PR themes

Sub-PR theme: Add VSR pre-allocation and advanceExact benchmarks

Relevant files:

  • sandbox/plugins/parquet-data-format/src/test/java/org/opensearch/parquet/vsr/VSRPreAllocationTests.java
  • sandbox/plugins/parquet-data-format/benchmarks/src/main/java/org/opensearch/parquet/benchmark/AdvanceExactBenchmark.java

Sub-PR theme: flat_object → Parquet MAP column support (mapper, ParquetField, read-back, tests)

Relevant files:

  • server/src/main/java/org/opensearch/index/mapper/FlatObjectFieldMapper.java
  • server/src/test/java/org/opensearch/index/mapper/FlatObjectFieldMapperTests.java
  • sandbox/plugins/parquet-data-format/src/main/java/org/opensearch/parquet/fields/core/data/FlatObjectParquetField.java
  • sandbox/plugins/parquet-data-format/src/main/java/org/opensearch/parquet/fields/plugins/CoreDataFieldPlugin.java
  • sandbox/plugins/parquet-data-format/src/test/java/org/opensearch/parquet/fields/core/data/FlatObjectParquetFieldTests.java
  • sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/FlatObjectMapColumnIT.java
  • sandbox/plugins/composite-engine/src/internalClusterTest/java/org/opensearch/composite/FlatObjectEngineParityIT.java
  • sandbox/qa/analytics-engine-rest/src/test/java/org/opensearch/analytics/qa/OtelFlattenedAttributesIndexingIT.java
  • sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/exec/ArrowValues.java

⚡ Recommended focus areas for review

Duplicate-key merge bug

In mapToSource, duplicate keys are grouped via putIfAbsent then put with a merged list. However, on the third and subsequent occurrences of the same key, putIfAbsent returns the already-merged List, and the code wraps that whole list as a single element inside a new 2-element list ([existingList, value]) instead of appending. The result for three duplicates becomes [[a, b], c] rather than [a, b, c]. The existing instanceof List branch appears to try to handle this but only triggers when putIfAbsent returns non-null (i.e., a key was already present) AND existing was already a list — but since putIfAbsent doesn't overwrite, existing on the third call is still the merged list from the second call, so the branch does fire; however, code correctness depends on this subtle ordering. Verify triple-duplicate keys round-trip correctly.

Object existing = out.putIfAbsent(key.toString(), value);
if (existing != null) {
    // Second and later occurrences of the same key: promote to a list, preserving order.
    if (existing instanceof List<?> list) {
        List<Object> merged = new ArrayList<>(list);
        merged.add(value);
        out.put(key.toString(), merged);
    } else {
        List<Object> merged = new ArrayList<>(2);
        merged.add(existing);
        merged.add(value);
        out.put(key.toString(), merged);
    }
}
Broken log format strings

The logger.info calls use {:.2f} (SLF4J/Log4j does not support printf-style format specifiers) with defaultAvgMs, preAllocAvgMs, and speedup. Log4j's parameterized logger only supports {} placeholders, so these will render literally as {:.2f} ms per batch with the numeric argument likely dropped or mis-aligned across the multi-argument calls, making the benchmark output unreadable.

logger.info("=== VSR Pre-Allocation Benchmark ===");
logger.info("Schema: {} varchar fields, batch size: {}, avg value: {} bytes", NUM_VARCHAR_FIELDS, BATCH_SIZE, AVG_VALUE_BYTES);
logger.info("Default (grow-on-demand): {:.2f} ms per batch", defaultAvgMs);
logger.info("Pre-allocated:            {:.2f} ms per batch", preAllocAvgMs);
logger.info("Speedup:                  {:.2f}x", speedup);
Empty-list detection over-matches

value instanceof List<?> list && list.isEmpty() is used to detect an explicit empty array and seed an empty multi-valued pair. But FlatObjectFieldMapper.parseCreateFieldForPluggableFormat also passes a List<Map.Entry<String,String>> as the single value for a flat_object field (which is NOT declared multi_value). For flat_object this branch is not reached because isMultiValued() is false, but any future multi-valued field type whose single element is naturally a List (e.g., geo coordinates) would be mis-seeded as empty. Consider a more explicit signal for "empty array" rather than shape-based detection.

// An explicit empty array (`"field": []`) is signalled by an empty List and seeds a
// zero-value pair, so its LIST cell is written empty-but-non-null rather than null.
final FieldValuePair pair;
if (fieldType.isMultiValued()) {
    pair = value instanceof List<?> list && list.isEmpty()
        ? FieldValuePair.emptyMultiValued(fieldType)
        : FieldValuePair.multiValued(fieldType, value);
} else {
    pair = new FieldValuePair(fieldType, value);
}
seen.put(fieldType.name(), pair);
collectedFields.add(pair);
return;

@github-actions

github-actions Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

PR Code Suggestions ✨

Latest suggestions up to 375fe95

Explore these optional code suggestions:

CategorySuggestion                                                                                                                                    Impact
Possible issue
Fix invalid row group builder method

set_max_row_group_row_count is not a standard method on the parquet
WriterProperties::builder() — the standard API is set_max_row_group_size(usize).
Using an unknown method name will cause compilation failure. Use
set_max_row_group_size(ROW_GROUP_ROWS) to match the parquet crate API.

sandbox/plugins/parquet-data-format/src/main/rust/benches/merge_baseline_only.rs [27]

-let props = WriterProperties::builder().set_max_row_group_row_count(Some(ROW_GROUP_ROWS)).build();
+let props = WriterProperties::builder().set_max_row_group_size(ROW_GROUP_ROWS).build();
Suggestion importance[1-10]: 8

__

Why: The set_max_row_group_row_count method does not exist in the standard parquet WriterProperties builder API; the correct method is set_max_row_group_size. This would cause a compilation failure in the benchmark file.

Medium
Possible bug
Gate search capability on searchability

searchCapability() unconditionally returns FULL_TEXT_SEARCH even when the field is
not searchable (isSearchable() == false, e.g. index: false under a pluggable data
format). Callers using this capability may then incorrectly assume the field is
queryable via full-text search. Return the capability only when the field is
actually searchable, or gate the caller on isSearchable().

server/src/main/java/org/opensearch/index/mapper/FlatObjectFieldMapper.java [279-282]

 @Override
 protected FieldTypeCapabilities.Capability searchCapability() {
-    return FieldTypeCapabilities.Capability.FULL_TEXT_SEARCH;
+    return isSearchable() ? FieldTypeCapabilities.Capability.FULL_TEXT_SEARCH : super.searchCapability();
 }
Suggestion importance[1-10]: 4

__

Why: Reasonable observation that searchCapability() should reflect actual searchability when index: false. However, without seeing how searchCapability() is consumed and whether callers already gate on isSearchable(), the impact is uncertain, and the base implementation behavior is unknown.

Low
Allow null values in map entries

Map.entry(relativePath, value) throws NullPointerException when value is null (e.g.
from a configured null_value that resolves to null, or edge cases in value parsing).
Since the MAP column supports null values (as shown by FlatObjectParquetField
handling null entry values), use an entry implementation that permits nulls to avoid
dropping documents in pluggable-format mode.

server/src/main/java/org/opensearch/index/mapper/FlatObjectFieldMapper.java [808-811]

 final String leafPath = Strings.collectionToDelimitedString(path, ".");
 final String relativePath = leafPath.substring(name().length() + 1);
 if (entries != null) {
     // Pluggable-format mode: the leaf becomes one MAP entry. No Lucene fields are built —
     // the columnar format owns storage for this field.
-    entries.add(Map.entry(relativePath, value));
+    entries.add(new java.util.AbstractMap.SimpleEntry<>(relativePath, value));
     parser.nextToken();
     return;
 }
Suggestion importance[1-10]: 3

__

Why: The concern about Map.entry NPE is technically valid, but in the flat_object mapper's leaf-parsing path, null values are already filtered out earlier (the mapper's KeywordFieldMapper.parseCreateFieldForPluggableFormat returns early on null, as documented in testNullElementsInsideArrayAreDropped), so value reaching this line should not be null in practice. Low impact.

Low
General
Document map leaf configuration ambiguity

For MAP columns both key and value leaves get treated as independently configurable
with the same field-level settings, but bloom-filter/encoding settings from a user
targeting attrs will now silently be applied twice (to both key and value) even if
the user only meant one side. Consider documenting this behavior or restricting
settings to value leaves only for MAP types, otherwise users cannot separately
configure key vs value columns.

sandbox/plugins/parquet-data-format/src/main/rust/src/writer_properties_builder.rs [296-310]

 for (column_path, arrow_type) in leaves {
+    // NOTE: For MAP columns, per-field settings apply to BOTH key and value leaves.
+    // Users cannot separately target them; if that becomes needed, extend FieldConfig
+    // with an explicit leaf selector.
     let column_path = column_path.clone();
     let arrow_type = *arrow_type;
     let type_key_owned = arrow_type_key(arrow_type);
     let type_key = Some(type_key_owned.as_str());
     builder = Self::apply_leaf_config(
         builder,
         config,
         field_name,
         index_cfg,
         column_path,
         arrow_type,
         type_key,
     )?;
 }
Suggestion importance[1-10]: 3

__

Why: The suggestion is a minor documentation improvement noting that MAP settings apply to both key and value leaves. The existing code already has a comment addressing this, so the additional documentation offers marginal value.

Low
Use null-tolerant singleton list wrapper

List.of(parseValue) throws NullPointerException if parseValue is null, but the null
check above only handles the case where parseValue itself is null before reaching
this line — it doesn't catch a null single scalar wrapped here. Actually the null
case is handled above, but if parseValue is a List containing nulls that's fine;
however if a scalar null slips through some path, List.of would NPE. Consider using
Collections.singletonList to be defensive.

sandbox/plugins/parquet-data-format/src/main/java/org/opensearch/parquet/fields/ParquetField.java [154]

-List<?> values = parseValue instanceof List<?> list ? list : List.of(parseValue);
-int start = listVector.startNewValue(row);
-FieldVector dataVector = listVector.getDataVector();
-for (int i = 0; i < values.size(); i++) {
-    Object value = values.get(i);
-    if (value == null) {
-        dataVector.setNull(start + i);
-    } else {
-        addToVector(dataVector, start + i, value);
-    }
-}
+List<?> values = parseValue instanceof List<?> list ? list : java.util.Collections.singletonList(parseValue);
Suggestion importance[1-10]: 2

__

Why: The null case for parseValue is already handled a few lines above with listVector.setNull(row); return;, so List.of(parseValue) is only reached with a non-null scalar. The suggestion's own analysis acknowledges the null is handled. Minimal defensive value.

Low
Unify list offset-width constants

getInt((long) idx * ListVector.OFFSET_WIDTH) accepts a long byte offset but Arrow's
getInt(long) expects the byte index directly; verify this call signature — most
Arrow buffer accessors take a byte index. If this compiles correctly (Netty's
ArrowBuf), the cast is fine, but note that MapVector.OFFSET_WIDTH is used
inconsistently with ListVector.OFFSET_WIDTH in mapToSource above (both are 4, so
functionally equivalent, but style-wise inconsistent). Use one constant to avoid
confusion.

sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/exec/ArrowValues.java [100-108]

+if (vec instanceof ListVector listVector && vec instanceof MapVector == false) {
+    FieldVector data = listVector.getDataVector();
+    int start = listVector.getOffsetBuffer().getInt((long) idx * ListVector.OFFSET_WIDTH);
+    int end = listVector.getOffsetBuffer().getInt((long) (idx + 1) * ListVector.OFFSET_WIDTH);
+    List<Object> values = new ArrayList<>(Math.max(0, end - start));
+    for (int i = start; i < end; i++) {
+        values.add(toSourceValue(data, i));
+    }
+    return values;
+}
 
-
Suggestion importance[1-10]: 1

__

Why: A pure style nit — both constants equal 4, and the improved_code is identical to the existing_code, offering no actual change. Very low impact.

Low

Previous suggestions

Suggestions up to commit 6e619bc
CategorySuggestion                                                                                                                                    Impact
Possible issue
Use correct row-group size setter

set_max_row_group_row_count does not appear to be a standard WriterProperties API
(the standard method is set_max_row_group_size). If this compiles against a
customized parquet fork, that's fine, but otherwise this bench will fail to build.
Verify the method name against the parquet crate version in use.

sandbox/plugins/parquet-data-format/src/main/rust/benches/merge_baseline_only.rs [27]

-let props = WriterProperties::builder().set_max_row_group_row_count(Some(ROW_GROUP_ROWS)).build();
+let props = WriterProperties::builder().set_max_row_group_size(ROW_GROUP_ROWS).build();
Suggestion importance[1-10]: 6

__

Why: If set_max_row_group_row_count is not the actual API in the parquet crate version used, the bench won't compile. This is a reasonable correctness check, though it depends on the exact crate version.

Low
Avoid NPE on null leaf values in entries

Map.entry(relativePath, value) throws NullPointerException if value is null, but the
null-value branch above can leave value as null when nullValue is not configured. In
pluggable-format mode this would abort parsing on any null leaf; use an entry type
that permits null values (e.g. new AbstractMap.SimpleEntry<>) or skip null values
consistently with the Lucene path.

server/src/main/java/org/opensearch/index/mapper/FlatObjectFieldMapper.java [804-811]

 final String leafPath = Strings.collectionToDelimitedString(path, ".");
 final String relativePath = leafPath.substring(name().length() + 1);
 if (entries != null) {
     // Pluggable-format mode: the leaf becomes one MAP entry. No Lucene fields are built —
     // the columnar format owns storage for this field.
-    entries.add(Map.entry(relativePath, value));
+    entries.add(new java.util.AbstractMap.SimpleEntry<>(relativePath, value));
     parser.nextToken();
     return;
 }
Suggestion importance[1-10]: 3

__

Why: The code path before this appears to normalize the value, and null values are typically handled earlier in parseToken (per the null-elements-dropped test). The suggestion is speculative about a NPE risk, but Map.entry does throw on nulls, so if a null could reach here it would be a real issue — however evidence in the diff suggests nulls are filtered upstream.

Low
General
Ensure thread-safe visibility of new flag

MappedFieldType instances are typically treated as immutable and shared across
threads once built. Adding a mutable multiValued field with a public setter risks
visibility/race issues if setMultiValued is called after publication. Consider
making the field volatile, or better, only setting it during construction/build and
documenting it as build-time-only, matching how other similar flags (e.g.,
eagerGlobalOrdinals) are already used.

server/src/main/java/org/opensearch/index/mapper/MappedFieldType.java [100]

-+    private boolean multiValued;
++    private volatile boolean multiValued;
Suggestion importance[1-10]: 4

__

Why: The concern about thread visibility is legitimate, but the new field mirrors existing patterns like eagerGlobalOrdinals which are also non-volatile, so the impact is limited and consistency with existing code likely matters more.

Low
Clarify multi_value vs list-payload distinction

When a multi_value field is first seen with a non-empty List<?> value (e.g. from
FlatObjectFieldMapper.parseCreateFieldForPluggableFormat which passes a list of
entries as a single value), it will be wrapped as multiValued(fieldType, listValue),
treating the whole list as one element rather than as multiple values. Consider
clarifying: only bare-scalar addField calls should accumulate; single-call list
values (like flat_object entries) should be treated as a scalar payload. Verify this
interaction is handled correctly for flat_object which is not multi-valued but
delivers a List.

sandbox/plugins/parquet-data-format/src/main/java/org/opensearch/parquet/writer/ParquetDocumentInput.java [62-75]

 FieldValuePair existing = seen.get(fieldType.name());
 if (existing == null) {
     // Fields declared `multi_value: true` in the mapping start out as a list of one so the
     // value shape reaching the VSR is the same whether the document had one value or several.
     // An explicit empty array (`"field": []`) is signalled by an empty List and seeds a
     // zero-value pair, so its LIST cell is written empty-but-non-null rather than null.
+    // Note: fields like flat_object are NOT multi_valued but deliver a List payload — those
+    // fall through to the scalar branch which is correct.
     final FieldValuePair pair;
     if (fieldType.isMultiValued()) {
         pair = value instanceof List<?> list && list.isEmpty()
             ? FieldValuePair.emptyMultiValued(fieldType)
             : FieldValuePair.multiValued(fieldType, value);
     } else {
         pair = new FieldValuePair(fieldType, value);
     }
Suggestion importance[1-10]: 3

__

Why: Only asks the user to verify existing behavior and suggests a comment addition. The code appears correct as flat_object is not multi_valued and thus takes the scalar branch, which the suggestion itself acknowledges.

Low
Assert vector presence before dispatch

managedVSR.getVector(fieldType.name()) may return null for fields not in the schema
(e.g. metadata fields not yet reconciled), causing the instanceof check to fall
through silently to addToGroup where a NullPointerException could occur. Add an
explicit null check or assertion for clearer failure semantics.

sandbox/plugins/parquet-data-format/src/main/java/org/opensearch/parquet/fields/ParquetField.java [130-138]

 FieldVector vector = managedVSR.getVector(fieldType.name());
+assert vector != null : "No vector present in VSR for field [" + fieldType.name() + "]";
 // MapVector extends ListVector but carries a (key, value) struct child instead of a plain
 // element, so it must not go through the LIST writer. Its owning ParquetField writes it in
 // addToGroup, where the key/value shape of the parsed value is known.
 if (vector instanceof ListVector listVector && vector instanceof MapVector == false) {
     writeList(fieldType, managedVSR, listVector, parseValue);
     return;
 }
 addToGroup(fieldType, managedVSR, parseValue);
Suggestion importance[1-10]: 2

__

Why: Minor defensive-programming suggestion; adds an assertion for clarity but does not fix a real bug, as the existing instanceof check already handles null safely (null is not instanceof anything).

Low
Guard against null keys in leaf entries

entry.getKey() can theoretically be null for some Map.Entry implementations; calling
.toString() on it would throw NPE. Since keys are structural (relative path), a null
key indicates a programming error — add an explicit null check to fail with a clear
message rather than a bare NPE.

sandbox/plugins/analytics-backend-lucene/src/main/java/org/opensearch/be/lucene/LuceneFieldFactoryRegistry.java [104-111]

 for (Object element : (List<?>) value) {
     if (element instanceof Map.Entry<?, ?> entry) {
         // A null leaf value is dropped by the mapper before it reaches here; guard anyway so
         // a future change cannot silently index the string "null".
         if (entry.getValue() == null) {
             continue;
         }
+        if (entry.getKey() == null) {
+            throw new IllegalArgumentException("flat_object field [" + ft.name() + "] must not have null keys");
+        }
         final String relativePath = entry.getKey().toString();
Suggestion importance[1-10]: 2

__

Why: Keys come from the mapper which constructs them from parser field names, so null keys are essentially impossible. The suggestion adds defensive code with little practical value.

Low
Guard against malformed MAP entries type

The MAP entries child is a Struct; recursing into it via collect_leaves with its
data_type() will hit the Struct(children) arm and correctly emit key/value leaves.
However, if entries is not a Struct (malformed schema), this silently emits a
primitive leaf at the entries path. Consider asserting or documenting that
entries.data_type() must be a Struct, or explicitly matching it to avoid producing
an invalid leaf path.

sandbox/plugins/parquet-data-format/src/main/rust/src/writer_properties_builder.rs [110-114]

 Map(entries, _) => {
     let mut parts = prefix;
     parts.push(entries.name().clone());
+    debug_assert!(matches!(entries.data_type(), arrow::datatypes::DataType::Struct(_)),
+        "MAP entries must be a Struct of key/value");
     collect_leaves(parts, entries.data_type(), out);
 }
Suggestion importance[1-10]: 2

__

Why: Adding a debug_assert! for a well-known Arrow invariant (MAP entries is always a Struct) offers marginal defensive value; malformed schemas would fail earlier.

Low
Suggestions up to commit 9bdbcc5
CategorySuggestion                                                                                                                                    Impact
Possible issue
Fix incorrect WriterProperties builder method

WriterProperties::builder() does not have a set_max_row_group_row_count method in
the parquet crate; the correct method is set_max_row_group_size. This will fail to
compile. The same issue exists in merge_optimized_only.rs.

sandbox/plugins/parquet-data-format/src/main/rust/benches/merge_baseline_only.rs [27]

-let props = WriterProperties::builder().set_max_row_group_row_count(Some(ROW_GROUP_ROWS)).build();
+let props = WriterProperties::builder().set_max_row_group_size(ROW_GROUP_ROWS).build();
Suggestion importance[1-10]: 8

__

Why: If accurate, this identifies a compile error in both benchmark files. set_max_row_group_size is the standard parquet-rs API name, making this a plausible and high-impact correctness fix.

Medium
Set child vector value count after list writes

ListVector.startNewValue(row) returns an offset based on the vector's internal
lastSet, which may not equal the child vector's high-water mark if rows were
previously written out of order or skipped. Writing at start + i without first
ensuring dataVector capacity via setValueCount/setInitialCapacity can leave stale
values in intermediate slots between the previous row's end and start. Consider
explicitly calling dataVector.setValueCount(start + values.size()) or verifying the
invariant that rows are always written in ascending order.

sandbox/plugins/parquet-data-format/src/main/java/org/opensearch/parquet/fields/ParquetField.java [154-166]

 List<?> values = parseValue instanceof List<?> list ? list : List.of(parseValue);
 int start = listVector.startNewValue(row);
 FieldVector dataVector = listVector.getDataVector();
 for (int i = 0; i < values.size(); i++) {
     Object value = values.get(i);
     if (value == null) {
         dataVector.setNull(start + i);
     } else {
         addToVector(dataVector, start + i, value);
     }
 }
+dataVector.setValueCount(start + values.size());
 listVector.endValue(row, values.size());
Suggestion importance[1-10]: 3

__

Why: The concern about child vector capacity is speculative; setSafe methods on Arrow vectors typically handle capacity growth automatically, and rows are written in ascending order in this codebase.

Low
General
Guard substring against short leaf path

leafPath.substring(name().length() + 1) will throw StringIndexOutOfBoundsException
if leafPath equals name() (i.e., a leaf directly at the field root with no nested
key). While unusual for flat_object, a malformed document could trigger this. Guard
the substring or verify leafPath.length() > name().length() before slicing.

server/src/main/java/org/opensearch/index/mapper/FlatObjectFieldMapper.java [745-753]

 final String leafPath = Strings.collectionToDelimitedString(path, ".");
-final String relativePath = leafPath.substring(name().length() + 1);
+final String relativePath = leafPath.length() > name().length() ? leafPath.substring(name().length() + 1) : "";
 if (entries != null) {
-    // Pluggable-format mode: the leaf becomes one MAP entry. No Lucene fields are built —
-    // the columnar format owns storage for this field.
     entries.add(Map.entry(relativePath, value));
     parser.nextToken();
     return;
 }
Suggestion importance[1-10]: 4

__

Why: A defensive guard against StringIndexOutOfBoundsException is reasonable, though the case (a leaf value directly at field root without a key) is unlikely given flat_object requires an object structure.

Low
Detect fields expanding to zero leaves

If a top-level field expands to zero leaves (e.g., an empty struct), the field's
config will be silently ignored, mirroring the previous silent no-op bug this PR
fixes. Consider returning an error when leaves is empty to surface misconfiguration
explicitly.

sandbox/plugins/parquet-data-format/src/main/rust/src/writer_properties_builder.rs [296-310]

+if leaves.is_empty() {
+    return Err(format!(
+        "Field '{}' in field_configs has no primitive leaves",
+        field_name
+    ));
+}
 for (column_path, arrow_type) in leaves {
     let column_path = column_path.clone();
     let arrow_type = *arrow_type;
     let type_key_owned = arrow_type_key(arrow_type);
     let type_key = Some(type_key_owned.as_str());
     builder = Self::apply_leaf_config(
         builder,
         config,
         field_name,
         index_cfg,
         column_path,
         arrow_type,
         type_key,
     )?;
 }
Suggestion importance[1-10]: 3

__

Why: Edge case of empty struct is unlikely in practice, but explicitly erroring would prevent silent misconfiguration consistent with the PR's intent.

Low
Clarify list-wrapping semantics for multi-valued pair

The multiValued factory unconditionally adds firstValue even when it is a List
(e.g., the flat_object mapper passes a List<Map.Entry> as a single value). This will
double-wrap into List<List<...>> and break downstream writers that expect the pair's value to
be either a scalar or a flat list of elements. Consider handling the List case
specifically or documenting the invariant.

sandbox/plugins/parquet-data-format/src/main/java/org/opensearch/parquet/writer/FieldValuePair.java [77-81]

+public static FieldValuePair multiValued(MappedFieldType fieldType, Object firstValue) {
+    List<Object> values = new ArrayList<>(1);
+    values.add(firstValue);
+    return new FieldValuePair(fieldType, values);
+}
 
-
Suggestion importance[1-10]: 2

__

Why: The suggestion raises a concern but the improved_code is identical to existing_code, providing no actual change. Additionally, flat_object is not multi_value (it's single-arity), so it doesn't take this path.

Low
Verify list-wrapping for multi-valued object types

When a multi_value field receives a non-empty List value (e.g., flat_object's
leaf-entry list arriving in a single addField call), it will be wrapped as a single
element in the accumulator list, producing List<List<...>>. This may conflict with
FlatObjectParquetField.addToGroup, which casts parseValue directly to List<?>. Confirm
whether flat_object goes through the multi-valued accumulation path or bypasses it.

sandbox/plugins/parquet-data-format/src/main/java/org/opensearch/parquet/writer/ParquetDocumentInput.java [68-75]

+if (fieldType.isMultiValued()) {
+    pair = value instanceof List<?> list && list.isEmpty()
+        ? FieldValuePair.emptyMultiValued(fieldType)
+        : FieldValuePair.multiValued(fieldType, value);
+} else {
+    pair = new FieldValuePair(fieldType, value);
+}
 
-
Suggestion importance[1-10]: 2

__

Why: The improved_code is identical to existing_code. Also, flat_object is not declared multi_value (it's single-arity, as confirmed by FlatObjectParquetField.supportsMultiValue() returning false), so this path isn't taken for flat_object.

Low
Clarify struct handling scope in leaf collection

When collect_leaves recurses into a Struct that is the top-level field (not the
entries group of a Map), the prefix is cloned for every child but the original
prefix is dropped unused after the loop. More importantly, if a top-level Arrow
field is itself a Struct, the leaves will be emitted, but the per-field
validation/type_key resolution assumes a single homogeneous field type — mixed-type
struct children will each be validated against their own leaf type, which is
correct, but the caller uses one field_name for all leaves. Consider explicitly
documenting or restricting struct handling to the Map entries case to avoid
unintended behavior for top-level struct fields.

sandbox/plugins/parquet-data-format/src/main/rust/src/writer_properties_builder.rs [115-121]

 Struct(children) => {
+    // Reached only via a Map's `entries` group in current usage; top-level Struct fields
+    // would apply the same field-level config to every child leaf, which may or may not
+    // be intended.
     for child in children {
         let mut parts = prefix.clone();
         parts.push(child.name().clone());
         collect_leaves(parts, child.data_type(), out);
     }
 }
Suggestion importance[1-10]: 2

__

Why: The suggestion only adds a comment without functional change; it's a minor documentation improvement.

Low
Suggestions up to commit 97f31af
CategorySuggestion                                                                                                                                    Impact
Possible issue
Use correct WriterProperties API method

set_max_row_group_row_count is not a standard method on WriterProperties::builder()
in the parquet crate; the correct method is set_max_row_group_size. If this method
does not exist, the benchmark will fail to compile. Verify the API name against the
parquet crate version in use.

sandbox/plugins/parquet-data-format/src/main/rust/benches/merge_baseline_only.rs [27]

-let props = WriterProperties::builder().set_max_row_group_row_count(Some(ROW_GROUP_ROWS)).build();
+let props = WriterProperties::builder().set_max_row_group_size(ROW_GROUP_ROWS).build();
Suggestion importance[1-10]: 7

__

Why: If set_max_row_group_row_count is not a real API method, the benchmark will fail to compile. The standard parquet crate API is set_max_row_group_size, so this is potentially a correctness/compilation issue worth verifying.

Medium
Handle empty-list marker after accumulation

When an existing multi-valued pair is found and a new value arrives that is itself a
List (e.g. from an inner empty-array signaling call), existing.addValue(list) will
nest a list inside the values list. Since registerEmptyMultiValueArray calls
addField(fieldType, List.of()), if this occurs after values are already accumulated
the empty list would be added as a nested element rather than a no-op. Consider
ignoring an empty-list value when the pair already has entries, or documenting that
this can only occur when existing is null.

sandbox/plugins/parquet-data-format/src/main/java/org/opensearch/parquet/writer/ParquetDocumentInput.java [62-89]

 FieldValuePair existing = seen.get(fieldType.name());
 if (existing == null) {
     ...
     seen.put(fieldType.name(), pair);
     collectedFields.add(pair);
     return;
 }
 if (existing.isMultiValued() == false) {
     throw new MapperParsingException(
         "Cannot accept multiple values for field: ["
             + fieldType.name()
+...
+// Ignore an empty-list marker if values were already accumulated for this field.
+if (value instanceof List<?> list && list.isEmpty()) {
+    return;
+}
+existing.addValue(value);
Suggestion importance[1-10]: 4

__

Why: The scenario is plausible only if empty-array registration occurs after values were accumulated; the parser flow makes this unlikely, but adding a guard is a modest defensive improvement.

Low
General
Prefer documented API over raw offset buffer

The null check vec.isNull(idx) at the method top handles absent list cells, but
ensure this branch runs before the generic getObject path. Also, using
ListVector.OFFSET_WIDTH for offset arithmetic is correct, but consider using
listVector.getElementStartIndex(idx) / getElementEndIndex(idx) which are the
documented API and safer against future changes to offset encoding.

sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/exec/ArrowValues.java [91-100]

 if (vec instanceof ListVector listVector && vec instanceof MapVector == false) {
     FieldVector data = listVector.getDataVector();
-    int start = listVector.getOffsetBuffer().getInt((long) idx * ListVector.OFFSET_WIDTH);
-    int end = listVector.getOffsetBuffer().getInt((long) (idx + 1) * ListVector.OFFSET_WIDTH);
+    int start = listVector.getElementStartIndex(idx);
+    int end = listVector.getElementEndIndex(idx);
     List<Object> values = new ArrayList<>(Math.max(0, end - start));
     for (int i = start; i < end; i++) {
         values.add(toSourceValue(data, i));
     }
     return values;
 }
Suggestion importance[1-10]: 5

__

Why: Using getElementStartIndex/getElementEndIndex is cleaner and more future-proof than raw offset buffer arithmetic; a reasonable readability/maintainability improvement.

Low
Guard against null keys defensively

Guard against a null key in addition to a null value. entry.getKey().toString() will
NPE if the key is ever null, which could cause an entire document to fail indexing
due to one malformed entry. Skip the entry defensively, mirroring the null-value
handling.

sandbox/plugins/analytics-backend-lucene/src/main/java/org/opensearch/be/lucene/LuceneFieldFactoryRegistry.java [104-113]

 for (Object element : (List<?>) value) {
     if (element instanceof Map.Entry<?, ?> entry) {
         // A null leaf value is dropped by the mapper before it reaches here; guard anyway so
         // a future change cannot silently index the string "null".
-        if (entry.getValue() == null) {
+        if (entry.getKey() == null || entry.getValue() == null) {
             continue;
         }
         final String relativePath = entry.getKey().toString();
         final String leafValue = entry.getValue().toString();
Suggestion importance[1-10]: 3

__

Why: Adding a null-key guard is a minor defensive improvement; in practice the mapper does not emit null keys, so the impact is low but it is a reasonable robustness addition.

Low
Handle empty nested fields explicitly

When collect_leaves is called on a Map's entries group, entries.data_type() is
Struct, but its prefix already includes the entries segment. This branch is also
reached at the top level when a user field is itself a Struct — in that case an
intermediate Struct field with no primitive descendants (or nested structures) will
produce column paths correctly, but note that a top-level empty Struct will yield
zero leaves, causing type_map.get(field_name) to return an empty Vec and silently
drop any field_configs for that field without error. Consider validating that at
least one leaf exists, or explicitly erroring for unsupported top-level types.

sandbox/plugins/parquet-data-format/src/main/rust/src/writer_properties_builder.rs [115-121]

 Struct(children) => {
+    if children.is_empty() {
+        return;
+    }
     for child in children {
         let mut parts = prefix.clone();
         parts.push(child.name().clone());
         collect_leaves(parts, child.data_type(), out);
     }
 }
Suggestion importance[1-10]: 3

__

Why: The suggestion raises a valid edge case (empty Struct producing no leaves), but the proposed improved_code (early return on empty) does not actually address it — it produces the same behavior. The concern is minor and unlikely in practice.

Low
Use nullable singleton list for robustness

List.of(parseValue) throws NPE if parseValue is non-null but wrapped as a single
null-containing container elsewhere; more importantly, when parseValue is a single
non-list value the branch is hit but not covered when parseValue == null — that is
already handled above. However, List.of(parseValue) will NPE if parseValue itself is
null which is impossible here, but using Collections.singletonList would be safer if
invariants change. Consider using Collections.singletonList to avoid future NPE
risk.

sandbox/plugins/parquet-data-format/src/main/java/org/opensearch/parquet/fields/ParquetField.java [154-165]

-List<?> values = parseValue instanceof List<?> list ? list : List.of(parseValue);
+List<?> values = parseValue instanceof List<?> list ? list : java.util.Collections.singletonList(parseValue);
 int start = listVector.startNewValue(row);
 FieldVector dataVector = listVector.getDataVector();
 for (int i = 0; i < values.size(); i++) {
     Object value = values.get(i);
     if (value == null) {
         dataVector.setNull(start + i);
     } else {
         addToVector(dataVector, start + i, value);
     }
 }
 listVector.endValue(row, values.size());
Suggestion importance[1-10]: 2

__

Why: The suggestion is speculative — parseValue is guaranteed non-null at this point due to the earlier check, so List.of is safe. The change offers marginal defensive value.

Low
Suggestions up to commit 1c9e4d2
CategorySuggestion                                                                                                                                    Impact
Possible issue
Guard substring against equal-length leaf path

leafPath.substring(name().length() + 1) assumes leafPath always starts with name() +
".", but for a top-level leaf directly under the field (e.g. a flat_object whose
value is a primitive at the root object), leafPath may equal name() itself, causing
StringIndexOutOfBoundsException. Guard the substring computation, or use the deque
directly to build the relative path.

server/src/main/java/org/opensearch/index/mapper/FlatObjectFieldMapper.java [746-753]

-final String relativePath = leafPath.substring(name().length() + 1);
+final String relativePath = leafPath.length() > name().length() + 1
+    ? leafPath.substring(name().length() + 1)
+    : "";
 if (entries != null) {
-    // Pluggable-format mode: the leaf becomes one MAP entry. No Lucene fields are built —
-    // the columnar format owns storage for this field.
     entries.add(Map.entry(relativePath, value));
     parser.nextToken();
     return;
 }
Suggestion importance[1-10]: 4

__

Why: The concern about leafPath.substring(name().length() + 1) is plausible defensively, but in practice the deque always contains the field name plus at least one child key at a value leaf, so this edge case is unlikely to trigger. Still, a minor robustness improvement.

Low
General
Avoid overloading addField with List sentinel for empty arrays

Detecting an empty array via value instanceof List<?> list && list.isEmpty() is
fragile: a legitimate single scalar value that happens to be a non-List works, but
if the parser ever passes a List containing one element for a normal multi-value
case (e.g. from registerEmptyMultiValueArray semantics extended), the first element
would be treated as the whole value. Consider using a dedicated sentinel or a
distinct API call from DocumentParser to signal an empty array, rather than
overloading addField with a List argument.

sandbox/plugins/parquet-data-format/src/main/java/org/opensearch/parquet/writer/ParquetDocumentInput.java [69-75]

 if (fieldType.isMultiValued()) {
-    pair = value instanceof List<?> list && list.isEmpty()
-        ? FieldValuePair.emptyMultiValued(fieldType)
-        : FieldValuePair.multiValued(fieldType, value);
+    if (value instanceof List<?> list && list.isEmpty()) {
+        pair = FieldValuePair.emptyMultiValued(fieldType);
+    } else {
+        pair = FieldValuePair.multiValued(fieldType, value);
+    }
 } else {
     pair = new FieldValuePair(fieldType, value);
 }
Suggestion importance[1-10]: 3

__

Why: The suggestion is a minor stylistic refactor that expands a ternary into an if/else; it doesn't change behavior and the current implementation is correct given the parser contract.

Low
Reject null map keys explicitly

entry.getKey().toString() will throw a NullPointerException if the parsed key is
null. While FlatObjectFieldMapper normally supplies a non-null relative path,
defensively rejecting a null key here (or in the mapper) would surface a clearer
error than an NPE deep inside the writer, since a null key would also violate the
Arrow MAP spec's non-nullable key requirement.

sandbox/plugins/parquet-data-format/src/main/java/org/opensearch/parquet/fields/core/data/FlatObjectParquetField.java [69-75]

 for (int i = 0; i < entries.size(); i++) {
     Map.Entry<?, ?> entry = (Map.Entry<?, ?>) entries.get(i);
     int index = start + i;
-    // The entries struct is non-nullable per the Arrow MAP spec, so every slot written must
-    // have its validity bit set; without this the child values read back as null.
+    Object key = entry.getKey();
+    if (key == null) {
+        throw new IllegalArgumentException("flat_object map key cannot be null for field [" + mappedFieldType.name() + "]");
+    }
     entriesVector.setIndexDefined(index);
-    keyVector.setSafe(index, entry.getKey().toString().getBytes(StandardCharsets.UTF_8));
+    keyVector.setSafe(index, key.toString().getBytes(StandardCharsets.UTF_8));
Suggestion importance[1-10]: 3

__

Why: Defensive null-check for map keys; the mapper already guarantees non-null keys, so this is a marginal improvement to error clarity.

Low
Use null-tolerant singleton list wrapping

List.of(parseValue) will throw NullPointerException if parseValue is a single
non-list null value — but the earlier null-check handles the whole-value null case,
so scalar nulls reach the else branch. Since single non-list values are wrapped,
ensure the scalar path also tolerates a non-null non-List value correctly;
additionally, guard that values iteration doesn't inadvertently call List.of with a
null element in future refactors by using Collections.singletonList which permits
nulls.

sandbox/plugins/parquet-data-format/src/main/java/org/opensearch/parquet/fields/ParquetField.java [154-165]

-List<?> values = parseValue instanceof List<?> list ? list : List.of(parseValue);
+List<?> values = parseValue instanceof List<?> list ? list : java.util.Collections.singletonList(parseValue);
 int start = listVector.startNewValue(row);
 FieldVector dataVector = listVector.getDataVector();
 for (int i = 0; i < values.size(); i++) {
     Object value = values.get(i);
     if (value == null) {
         dataVector.setNull(start + i);
     } else {
         addToVector(dataVector, start + i, value);
     }
 }
 listVector.endValue(row, values.size());
Suggestion importance[1-10]: 2

__

Why: The suggestion's premise is weak: parseValue is already null-checked earlier so List.of(parseValue) won't get null in the current code, and the concern about future refactors is speculative.

Low
Suggestions up to commit 6c3ec12
CategorySuggestion                                                                                                                                    Impact
Possible bug
Use null-permitting map entry type

Map.entry(relativePath, value) throws NPE if value is null. Although parseValue
returns early on null tokens above, if a null_value substitute is ever null or a
code path change lets a null through, this will NPE. Since the code needs to
preserve keys with null values (see FlatObjectParquetField null handling), use
AbstractMap.SimpleEntry which permits null values, matching what the writer already
supports.

server/src/main/java/org/opensearch/index/mapper/FlatObjectFieldMapper.java [746-753]

 final String leafPath = Strings.collectionToDelimitedString(path, ".");
 final String relativePath = leafPath.substring(name().length() + 1);
 if (entries != null) {
     // Pluggable-format mode: the leaf becomes one MAP entry. No Lucene fields are built —
-    // the columnar format owns storage for this field.
-    entries.add(Map.entry(relativePath, value));
+    // the columnar format owns storage for this field. Use SimpleEntry to allow null values.
+    entries.add(new java.util.AbstractMap.SimpleEntry<>(relativePath, value));
     parser.nextToken();
     return;
 }
Suggestion importance[1-10]: 6

__

Why: Valid concern: Map.entry() disallows null values, and the writer's null-value handling (values.isNull(index)) suggests null values are expected to flow through. If a null-value path is reached, the current code would NPE unexpectedly. Using SimpleEntry aligns with the writer's stated capabilities.

Low
Preserve presence signal for empty arrays

getFieldCount now sums valueCount() across pairs, but an explicit empty array
(emptyMultiValued) returns valueCount()=0. If callers use this to detect field
presence (e.g. the @timestamp check mentioned in the comment), an empty-array field
will be indistinguishable from an absent field. Consider returning at least 1 for a
present multi-valued entry, or clarify/split the API to separate presence from value
counts.

sandbox/plugins/parquet-data-format/src/main/java/org/opensearch/parquet/writer/ParquetDocumentInput.java [113-119]

 @Override
 public long getFieldCount(String fieldName) {
-    // Counts values, not entries: a multi-valued field is one entry holding N values, and
-    // callers (single-value assertions below, the data-stream @timestamp check) mean values.
+    // Counts values for scalars; for multi-valued entries counts at least 1 so an explicit
+    // empty array is still detectable as "present" by callers checking presence.
     return collectedFields.stream()
         .filter(fvp -> fvp.getFieldType().name().equals(fieldName))
-        .mapToLong(FieldValuePair::valueCount)
+        .mapToLong(fvp -> fvp.isMultiValued() ? Math.max(1, fvp.valueCount()) : 1)
         .sum();
 }
Suggestion importance[1-10]: 5

__

Why: The observation is valid: an empty multi-valued array reports getFieldCount=0, potentially conflating with absent for presence checks. However, the PR's test testDeclaredMultiValueFieldWithEmptyArrayIsPresentEmptyList explicitly asserts getFieldCount("tags") equals 0, suggesting this behavior is intentional. The suggested fix could break that contract.

Low
General
Guard against null map keys explicitly

entry.getKey().toString() will NPE if the key is null. Although
FlatObjectFieldMapper currently supplies non-null relative paths, the Arrow MAP spec
makes keys non-nullable, so a null key here would produce an obscure NPE rather than
a clear rejection. Add an explicit null-check with a descriptive error to guard the
invariant.

sandbox/plugins/parquet-data-format/src/main/java/org/opensearch/parquet/fields/core/data/FlatObjectParquetField.java [69-76]

 for (int i = 0; i < entries.size(); i++) {
     Map.Entry<?, ?> entry = (Map.Entry<?, ?>) entries.get(i);
     int index = start + i;
+    Object key = entry.getKey();
+    if (key == null) {
+        throw new IllegalArgumentException(
+            "flat_object [" + mappedFieldType.name() + "] cannot have a null map key"
+        );
+    }
     // The entries struct is non-nullable per the Arrow MAP spec, so every slot written must
     // have its validity bit set; without this the child values read back as null.
     entriesVector.setIndexDefined(index);
-    keyVector.setSafe(index, entry.getKey().toString().getBytes(StandardCharsets.UTF_8));
+    keyVector.setSafe(index, key.toString().getBytes(StandardCharsets.UTF_8));
Suggestion importance[1-10]: 3

__

Why: Adding an explicit null-check for keys is defensive but the upstream FlatObjectFieldMapper guarantees non-null relative paths, so the practical impact is low. It's a minor robustness improvement.

Low

Indexes the shared 100-document otel_logs corpus through the REST API on
a fully assembled node with the analytics, composite-engine and
parquet-data-format plugins installed, using the OTel/Textbench mapping
shape: resource, log and instrumentationScope typed flat_object instead
of explicit object trees.

This covers what only a real node exercises — plugin wiring and
capability assignment across the composite primary/secondary formats,
the bulk path, and flush plus force-merge of MAP columns through the
native writer. Verified to fail without the flat_object support
(searchCapability is not supported for field: log of type: flat_object)
and to pass with it.

The corpus is a good stress for a map column: resource alone carries
nine sub-trees across five services, so key sets differ per document.
A second test admits the heterogeneous shapes a real pipeline emits —
empty bag, omitted bag, array leaf, explicit null, and previously unseen
deep keys.

Signed-off-by: Bukhtawar Khan <bukhtawa@amazon.com>
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 6c3ec12

…ngines

Same mapping, same document, two engines: the internal (Lucene) engine
indexes a flat_object into three Lucene fields (the parent path-parts
field, _value and _valueAndPath), while the composite engine indexes
none of them and keeps the data only in the Parquet MAP column.

The cause is capability assignment. flat_object requests
FULL_TEXT_SEARCH and COLUMNAR_STORAGE; the parquet primary is offered
capabilities first and FlatObjectParquetField claims both, so the lucene
secondary gets an empty set and LuceneDocumentInput#addField returns
without indexing. keyword and text behave differently on purpose: their
parquet implementations do not claim FULL_TEXT_SEARCH, so Lucene claims
it and they stay searchable on a composite index — asserted here so the
distinction is not mistaken for an accident.

Consequence, also asserted: a term query on a flat_object leaf matches
on the internal engine, and on the composite engine the field is durable
but served by neither format — parquet claims the capability without a
read path, Lucene holds no terms.

Signed-off-by: Bukhtawar Khan <bukhtawa@amazon.com>
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 1c9e4d2

@github-actions

Copy link
Copy Markdown
Contributor

❌ Gradle check result for 1c9e4d2: FAILURE

Please examine the workflow log, locate, and copy-paste the failure(s) below, then iterate to green. Is the failure a flaky test unrelated to your change?

FlatObjectParquetField claimed FULL_TEXT_SEARCH as well as
COLUMNAR_STORAGE, which starved the lucene secondary of the field: the
primary is offered capabilities first, so Lucene got an empty set and
indexed nothing for it. The field was durable in the Parquet MAP column
but searchable by neither format, since the columnar read path cannot
project a MAP yet.

Parquet now claims only COLUMNAR_STORAGE, matching keyword and text
whose parquet fields deliberately leave the inverted index to Lucene.
LuceneDataFormat declares flat_object (FULL_TEXT_SEARCH,
COLUMNAR_STORAGE) and LuceneFieldFactoryRegistry gains a flat_object
factory that expands the mapper's leaf entry list into the same three
fields the non-pluggable path writes: _value, _valueAndPath and the
parent path-part terms. Terms only — doc values stay with the primary.

FlatObjectEngineParityIT asserts the Lucene FieldInfos now match between
the composite and internal engines, and that flat_object is exactly as
searchable as keyword on a composite index. Transport _search is refused
for every field type there (IndexShard will not apply it to a
DataFormatAwareEngine), so that limitation is asserted as engine-wide
rather than mistaken for a flat_object gap.

A dotted-path query through the REST path still fails in the DSL query
executor, which resolves field names against the columnar row type where
a flat_object sub-path is not a column; asserted in
OtelFlattenedAttributesIndexingIT so it flips when routing lands.

Signed-off-by: Bukhtawar Khan <bukhtawa@amazon.com>
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 97f31af

The class javadoc still described the pre-fix state (composite indexing
nothing in Lucene for a flat_object) after the capability split was
corrected, contradicting the assertions below it. Replaced with the
verified layout: both engines index the same three Lucene fields, doc
values stay with the primary format on the composite engine, and the
Parquet MAP column is the columnar copy alongside.

Signed-off-by: Bukhtawar Khan <bukhtawa@amazon.com>
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 9bdbcc5

@mgodwan

mgodwan commented Aug 17, 2026

Copy link
Copy Markdown
Member

Won't map require each key to have the same value type? Given opensearch flat object supports fluid schema across keys within the flat object hierarchy, VARIANT type may fit more to the use case. Let me know your thoughts on this.

@github-actions

Copy link
Copy Markdown
Contributor

✅ Gradle check result for 9bdbcc5: SUCCESS

@codecov

codecov Bot commented Aug 17, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 78.43137% with 11 lines in your changes missing coverage. Please review.
✅ Project coverage is 71.55%. Comparing base (5592a7b) to head (9bdbcc5).
⚠️ Report is 12 commits behind head on main.

Files with missing lines Patch % Lines
...va/org/opensearch/index/mapper/DocumentParser.java 60.00% 3 Missing and 1 partial ⚠️
...opensearch/index/mapper/FlatObjectFieldMapper.java 84.61% 3 Missing and 1 partial ⚠️
...a/org/opensearch/index/mapper/FilterFieldType.java 0.00% 3 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##               main   #22741      +/-   ##
============================================
+ Coverage     71.43%   71.55%   +0.12%     
+ Complexity    76995    76986       -9     
============================================
  Files          6156     6139      -17     
  Lines        358470   358092     -378     
  Branches      52247    52225      -22     
============================================
+ Hits         256063   256248     +185     
+ Misses        82067    81480     -587     
- Partials      20340    20364      +24     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

TextBench's v3 storage-optimisation mapping sets index:false on the
flattened attribute fields, which OpenSearch rejected outright: the
flat_object TypeParser ignored the mapping node entirely, so
checkNoRemainingFields turned every parameter into a mapping error. The
only valid flat_object mapping was {"type":"flat_object"}.

flat_object now declares its parameters the way every other field type
does. DynamicKeyFieldMapper moves from FieldMapper to
ParametrizedFieldMapper (flat_object is its only subclass), so parsing,
serialization, includeDefaults and update-conflict detection come from
the framework instead of being hand-written. The mapper keeps its own
Lucene FieldType, as KeywordFieldMapper does, because
ParametrizedFieldMapper always passes a fresh one down; a second frozen
FIELD_TYPE_NOT_INDEXED carries IndexOptions.NONE. The empty mergeOptions
override is gone — that method is final on ParametrizedFieldMapper, and
merge is now driven by the declared parameters, so index finally gets a
real conflict check instead of silently succeeding.

index:false is accepted only when index.pluggable.dataformat.enabled is
set, to keep the blast radius off plain Lucene indices. There the field
would merely fall back to the doc-values path, which is a behaviour
change for existing mappings with no upside; under a columnar primary it
means something concrete, since requestedCapabilities() stops asking for
FULL_TEXT_SEARCH and the field drops out of the Lucene secondary
altogether.

The parameter's read-back derives from fieldType().isSearchable(),
because FlatObjectFieldType takes its searchability from the _value
sub-field rather than storing a flag, so the round trip through
serialization is asserted explicitly.

Signed-off-by: Bukhtawar Khan <bukhtawa@amazon.com>
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 6e619bc

@github-actions

Copy link
Copy Markdown
Contributor

❌ Gradle check result for 6e619bc: FAILURE

Please examine the workflow log, locate, and copy-paste the failure(s) below, then iterate to green. Is the failure a flaky test unrelated to your change?

On a composite index _source is not stored: DataFormatAwareEngine.getById
falls through to the DocumentLookupProvider, which reads the parquet row
and rebuilds the document through ArrowValues.toSourceMap. That method
had no MAP branch — only a guard keeping maps out of the LIST branch — so
a flat_object column fell through to the scalar switch, returned null,
and toSourceMap dropped it. The attribute bag was silently absent from
every get-by-id response while being perfectly durable on disk.

toSourceValue now converts a MapVector to a JSON object, mirroring the
handling toJavaValue already had. Keys are emitted verbatim: a leaf
written as {"k8s":{"pod":"x"}} was already flattened to the key
k8s.pod on the way in, so it reads back as {"k8s.pod":"x"}. That is
flat_object's own model — its Lucene _valueAndPath terms use the same
dotted form — and it avoids inventing an un-flattening convention for an
ambiguity that cannot be resolved after the fact.

A Parquet MAP is a repeated group, so a key legitimately repeats when the
source had an array; repeats group back into a list rather than
overwriting, so {"tag":["a","b"]} round-trips. An empty object stays
an empty object rather than vanishing.

Signed-off-by: Bukhtawar Khan <bukhtawa@amazon.com>
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 375fe95

@github-actions

Copy link
Copy Markdown
Contributor

❌ Gradle check result for 375fe95: FAILURE

Please examine the workflow log, locate, and copy-paste the failure(s) below, then iterate to green. Is the failure a flaky test unrelated to your change?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants