Conversation
Adds opt-in multi-value (array) support for keyword fields in the
composite Lucene+Parquet engine, stored as Arrow/Parquet LIST columns.
Cardinality is declared per field via the new index setting
index.parquet.multi_value.field. OpenSearch mappings never carry
cardinality -- an array is a property of an individual document -- but a
Parquet column's type is fixed for the whole file, so the declaration
bridges the two. Listed fields become LIST<element> and accept any
number of values; every other field keeps its scalar column, its
current performance, and still rejects a second value. The setting is
Final because the column type is baked into every file the index
writes.
Write path: ParquetDocumentInput accumulates values instead of throwing
on the second one, preserving document order and duplicates (both
matter because _source is derived from these columns). The list write
protocol lives once in ParquetField, so adding another field type means
overriding addToVector plus supportsMultiValue.
Read path: the Calcite schema types declared fields as ARRAY, ARRAY
joins the DataFusion scan capability (not filter/sort/aggregate, where
array semantics are undefined), and ArrowValues renders list columns so
get-by-id no longer drops them.
Also fixes three latent bugs that only a nested column exposes:
- VSRManager.reconcileSchema rebuilt each field from name + FieldType,
dropping getChildren() and leaving a LIST column with no element
vector.
- Per-column encoding/compression/bloom settings addressed columns by
bare name; a list's leaf is <field>.list.element, so every such
setting silently no-opped. Encoding validation also checked the list
wrapper rather than the element.
- The scoped page-index cache gave non-projected columns a placeholder
OffsetIndex claiming one page spanning num_rows ROWS. Pages of a
repeated leaf hold VALUES, and rows != values, so readers derived the
wrong byte range ("Src size is incorrect" / "StructArrayReader out of
sync"). Repeated leaves now always get a real OffsetIndex.
Tests: unit coverage for the document input, VSR list writes and
schema reconciliation, and the Rust column-path resolution; Rust merge
tests covering sorted/unsorted merges, multi-batch deferred decode, and
a clean error when a LIST column is used as a sort key; and
MultiValueFieldIT covering ingest, projection on both the ListingTable
and indexed read paths, array_length, get-by-id source reconstruction,
force-merge, and the rejection paths.
Scope: keyword only. text/ip/numerics/dates remain scalar-only.
Signed-off-by: Bukhtawar Khan <bukhtawa@amazon.com>
PR Code Suggestions ✨Latest suggestions up to 5559906 Explore these optional code suggestions:
Previous suggestionsSuggestions up to commit 827515b
Suggestions up to commit 8cba589
Suggestions up to commit 3353037
Suggestions up to commit cdd8f96
Suggestions up to commit c53d71f
|
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #22685 +/- ##
============================================
- Coverage 71.39% 71.39% -0.01%
+ Complexity 76808 76781 -27
============================================
Files 6148 6148
Lines 357994 358012 +18
Branches 52179 52179
============================================
+ Hits 255607 255610 +3
+ Misses 82054 82022 -32
- Partials 20333 20380 +47 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Tried the alternative to scoping repeated leaves into the real OffsetIndex set: keep the placeholder but make it nested-aware by pointing its single page at data_page_offset() instead of byte_range(). It passes an isolated reader harness but still fails the end-to-end suite — no single-page location can describe a repeated leaf, because the row->value mapping it would need to encode is exactly what the real page index carries. Record that in the comment so the alternative is not re-attempted, and note the bounded blast radius (OffsetIndex is the cheap fixed-width half; ColumnIndex scoping is untouched). Signed-off-by: Bukhtawar Khan <bukhtawa@amazon.com>
PR Reviewer Guide 🔍(Review updated until commit 5559906)Here are some key observations to aid the review process:
|
Replaces the blanket "always build a real OffsetIndex for every
repeated leaf" approach with a targeted one: teach the scoped
page-index cache's name->leaf resolver to handle nested columns.
The actual defect was in resolve_with_schema: it delegates to arrow-rs
StatisticsConverter, whose parquet_column helper silently returns None
for any nested field ("Nested fields are not supported"). A referenced
LIST column therefore dropped out of the resolved set and received only
a placeholder OffsetIndex -- and no single-page placeholder can describe
a repeated leaf, because first_row_index is defined in ROWS while a
repeated leaf's pages hold VALUES. The resolver now maps a nested
arrow field to all parquet leaves under its root, the same
root-positional correspondence parquet_column uses for flat columns,
sound here because the arrow schema is derived from the file's own
footer (1:1 field<->root order).
Compared to the previous fix this keeps placeholders for nested
columns a query never touches, preserving the cache's memory savings
on schemas with many list columns: only *referenced* nested columns
pay for a real OffsetIndex. The placeholder comment now records why a
nested placeholder can never be made valid (a data_page_offset-based
variant was tried and fails end-to-end), so the alternative is not
re-attempted.
Adds resolver unit tests covering the nested arm, flat columns,
mixed name sets, and unknown names.
Signed-off-by: Bukhtawar Khan <bukhtawa@amazon.com>
|
Persistent review updated to latest commit 4041296 |
|
❌ Gradle check result for 4041296: 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? |
Replaces index.parquet.multi_value.field with a `multi_value` boolean
parameter on the keyword mapper:
"tags": { "type": "keyword", "multi_value": true }
Cardinality is a property of the field, so it belongs in the mapping:
the declaration travels with the field definition instead of a parallel
settings list, the mapping is self-describing for any consumer that
reads it (the read-side Calcite schema builder now reads the property
straight from the mapping instead of mirroring a settings key), and
"field does not exist" misconfiguration becomes structurally
impossible. The parameter is not updateable (registerConflictCheck)
for the same reason the setting was Final: the column type is baked
into every parquet file the index has written.
Server side: MappedFieldType gains experimental
isMultiValued()/setMultiValued() (FilterFieldType delegates);
KeywordFieldMapper exposes the parameter and copies it onto the
_ignored_source companion so derived source keeps every value.
Parquet plugin: ParquetDocumentInput reads the flag off the
MappedFieldType directly, so the engine no longer threads a field-name
set through its constructors; ArrowSchemaBuilder reads the mapper's
declaration and rejects multi_value on types without list support,
which also replaces the validator's name-based checks.
Read side: OpenSearchSchemaBuilder types a multi_value field as
ARRAY<element> from the mapping property, dropping the settings mirror
it previously kept in sync by hand.
Signed-off-by: Bukhtawar Khan <bukhtawa@amazon.com>
|
Persistent review updated to latest commit 929e6d4 |
Two follow-ups on the multi_value contract:
Index sort: a multi-valued cell has no single value to sort on, so
using a multi_value field in index.sort.field now fails at index
creation with an error naming the field and setting, instead of
surfacing later as a native merge failure ("Unsupported sort column
type"). Matches Lucene's own rejection of index sorting on
multi-valued fields.
Query-time semantics over multi_value columns, established by probing
a live cluster and pinned as behavioral contracts in MultiValueFieldIT:
- `where tags = 'x'` is rejected by the PPL analyzer (SQL plugin, i.e.
before this repo's planner runs) with a message naming the type
mismatch (EQUAL ... [ARRAY,STRING]). Pinned so a regression to an
internal planner error is caught. Rewriting equality to
contains-semantics belongs in the SQL repo's analyzer, not here.
- `where mvfind(tags, 'x') >= 0` is the working contains filter, and
`array_length(tags) > n` the working element-count filter; both
reach DataFusion with the LIST column intact.
- `sort tags` works: null list first, then lexicographic by elements
(Arrow RowConverter ordering).
- `stats count() by tags` groups by the whole array value, not per
element (per-element bucketing needs an unnest, which PPL does not
expose here).
Signed-off-by: Bukhtawar Khan <bukhtawa@amazon.com>
|
Persistent review updated to latest commit c53d71f |
Engine-side half of Lucene-parity equality on multi-valued fields. The SQL plugin's PPL analyzer (companion opensearch-project/sql change) rewrites `tags = 'x'` on an ARRAY-typed column to Calcite's ARRAY_CONTAINS; this commit makes that predicate plannable and executable here: - ScalarFunction.ARRAY_CONTAINS enum constant, resolvable from Calcite's SqlLibraryOperators.ARRAY_CONTAINS. - Substrait signature mapping ARRAY_CONTAINS -> DataFusion's native array_has (element equality, not regex), plus the array_has entry in the substrait extension catalog so isthmus can emit the call. - FieldStorageResolver types a `multi_value: true` field as FieldType.ARRAY for capability lookups, agreeing with the Calcite row type from OpenSearchSchemaBuilder; previously the lookup used the element type (keyword) and rejected the predicate with "No backend can evaluate filter predicate [ARRAY_CONTAINS] on fields [tags:keyword]". - DataFusion filter capabilities: ARRAY_CONTAINS on FieldType.ARRAY, NOT on ARRAY (for `!=` = NOT(contains)), and STANDARD_FILTER_OPS extended to ARRAY — predicates over an ARRAY column reach the comparison through element-typed scalars (mvfind(tags,'x') >= 0, array_length(tags) > n), and the filter rule's per-field capability check sees the underlying ARRAY column. The IT (testEqualsOnMultiValueColumnMeansContains) asserts contains match, exact element equality (no substring), and != as NOT(contains) with three-valued-logic null exclusion — verified green end-to-end against a local unified-query build carrying the SQL-side overload, then marked AwaitsFix until that change ships in the published 3.8.0.0-SNAPSHOT the QA cluster installs. All other MultiValueFieldIT tests pass against the published snapshot unchanged. Signed-off-by: Bukhtawar Khan <bukhtawa@amazon.com>
|
Persistent review updated to latest commit cdd8f96 |
Adds MultiValueFieldDurabilityIT: refresh, flush, force-merge, segment replication to a peer, and node-restart recovery over a multi_value keyword field, asserting after every stage that each document still carries exactly its own values in order with duplicates intact. A multi-valued field is a Parquet LIST column rather than a flat one, so each stage that writes, rewrites, ships, or replays those files is an independent place the offsets encoding can break — and because the column is the source of truth for derived _source (parquet-owned fields have no Lucene stored fields), a loss there is silent data corruption rather than a query error. The previous coverage stopped at refresh/flush/force-merge on a single shard with replicas=0. Reads go through get-by-id, not _search: IndexShard.applyOnEngine rejects DataFormatAwareEngine, so a composite index has no searcher. Ids are captured from the index responses because the index is append-only and rejects custom _ids. The replica arm resolves this index's own shards rather than calling assertCatalogSnapshotsConverged, whose node lookups are bound to the base class's INDEX_NAME. Verified stable across repeated runs (-Dtests.iters=3). Signed-off-by: Bukhtawar Khan <bukhtawa@amazon.com>
|
Persistent review updated to latest commit 3353037 |
Two gaps in the multi_value lifecycle coverage: Concurrent indexing during peer recovery. A BackgroundIndexer subclass emits multi-valued documents (lengths 0..3, including duplicates, derived from the doc id) and a replica is added while those writes are in flight. This reaches what the stop-then-recover test cannot: the replica is built from a catalog that is still advancing, so a generation can ship while the primary's active VSR is mid-list. Asserts no acknowledged write is lost and the replica's catalog converges on the primary's parquet files. Partial write failure across the two formats. A composite write calls parquet then lucene and rolls back every writer it touched if any one fails. A tags element longer than Lucene's MAX_TERM_LENGTH (32766) fails ONLY in the secondary — after parquet accepted the row and, for a multi-valued field, after its list offsets were advanced — so the rollback must rewind a partially written LIST cell, which a flat column cannot exercise. The test asserts the rejected document lands in neither format, and that subsequent good documents still index and read back correctly, proving the VSR was left consistent rather than merely not crashing. Verified the failure takes the intended path rather than passing vacuously: the run logs "Failed to add document in secondary format [lucene], rolling back" alongside Lucene's "immense term in field=\"tags\"". Stable across -Dtests.iters=2. Signed-off-by: Bukhtawar Khan <bukhtawa@amazon.com>
|
Persistent review updated to latest commit 8cba589 |
Three more indexing-side cases for multi_value fields: Null elements inside an array are DROPPED, not preserved as null list entries: KeywordFieldMapper's pluggable-format parse returns early on a null value, so it never reaches addField. ["a",null,"b"] stores as ["a","b"], making array_length 2. This matches Lucene (no term is indexed for a null) but the alternative is equally defensible, so it is pinned rather than left to drift. A 50k-element array in one document. VSR rotation is driven by ROW count (index.parquet.max_rows_per_vsr), not bytes, so a single pathological array grows the child vector without triggering a rotation — the case where the row-count bound does not hold. Verifies the write completes and every element survives the flush. A multi_value field added by a mapping update on a live index, which is the only path that reaches VSRManager.reconcileSchema for a LIST column (an index created with the field gets children from the initial schema). Confirmed non-vacuous by mutation: restoring the old name+FieldType field rebuild makes this test fail with "Lists have one child Field. Found: none". Signed-off-by: Bukhtawar Khan <bukhtawa@amazon.com>
|
Persistent review updated to latest commit 827515b |
Bulk is the realistic ingest path and is stricter than the
single-document rollback case: CompositeWriter rolls each touched
writer back to the composite's running acceptedRows, so within one
batch every rejected item must rewind to exactly the count its
predecessors established. A mistake there corrupts the failing
document's NEIGHBOURS rather than the document itself, and for a LIST
column would leave the child vector's offsets pointing past the
surviving rows.
The test interleaves bad/good/good/bad/good/good/bad so a rejected item
sits first, between, and last. Invalid items carry a tags element over
Lucene's MAX_TERM_LENGTH, so parquet accepts the row (advancing list
offsets) and lucene rejects it — the asymmetric cross-format path.
Asserts exactly three items fail, all four valid neighbours are
acknowledged with their arrays byte-identical, and writes resume
afterwards. Run logs confirm 3 secondary-format rollbacks and 0
primary-format, so the failures take the intended path.
Documented a scope limit found by mutation testing: injecting
rollbackTo(target + 1) leaves this test green, because ParquetWriter's
own range guards ("Cannot rollback to N: only M rows admitted" and the
exactly-one-doc limit) mask it — the arithmetic is pinned by unit tests
on ParquetWriter/VSRManager, not by this IT, which asserts the outcome.
Signed-off-by: Bukhtawar Khan <bukhtawa@amazon.com>
|
Persistent review updated to latest commit 5559906 |
Description
Adds opt-in multi-value (array) support for
keywordfields in the composite Lucene+Parquet engine, stored as Arrow/ParquetLISTcolumns.Cardinality is declared per field via a new
multi_valuemapping parameter:{"tags": ["beta","alpha","beta"]}now indexes and reads back intact, order and duplicates preserved.Why opt-in rather than automatic
OpenSearch mappings never carry cardinality — an array is a property of an individual document — but a Parquet column's type is fixed for the whole file. The setting bridges that gap. Alternatives considered:
VSRManager.isSchemaMutable()goes false after the first flush), so promotion would need an in-flight file rewrite.Opt-in keeps cost and blast radius at zero for undeclared fields, which keep their scalar column and still reject a second value (with an error naming the parameter). A mapping parameter rather than an index setting because cardinality is a property of the field: the declaration travels with the field definition, the mapping is self-describing for any consumer that reads it (the read-side Calcite schema builder reads the property straight from the mapping — no settings mirror to keep in sync), and "declared field doesn't exist" misconfiguration is structurally impossible. The parameter is not updateable (
registerConflictCheck) because the column type is baked into every file the index has written.MappedFieldTypegains experimentalisMultiValued()/setMultiValued()to carry the flag to the document-input layer.Implementation
Write path.
ParquetDocumentInputaccumulates values rather than throwing on the second one —DocumentParseralready callsaddFieldonce per array element, so no mapper changes were needed. Order and duplicates are preserved because_sourceis derived from these columns. The list write protocol (startNewValue/setSafe/endValue, null list for an absent field) lives once inParquetField, so adding another field type means overridingaddToVector+supportsMultiValue().Read path. The Calcite schema types declared fields as
ARRAY<element>;ArrowCalciteTypes.toArrowFieldcarries the element type (an Arrow list holds its child on theField, not theArrowType);ARRAYjoins the DataFusion scan capability but deliberately not filter/sort/aggregate, where array semantics are undefined; andArrowValues.toSourceValuerenders list columns so get-by-id no longer silently drops the field.Testing
reconcileSchemachild preservation; Calcite ARRAY typing and element-type propagation; Rust column-path/element-type resolution.Unsupported sort column typeerror when a LIST column is used as a sort key.MultiValueFieldDurabilityIT, internalClusterTest, 9 tests): refresh, flush, force-merge, segment replication to a peer (asserting the replica's catalog converges on the primary's parquet files), node-restart recovery, peer recovery while indexing continues (aBackgroundIndexersubclass emitting multi-valued docs, so a generation can ship while the primary's VSR is mid-list), and cross-format partial failure — atagselement over Lucene'sMAX_TERM_LENGTHfails only in the secondary, after parquet accepted the row and advanced its list offsets, soCompositeWriter's rollback must rewind a partially written LIST cell; the test asserts the doc lands in neither format and that writes resume correctly. Verified non-vacuous via the run logs ("Failed to add document in secondary format [lucene], rolling back" + Lucene's "immense term in field="tags""). Each stage re-reads every document and requires values byte-identical, in order, duplicates intact. Also pins: null elements inside an array are dropped (["a",null,"b"]stores as["a","b"]— matches Lucene, but the alternative is defensible so it is pinned); a 50k-element array in one document (VSR rotation is row-count driven, so one pathological array grows the child vector without rotating); and amulti_valuefield added by mapping update, the only path reachingVSRManager.reconcileSchemafor a LIST column — confirmed non-vacuous by mutation (restoring the old field rebuild makes it fail with "Lists have one child Field. Found: none"). Also bulk with interleaved valid/invalid documents — a rejected item first, between, and last — sinceCompositeWriterrolls back to the composite's runningacceptedRowsand a mistake there would corrupt the failing document's neighbours; asserts all valid neighbours keep byte-identical arrays and writes resume.MultiValueFieldIT): ingest; projection on both read paths as a control pair (ListingTable vs indexed);array_lengthover a LIST column; get-by-id_sourcereconstruction; force-merge round-trip asserting every document keeps exactly its own values; and the two rejection paths.precommitpasses on all four touched modules. No regressions inListAggregateMultiShardIT,TwoShardScalarIT,SortCommandIT,ArrayFunctionIT.Known limitations / follow-ups
keywordonly.text,ip, numerics, dates, boolean remain scalar-only; the field-type layer is ready for them."tags": []reads back as null, not an empty list. An explicit empty array yields zeroaddFieldcalls, so the writer never sees the field — indistinguishable from absent. Tested and documented; worth deciding whether to preserve the distinction.multi_valuefield cannot be used asindex.sort.field— rejected at index creation with an error naming the field (a multi-valued cell has no single value to sort on; matches Lucene's own rejection). The native merge's clean error remains as defense in depth, both covered by tests.where tags = 'x'means CONTAINS (Lucene term-query parity) — this PR carries the engine half (ScalarFunction.ARRAY_CONTAINS, Substrait mapping to DataFusion'sarray_has,FieldStorageResolvertypingmulti_valuefields as ARRAY, filter capabilities); the analyzer half is Equality on an ARRAY-typed field means CONTAINS sql#5694 (rewrites=/!=on[ARRAY<T>, scalar]toARRAY_CONTAINS/NOT(ARRAY_CONTAINS)). Verified green end-to-end against a local unified-query build; the IT is@AwaitsFixuntil the SQL PR ships in the published snapshot. Other pinned semantics:mvfind(tags,'x') >= 0andarray_length(tags) > nfilters work;sort tagsorders lexicographically by elements (nulls first);stats … by tagsgroups by the whole array value (per-element bucketing needs unnest, future work).multi_valuefield are not covered (their base ITs use flat fields).ListVsScalarBenchmarkexists for exactly this and hasn't been run; the opt-in design confines any cost to declared fields.Check List
By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.
For more information on following Developer Certificate of Origin and signing off your commits, please check here.