Skip to content

Add plugin SPI for dynamic field-type inference and dynamic-template types - #22607

Open
naykudev wants to merge 11 commits into
opensearch-project:mainfrom
naykudev:dynamic-knn-vector-mapping
Open

Add plugin SPI for dynamic field-type inference and dynamic-template types#22607
naykudev wants to merge 11 commits into
opensearch-project:mainfrom
naykudev:dynamic-knn-vector-mapping

Conversation

@naykudev

@naykudev naykudev commented Jul 29, 2026

Copy link
Copy Markdown

Description

This PR introduces a generic core interface and plugin SPI that lets any mapper plugin participate in dynamic mapping for its own field types. Core defines the extension points (DynamicFieldTypeInferencer, DynamicTemplateTypeHandler, FieldValueParserSupplier) and the registration SPI on MapperPlugin; core itself only detects the JSON value and delegates the "is this mine, and what type is it" decision to whichever plugin registered. No plugin-specific type knowledge lives in core.

The k-NN plugin is simply the first consumer of this SPI (auto-mapping vector fields and match_mapping_type: "knn_vector" dynamic templates, in a companion PR), but the interface is deliberately generic — e.g. a geospatial plugin could register an inferencer that claims {"lat": .., "lon": ..} objects as geo_point through the exact same SPI, with zero further core changes.

Motivation. Today dynamic mapping can only produce core's built-in field types. When an unmapped numeric array arrives, core parses it element-by-element and maps it as float — it never looks at the array as a whole, and a plugin has no way to claim it. Likewise, match_mapping_type only accepts the built-in XContentFieldType values, so a plugin type can't be targeted by a dynamic template. This change gives plugins a single, generic seam for both.

What it adds (all @ExperimentalApi):

  • DynamicFieldTypeInferencerinferFieldType(FieldValueParserSupplier) → Map<String,Object> | null. Called for an unmapped field with no matching template; the plugin inspects the value and returns a mapping config to claim it, or null to pass.
  • DynamicTemplateTypeHandler — backs a plugin-registered match_mapping_type (validated against the plugin registry when it isn't a built-in XContentFieldType). adjustMappingConfig(...) completes the template config before the TypeParser builds the mapper; isConfigComplete(...) gates eager index-creation validation.
  • FieldValueParserSupplier — hands the plugin a fresh XContentParser over the buffered field bytes on each get(). Lazy (no parser allocated unless the plugin reads it), preserves JSON fidelity, no boxing. Core stays free of any deserialized-representation contract.
  • MapperPlugin SPI methods getDynamicFieldTypeInferencers() and getDynamicTemplateTypes(), collected into MapperRegistry by IndicesModule at startup (same pattern as getMappers()).

How it hooks in. A single hook, tryPluginInference(), fires in DocumentParser.innerParseObject() before the token-type switch — one placement covering arrays, objects, and scalars, so the SPI is genuinely generic. Resolution order: explicit mapping → plugin template → standard template → plugin inferencer → existing per-element fallback. If no plugin claims the field, the buffered bytes are replayed through the original path, so all existing behavior is preserved. When no plugin registers an inferencer or template type, the hook fast-exits before any buffering — zero overhead for clusters without such a plugin.

Ambiguity is a hard error, not a silent pick. Rather than taking the first claim by registration/load order, core consults all registered plugin template types and all inferencers for an unmapped field and throws a clear MapperParsingException (naming the conflicting claimants) if more than one claims it. Zero claims fall through as before; exactly one is used. Duplicate match_mapping_type registration across plugins is rejected at node startup.

Backwards compatibility. Strictly additive. The hook only fires for unmapped fields, only when a plugin is registered; existing dynamic templates, explicit mappings, and per-element inference are unchanged. All new types are @ExperimentalApi.

Scope note. This is the core SPI only. The k-NN implementation (inferencer + knn_vector template handler) is a companion PR in the k-NN repo and depends on this change; it cannot build against core until this merges and a snapshot publishes.

Testing

  • PluginDynamicTemplateTests (28) — parsing, index-creation validation (registered/unregistered/typo/complete/incomplete/{name} placeholder), inference claims/declines for scalars/strings/booleans, precedence (explicit beats inference, builtin fallback), and fast-path exit.
  • PluginInferenceConflictTests (3) — two inferencers claiming the same field throw; two plugin templates matching the same field throw; a single match resolves without throwing.
  • IndicesModuleTests (+3) — plugins register inferencers/template types correctly; duplicate template-type registration throws at startup; empty by default.
  • DynamicTemplateTests — plugin-type parse/serialization.
  • Full DocumentParserTests (130) pass — regression guard for the changed hot path.

All server mapper/indices suites green; spotlessJavaCheck passes.

Related Issues

Check List

  • Functionality includes testing.
  • New functionality has javadoc added.
  • API changes companion pull request created, if applicable.
  • Public documentation issue/PR created, if applicable.

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

Introduce a generic extension point so mapper plugins can participate in
dynamic mapping for their own field types, without core hard-coding any
plugin-specific type knowledge.

A single hook, tryPluginInference(), fires in innerParseObject() before
the token-type switch (covering arrays, objects, and scalars). It offers
an unmapped field to two plugin mechanisms:

- DynamicFieldTypeInferencer: inspects the buffered value and returns a
  mapping config to claim the field, or null to pass.
- DynamicTemplateTypeHandler: backs a plugin-registered match_mapping_type
  (validated against the plugin registry when it is not a builtin
  XContentFieldType), completing the template config before the TypeParser
  builds the mapper. isConfigComplete() gates eager index-creation
  validation.

Both receive a FieldValueParserSupplier, which hands out a fresh
XContentParser over the buffered field bytes on demand — lazy, no boxing,
preserves JSON fidelity. If no plugin claims the field, the buffered bytes
are replayed through the existing path, so all current behavior is
preserved and the hook fast-exits when no plugin is registered.

Plugins register via new MapperPlugin SPI methods collected by
IndicesModule into MapperRegistry. All new types are @experimentalapi.

Signed-off-by: ved naykude <vnaykude@amazon.com>
@github-actions

github-actions Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

PR Code Analyzer ❗

AI-powered 'Code-Diff-Analyzer' found issues on commit 840945c.

Hard block: Issues at Medium severity or above will block this PR from merging.

PathLineSeverityDescription
server/src/main/java/org/opensearch/index/mapper/DocumentParser.java1186mediumThe new tryPluginInference SPI calls plugin-supplied inferencer code (inferencer.inferFieldType) during every document parse for unmapped fields, passing a FieldValueParserSupplier that gives each plugin direct streaming access to the raw buffered field bytes. A malicious or compromised plugin installed on the cluster could use this hook to exfiltrate document field values. This is architecturally consistent with OpenSearch's trusted-plugin model, but the hook is broader than prior plugin extension points — it fires on every unmapped field rather than only at mapping-registration time.
server/src/test/java/org/opensearch/index/mapper/DynamicTemplateTests.java58lowThe test previously verified that an unknown match_mapping_type value throws an IllegalArgumentException immediately at parse time. The change removes that assertion and instead accepts any unknown string as a pluginMatchType. Validation is now deferred to index-creation time via the registry check. This is intentional for the plugin SPI feature, but it relaxes the parse-time rejection that previously caught typos and invalid type strings before they could be stored.

The table above displays the top 10 most important findings.

Total: 2 | Critical: 0 | High: 0 | Medium: 1 | Low: 1


Pull Requests Author(s): Please update your Pull Request according to the report above.

Repository Maintainer(s): You can bypass diff analyzer by adding label skip-diff-analyzer after reviewing the changes carefully, then re-run failed actions. To re-enable the analyzer, remove the label, then re-run all actions.


⚠️ Note: The Code-Diff-Analyzer helps protect against potentially harmful code patterns. Please ensure you have thoroughly reviewed the changes beforehand.

Thanks.

@naykudev
naykudev force-pushed the dynamic-knn-vector-mapping branch from 9eaca9f to 6f6a195 Compare July 29, 2026 20:08
@github-actions

github-actions Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

PR Reviewer Guide 🔍

(Review updated until commit dbd28f0)

Here are some key observations to aid the review process:

🧪 PR contains tests
🔒 No security concerns identified
✅ No TODO sections
🔀 No multiple PR themes
⚡ Recommended focus areas for review

Path corruption on replay exception

In attemptPluginInference, when replaying buffered content through the newly-created inferred/template mapper, context.path().add(resolvedFieldName) is called on the original context after switchParser returns a replayContext. The try/finally releases the slot on the original context.path(), but if parseObjectOrField throws, this cleanup happens correctly only because both contexts share the same path. However, parseObjectOrField(replayContext, ...) may itself add/remove path segments internally; if replay throws mid-way after adding nested segments (e.g. object fields), those segments will not be released before the outer finally runs, leaving ContentPath corrupted for subsequent fields. Consider snapshotting path length and restoring it in the finally block.

    try (
        XContentParser replayParser = contentType.xContent()
            .createParser(context.parser().getXContentRegistry(), context.parser().getDeprecationHandler(), rawContent)
    ) {
        replayParser.nextToken();
        ParseContext replayContext = context.switchParser(replayParser);
        context.path().add(resolvedFieldName);
        try {
            parseObjectOrField(replayContext, templateMapper);
        } finally {
            // Release the field-name slot even if replay throws, so ContentPath is not left
            // corrupt for subsequent fields in the same document.
            context.path().remove();
        }
    }
    return true;
}

// Step 2: No template matched — run the inferencers as the auto-detection fallback.
// This is the path for fields with no user-defined template: each inferencer checks whether
// the field looks like a plugin-managed type (e.g. numeric array >= 128 elements).
//
// We consult ALL registered inferencers rather than stopping at the first claim: if two
// inferencers both claim the same field, that is ambiguous and we fail loudly (per OpenSearch
// triage) rather than letting plugin load order silently pick a winner.
Map<String, Object> inferredFieldMapping = null;
DynamicFieldTypeInferencer claimingInferencer = null;
for (DynamicFieldTypeInferencer inferencer : inferencers) {
    Map<String, Object> claim;
    try {
        claim = inferencer.inferFieldType(fieldValueParser);
    } catch (Exception e) {
        // A buggy inferencer must not break document parsing, but the failure must be visible:
        // log it rather than swallowing silently, then move on to the next inferencer.
        logger.warn(
            () -> new ParameterizedMessage(
                "Skipping dynamic field type inferencer [{}]: it threw while inspecting field [{}]",
                inferencer.getClass().getName(),
                resolvedFieldName
            ),
            e
        );
        continue;
    }
    if (claim != null) {
        if (inferredFieldMapping != null) {
            throw new MapperParsingException(
                "field ["
                    + resolvedFieldName
                    + "] was claimed by more than one dynamic field type inferencer: ["
                    + claimingInferencer.getClass().getName()
                    + "] and ["
                    + inferencer.getClass().getName()
                    + "]; the inferred type is ambiguous"
            );
        }
        inferredFieldMapping = claim;
        claimingInferencer = inferencer;
    }
}

if (inferredFieldMapping == null) {
    // No template and no inferencer claimed this field — fall through to existing path
    replayThroughExistingPath(context, resolvedParent, resolvedFieldName, rawContent);
    return true;
}

String inferredType = (String) inferredFieldMapping.get("type");
if (inferredType == null) {
    replayThroughExistingPath(context, resolvedParent, resolvedFieldName, rawContent);
    return true;
}

// Step 3: No template — use inferencer result directly.
Mapper.TypeParser.ParserContext parserContext = context.docMapperParser().parserContext();
Mapper.TypeParser typeParser = parserContext.typeParser(inferredType);
if (typeParser == null) {
    // Unknown type — fall through to existing path rather than failing
    replayThroughExistingPath(context, resolvedParent, resolvedFieldName, rawContent);
    return true;
}

Mapper.Builder<?> builder = typeParser.parse(resolvedFieldName, inferredFieldMapping, parserContext);
Mapper.BuilderContext builderContext = new Mapper.BuilderContext(context.indexSettings().getSettings(), context.path());
Mapper inferredMapper = builder.build(builderContext);
context.addDynamicMapper(inferredMapper);

// Replay buffered content through the new mapper
try (
    XContentParser replayParser = contentType.xContent()
        .createParser(parser.getXContentRegistry(), parser.getDeprecationHandler(), rawContent)
) {
    replayParser.nextToken(); // position at the start of the value
    ParseContext replayContext = context.switchParser(replayParser);
    context.path().add(resolvedFieldName);
    try {
        parseObjectOrField(replayContext, inferredMapper);
    } finally {
        // Release the field-name slot even if replay throws, so ContentPath is not left
        // corrupt for subsequent fields in the same document.
        context.path().remove();
    }
}
Buffering overhead on every unmapped field

Once any plugin registers an inferencer or template type, every unmapped field in every document is fully buffered via copyCurrentStructure and then re-parsed (once for plugin inspection, again for replay through either the plugin mapper or the existing path). This doubles parse work and allocates a byte array per unmapped field, even for fields no plugin will ever claim (e.g., strings when only a numeric-array inferencer is registered). Consider a cheaper pre-check (e.g., pass the current token type to plugins to let them opt-out early) before buffering.

// Buffer the complete field value — needed for replay regardless of whether
// a plugin claims the field or not (streaming parser can only be read once)
byte[] rawContent;
try (XContentBuilder bufferBuilder = XContentBuilder.builder(contentType.xContent())) {
    bufferBuilder.copyCurrentStructure(parser);
    rawContent = BytesReference.toBytes(BytesReference.bytes(bufferBuilder));
}

// Hand plugins a factory that produces a fresh parser over the buffered bytes rather than a
// pre-deserialized object. Core stays free of any representation contract: each plugin streams
// the tokens it needs. Plugins whose config is already complete never call get(), so no parsing
// happens for them.
final FieldValueParserSupplier fieldValueParser = new FieldValueParserSupplier(
    contentType,
    parser.getXContentRegistry(),
    parser.getDeprecationHandler(),
    rawContent
);
parseNullValue path not covered

tryPluginInference fires for every non-FIELD_NAME token including VALUE_NULL. When no plugin claims, replayThroughExistingPath handles VALUE_NULL, but for tokens like END_OBJECT/END_ARRAY reached at the wrong nesting the buffered replay via copyCurrentStructure will fail or produce wrong results. Verify that tryPluginInference is only entered on value-start tokens (START_OBJECT/START_ARRAY/VALUE_*), not on structural end tokens that could reach this branch during innerParseObject.

} else {
    // Before branching by token type, offer the field to plugin inferencers.
    // This is the single convergence point where we know the field name, the
    // incoming token type, and paths — before the code fans out to parseObject /
    // parseArray / parseValue. Placing the hook here means one method covers arrays,
    // objects, and scalars rather than requiring three separate hooks. The hook only
    // fires for unmapped fields; if the field has a mapper we skip directly to the
    // switch below via the early-return inside tryPluginInference.
    if (tryPluginInference(context, mapper, currentFieldName, paths)) {
        token = parser.nextToken();
        continue;
    }
Backward compatibility break

The public DynamicTemplate.parse(name, conf) now throws IllegalArgumentException for a previously-unknown match_mapping_type where before the pre-existing behavior in some code paths silently ignored the template (the removed comment "if a wrong match type is specified, we ignore the template" indicates this). External callers using the 2-arg public API on templates authored for a plugin-typed index will now fail. Confirm no external callers rely on the tolerant behavior, or preserve it by returning null for unknown types in the 2-arg overload.

public static DynamicTemplate parse(String name, Map<String, Object> conf) throws MapperParsingException {
    return parse(name, conf, Collections.emptyMap());
}

@github-actions

github-actions Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

PR Code Suggestions ✨

Latest suggestions up to dbd28f0

Explore these optional code suggestions:

CategorySuggestion                                                                                                                                    Impact
Possible issue
Avoid double-advancing parser after plugin inference

After tryPluginInference consumes the value (object/array/scalar), the parser's
current token is already the last token of that value (e.g. END_OBJECT/END_ARRAY, or
the scalar itself). Calling parser.nextToken() here advances one token forward,
which is correct for structured values but for scalars it advances to the next
field. This is consistent, but verify the loop's continue semantics: the outer while
calls parser.nextToken() itself, so this manual advance combined with continue may
skip a token. Consider removing the explicit nextToken() and letting the loop
advance naturally, or documenting why the double-advance is needed.

server/src/main/java/org/opensearch/index/mapper/DocumentParser.java [662-665]

 if (tryPluginInference(context, mapper, currentFieldName, paths)) {
-    token = parser.nextToken();
     continue;
 }
Suggestion importance[1-10]: 5

__

Why: The concern about double-advancing the parser is plausible since the outer loop in innerParseObject typically advances the token itself. However, this is a "verify" suggestion without concrete evidence of a bug, and the improved code removes advancement which could equally cause issues. Moderate importance as it flags a potentially subtle parser positioning issue worth checking.

Low
Use replay context for path manipulation

context.switchParser(replayParser) likely mutates context (or returns a wrapper
sharing state). The code adds/removes the path slot on context but parses using
replayContext. If switchParser returns a new independent context, the path
manipulation is on the wrong object. Verify that replayContext and context share the
same ContentPath, otherwise the field-name slot won't be visible to the mapper being
parsed.

server/src/main/java/org/opensearch/index/mapper/DocumentParser.java [1339-1347]

 ParseContext replayContext = context.switchParser(replayParser);
-context.path().add(resolvedFieldName);
+replayContext.path().add(resolvedFieldName);
 try {
     parseObjectOrField(replayContext, templateMapper);
 } finally {
-    // Release the field-name slot even if replay throws, so ContentPath is not left
-    // corrupt for subsequent fields in the same document.
-    context.path().remove();
+    replayContext.path().remove();
 }
Suggestion importance[1-10]: 3

__

Why: This is a "verify" suggestion asking to check whether switchParser returns a context sharing the same ContentPath. Since the existing code uses context.path() consistently across multiple similar blocks, it likely shares state, but the concern is worth noting. Low-to-moderate impact as it's speculative.

Low
General
Verify getMapper has no path side-effects

getMapper may add path slots to context.path() as a side effect (similar to
getDynamicParentMapper). If so, an early return here without releasing those slots
would corrupt ContentPath for subsequent fields. Verify getMapper has no such side
effect, or release any added slots before returning false.

server/src/main/java/org/opensearch/index/mapper/DocumentParser.java [1224-1227]

+// Fast path: field is already mapped — let the normal path handle it
+Mapper existingMapper = getMapper(context, parentMapper, fieldName, paths);
+if (existingMapper != null) {
+    return false;
+}
 
-
Suggestion importance[1-10]: 3

__

Why: A speculative "verify" suggestion with no concrete evidence. The improved_code is identical to the existing_code, providing no actionable fix. Low impact.

Low
Preserve error message parity with fromString

Previously XContentFieldType.fromString was called, which throws
IllegalArgumentException with a specific error message listing valid built-in types.
The new manual loop silently proceeds to plugin-type checking, which changes the
error message when the value is neither built-in nor plugin-registered. This is
fine, but ensure the new combined error message (in the plugin-not-found branch) is
at least as informative as the old one — and preserves any exception type contract
(e.g. tests catching IllegalArgumentException still pass, which they do here since
you throw IllegalArgumentException).

server/src/main/java/org/opensearch/index/mapper/DynamicTemplate.java [253-260]

+XContentFieldType xcontentFieldType = null;
+String pluginMatchType = null;
+if (matchMappingType != null && !matchMappingType.equals("*")) {
+    for (XContentFieldType t : XContentFieldType.values()) {
+        if (t.toString().equals(matchMappingType)) {
+            xcontentFieldType = t;
+            break;
+        }
+    }
 
-
Suggestion importance[1-10]: 2

__

Why: The improved_code is identical to the existing_code, so it's not actionable. The observation about error message parity is minor since the new message includes both builtin and plugin types, arguably being more informative.

Low

Previous suggestions

Suggestions up to commit 1e88a41
CategorySuggestion                                                                                                                                    Impact
Possible issue
Guarantee path cleanup on exception

tryPluginInference calls getDynamicParentMapper which adds path slots, and can also
add the field-name slot on the replay path. If tryPluginInference throws (e.g. from
MapperParsingException for ambiguous claims), the added path slots from
getDynamicParentMapper are never released, leaving ContentPath corrupt for any error
handler that continues parsing subsequent fields in a bulk context. Wrap the added
path slots in a try/finally within tryPluginInference to guarantee cleanup on
exceptional exit.

server/src/main/java/org/opensearch/index/mapper/DocumentParser.java [662-665]

-if (tryPluginInference(context, mapper, currentFieldName, paths)) {
-    token = parser.nextToken();
-    continue;
-}
+// Inside tryPluginInference, restructure so all path additions are released in a finally block
+// regardless of whether an ambiguity MapperParsingException is thrown.
Suggestion importance[1-10]: 7

__

Why: Valid concern: if tryPluginInference throws a MapperParsingException (ambiguity path) after getDynamicParentMapper added path slots, those slots are not released, which could corrupt ContentPath state. This is a real correctness issue worth addressing.

Medium
General
Use finally to guarantee path-slot cleanup

getDynamicParentMapper mutates context.path() by adding intermediate path slots, but
these slots are added before the fast-path decision returns false. When returning
false after resolving dynamic=STRICT/FALSE, you correctly release them, but when
returning false from earlier fast-paths later in the flow (or when the caller's
normal path runs), the path state is only released here. Ensure the released count
matches every early-return exit path — currently the code correctly does so, but
consider hoisting the release into a finally to avoid regression if new branches are
added.

server/src/main/java/org/opensearch/index/mapper/DocumentParser.java [1231-1240]

 Tuple<Integer, ObjectMapper> parentMapperTuple = getDynamicParentMapper(context, resolvedPaths, parentMapper);
-ObjectMapper resolvedParent = parentMapperTuple.v2();
-ObjectMapper.Dynamic dynamic = dynamicOrDefault(resolvedParent, context);
-if (dynamic == ObjectMapper.Dynamic.STRICT || dynamic == ObjectMapper.Dynamic.FALSE) {
-    // Release path-slots added by getDynamicParentMapper before returning
-    for (int i = 0; i < parentMapperTuple.v1(); i++) {
-        context.path().remove();
+int addedPathSlots = parentMapperTuple.v1();
+boolean releasePaths = true;
+try {
+    ObjectMapper resolvedParent = parentMapperTuple.v2();
+    ObjectMapper.Dynamic dynamic = dynamicOrDefault(resolvedParent, context);
+    if (dynamic == ObjectMapper.Dynamic.STRICT || dynamic == ObjectMapper.Dynamic.FALSE) {
+        return false;
     }
-    return false;
+    // ... rest of method; set releasePaths = false only where downstream owns cleanup
+} finally {
+    if (releasePaths) {
+        for (int i = 0; i < addedPathSlots; i++) {
+            context.path().remove();
+        }
+    }
 }
Suggestion importance[1-10]: 5

__

Why: The suggestion is a reasonable defensive refactor to prevent future regressions in ContentPath cleanup, but the current code already handles all exit paths correctly, so the impact is limited to maintainability.

Low
Reduce buffering overhead on fall-through

This buffering happens for every unmapped field when any plugin is registered, even
when the field will ultimately fall through to the normal path (e.g. because no
inferencer claims it and no plugin template matches). For large object/array values
this doubles memory usage and adds CPU cost. Consider deferring buffering until it
is known that at least one inferencer or template could plausibly claim the field,
or short-circuit on scalar tokens where buffering is cheap but on very large
arrays/objects is expensive. At minimum, document this overhead so plugin authors
understand its cost profile.

server/src/main/java/org/opensearch/index/mapper/DocumentParser.java [1247-1251]

+// Consider adding a hasClaimingCandidate() pre-check or streaming an initial peek before
+// committing to full buffering, especially for START_ARRAY / START_OBJECT tokens.
 byte[] rawContent;
 try (XContentBuilder bufferBuilder = XContentBuilder.builder(contentType.xContent())) {
     bufferBuilder.copyCurrentStructure(parser);
     rawContent = BytesReference.toBytes(BytesReference.bytes(bufferBuilder));
 }
Suggestion importance[1-10]: 5

__

Why: Legitimate performance concern about buffering overhead for every unmapped field when plugins are registered, but the suggestion is vague and doesn't propose a concrete solution.

Low
Preserve error message quality on unknown types

The previous implementation used XContentFieldType.fromString(matchMappingType)
which threw IllegalArgumentException for unknown types. The new loop silently sets
xcontentFieldType = null for unknown types, only failing later when the plugin
registry is also empty. For the public 2-arg parse (empty registry), this still
throws — but the error message is now built from the registry-empty branch and no
longer uses the original enum's helpful listing. Consider preserving
XContentFieldType.fromString semantics or ensuring the new error message
equivalently lists all built-in types. The updated test in DynamicTemplateTests was
weakened from an exact-match to containsString — verify this loss of specificity is
intended.

server/src/main/java/org/opensearch/index/mapper/DynamicTemplate.java [254-274]

-for (XContentFieldType t : XContentFieldType.values()) {
-    if (t.toString().equals(matchMappingType)) {
-        xcontentFieldType = t;
-        break;
+if (xcontentFieldType == null) {
+    if (!knownPluginTypes.containsKey(matchMappingType)) {
+        List<String> allTypes = new ArrayList<>();
+        for (XContentFieldType t : XContentFieldType.values()) {
+            allTypes.add(t.toString());
+        }
+        if (!knownPluginTypes.isEmpty()) {
+            allTypes.addAll(knownPluginTypes.keySet());
+        }
+        throw new IllegalArgumentException(
+            "No field type matched on [" + matchMappingType + "], possible values are " + allTypes
+        );
     }
+    pluginMatchType = matchMappingType;
 }
-if (xcontentFieldType == null) {
-    // Validate the plugin type against the registry before storing it as the plugin match type.
-    if (!knownPluginTypes.containsKey(matchMappingType)) {
Suggestion importance[1-10]: 3

__

Why: The existing new code already builds an error message listing all built-in types plus plugin types, so the concern is mostly about test specificity, which is a minor point.

Low
Suggestions up to commit 8301dbf
CategorySuggestion                                                                                                                                    Impact
Possible issue
Avoid double-appending field name to path

context.path().add(resolvedFieldName) is called after switchParser, but
parseObjectOrField typically expects the ContentPath to already include the field
name only for object mappers, not field mappers — the existing
parseObject/parseValue methods handle path management themselves. Adding
resolvedFieldName unconditionally here may double-append the path segment for
object-typed template mappers (whose parseObjectOrField will also push the name),
corrupting field full paths. Match the existing convention: only add to path when
the mapper is an ObjectMapper, or delegate entirely to parseObjectOrField's internal
handling.

server/src/main/java/org/opensearch/index/mapper/DocumentParser.java [1306-1324]

 try (
     XContentParser replayParser = contentType.xContent()
         .createParser(context.parser().getXContentRegistry(), context.parser().getDeprecationHandler(), rawContent)
 ) {
     replayParser.nextToken();
     ParseContext replayContext = context.switchParser(replayParser);
-    context.path().add(resolvedFieldName);
+    boolean addedPath = templateMapper instanceof FieldMapper == false;
+    if (addedPath) {
+        context.path().add(resolvedFieldName);
+    }
     try {
         parseObjectOrField(replayContext, templateMapper);
     } finally {
-        // Release the field-name slot even if replay throws, so ContentPath is not left
-        // corrupt for subsequent fields in the same document.
-        context.path().remove();
+        if (addedPath) {
+            context.path().remove();
+        }
     }
 } finally {
     for (int i = 0; i < parentMapperTuple.v1(); i++) {
         context.path().remove();
     }
 }
Suggestion importance[1-10]: 5

__

Why: The concern about potential double-appending of the field name to ContentPath when the template mapper is an ObjectMapper may be valid, but without confirming exact behavior of parseObjectOrField for both types, the fix's correctness is uncertain. Still, this points to a real potential bug in path management.

Low
Verify parser position after buffered consumption

After tryPluginInference consumes and replays the buffered field value, the parser
is positioned at the end token of that value (e.g. END_OBJECT/END_ARRAY). Calling
parser.nextToken() here advances past that end token correctly, but if the plugin
path buffered a scalar the parser is left on the value token itself — advancing once
lands on the next field name, which matches the loop's expectation. However, because
context.switchParser(replayParser) returned a wrapper replayContext used only inside
the replay, the outer context.parser() was never actually swapped; ensure the outer
parser was advanced past the buffered structure. Verify that after
copyCurrentStructure the outer parser sits at the value's end token so a single
nextToken() reaches the next field, otherwise subsequent fields will be skipped or
misread.

server/src/main/java/org/opensearch/index/mapper/DocumentParser.java [662-665]

 if (tryPluginInference(context, mapper, currentFieldName, paths)) {
+    // copyCurrentStructure leaves the outer parser positioned at the end token of the
+    // consumed value (or on the scalar itself); advance to the next field name.
     token = parser.nextToken();
     continue;
 }
Suggestion importance[1-10]: 3

__

Why: The suggestion only adds a comment and asks to verify parser positioning. copyCurrentStructure on the outer parser does advance it past the buffered value, so the concern is largely a verification request with low impact.

Low
General
Preserve legacy error message for unknown types

The new parsing path replaces XContentFieldType.fromString(matchMappingType) with a
manual loop, but silently falls back to plugin lookup when no builtin matches. This
changes behavior for callers of the legacy public parse(name, conf) overload that
pass unknown types: previously fromString threw with a specific error message
listing builtin types; now it delegates to the new logic which throws
IllegalArgumentException with a different message. Verify the legacy overload still
produces the exact original error message for backwards compatibility, or reuse
XContentFieldType.fromString when knownPluginTypes is empty.

server/src/main/java/org/opensearch/index/mapper/DynamicTemplate.java [254-274]

-for (XContentFieldType t : XContentFieldType.values()) {
-    if (t.toString().equals(matchMappingType)) {
-        xcontentFieldType = t;
-        break;
+if (knownPluginTypes.isEmpty()) {
+    xcontentFieldType = XContentFieldType.fromString(matchMappingType);
+} else {
+    for (XContentFieldType t : XContentFieldType.values()) {
+        if (t.toString().equals(matchMappingType)) {
+            xcontentFieldType = t;
+            break;
+        }
     }
-}
-if (xcontentFieldType == null) {
-    // Validate the plugin type against the registry before storing it as the plugin match type.
-    if (!knownPluginTypes.containsKey(matchMappingType)) {
+    if (xcontentFieldType == null) {
+        if (!knownPluginTypes.containsKey(matchMappingType)) {
Suggestion importance[1-10]: 4

__

Why: The suggestion notes a minor error message backward compatibility concern. The PR test testParseUnknownMatchType was updated to reflect the new message, so this is a deliberate change, but it may still affect external consumers relying on the exact message format.

Low
Reduce buffering overhead on unmapped fields

Buffering the entire field value for every unmapped field — even when no plugin
ultimately claims it — introduces significant memory and CPU overhead on documents
with many unmapped fields (a common case for dynamic mappings). Consider skipping
buffering when both inferencers.isEmpty() and no plugin dynamic templates are
configured on the root mapper, or when the parent is dynamic=FALSE/STRICT.
Alternatively, for scalar tokens where the value fits in a primitive, avoid the
XContent round-trip.

server/src/main/java/org/opensearch/index/mapper/DocumentParser.java [1247-1251]

 // Buffer the complete field value — needed for replay regardless of whether
-// a plugin claims the field or not (streaming parser can only be read once)
+// a plugin claims the field or not (streaming parser can only be read once).
+// Note: this adds per-field overhead; ensure fast-path exits above have filtered
+// out cases where no plugin can possibly claim the field.
 byte[] rawContent;
 try (XContentBuilder bufferBuilder = XContentBuilder.builder(contentType.xContent())) {
     bufferBuilder.copyCurrentStructure(parser);
     rawContent = BytesReference.toBytes(BytesReference.bytes(bufferBuilder));
 }
Suggestion importance[1-10]: 3

__

Why: The fast-path exits already check inferencers.isEmpty() && templateTypes.isEmpty() and dynamic=STRICT/FALSE before buffering, so much of the concern is already addressed. The improved_code only adds a comment rather than making an actual optimization.

Low
Suggestions up to commit a42ac75
CategorySuggestion                                                                                                                                    Impact
Possible issue
Preserve resolved paths during replay

replayThroughExistingPath uses splitAndValidatePath(fieldName) for the replayed
paths, but the caller in tryPluginInference had already computed resolvedPaths (via
paths != null ? paths : splitAndValidatePath(fieldName)) and used the last segment
as the field name. When the original fieldName contains dots and paths was non-null,
re-splitting here will produce inconsistent path handling relative to the pre-hook
resolution. Pass the already-resolved paths through to preserve identical semantics
with the non-plugin path.

server/src/main/java/org/opensearch/index/mapper/DocumentParser.java [1440-1455]

         try (XContentParser replayParser = originalParser.contentType()
             .xContent()
             .createParser(originalParser.getXContentRegistry(), originalParser.getDeprecationHandler(), rawContent)
     ) {
         replayParser.nextToken(); // position at value start
         ParseContext replayContext = context.switchParser(replayParser);
         XContentParser.Token replayToken = replayParser.currentToken();
-        String[] replayPaths = splitAndValidatePath(fieldName);
         switch (replayToken) {
             case START_OBJECT:
                 parseObject(replayContext, parentMapper, fieldName, replayPaths);
                 break;
             case START_ARRAY:
                 parseArray(replayContext, parentMapper, fieldName, replayPaths);
                 break;
             case VALUE_NULL:
                 parseNullValue(replayContext, parentMapper, fieldName, replayPaths);
                 break;
             default:
                 if (replayToken != null && replayToken.isValue()) {
                     parseValue(replayContext, parentMapper, fieldName, replayToken, replayPaths);
                 }
         }
     } finally {
Suggestion importance[1-10]: 5

__

Why: The suggestion identifies a real minor inconsistency where replayThroughExistingPath re-splits fieldName instead of using the already-resolved paths. However, the improved_code references replayPaths without declaring it, making the fix incomplete. Impact is modest since callers pass the last path segment as fieldName.

Low
Avoid changing STRICT error semantics

getDynamicParentMapper may throw StrictDynamicMappingException for STRICT parents
when creating intermediate objects, changing observable behavior compared to the
current path where the switch-based branches handle STRICT with a proper exception
at the correct point. Wrap the call so that on exception the added path slots are
released, and consider deferring parent resolution until after confirming
plugins/templates actually exist for this field to avoid altering error semantics
for STRICT dynamic mappings.

server/src/main/java/org/opensearch/index/mapper/DocumentParser.java [1229-1240]

     // Only fire for dynamic=TRUE / STRICT_ALLOW_TEMPLATES / FALSE_ALLOW_TEMPLATES
     final String[] resolvedPaths = paths != null ? paths : splitAndValidatePath(fieldName);
-    Tuple<Integer, ObjectMapper> parentMapperTuple = getDynamicParentMapper(context, resolvedPaths, parentMapper);
+    Tuple<Integer, ObjectMapper> parentMapperTuple;
+    try {
+        parentMapperTuple = getDynamicParentMapper(context, resolvedPaths, parentMapper);
+    } catch (RuntimeException e) {
+        // Do not alter error semantics for STRICT parents; let the normal path handle it.
+        return false;
+    }
     ObjectMapper resolvedParent = parentMapperTuple.v2();
     ObjectMapper.Dynamic dynamic = dynamicOrDefault(resolvedParent, context);
     if (dynamic == ObjectMapper.Dynamic.STRICT || dynamic == ObjectMapper.Dynamic.FALSE) {
-        // Release path-slots added by getDynamicParentMapper before returning
         for (int i = 0; i < parentMapperTuple.v1(); i++) {
             context.path().remove();
         }
         return false;
     }
Suggestion importance[1-10]: 4

__

Why: Wrapping in a broad catch (RuntimeException e) and returning false is questionable — it would swallow legitimate strict errors. The concern about getDynamicParentMapper throwing has some validity but the proposed fix is not ideal.

Low
General
Avoid breaking public parse API behavior

The public 2-arg parse overload now passes an empty plugin-types map, which causes
previously ignored/deprecated unknown match_mapping_type values (which the old code
silently ignored, returning a template with null xcontentFieldType) to throw
IllegalArgumentException. This is a behavior change for any callers of the public
API. Consider preserving the pre-existing lenient behavior in the 2-arg overload (or
documenting the breaking change explicitly) to avoid breaking external callers/tests
relying on the historical semantics.

server/src/main/java/org/opensearch/index/mapper/DynamicTemplate.java [253-274]

     XContentFieldType xcontentFieldType = null;
     String pluginMatchType = null;
     if (matchMappingType != null && !matchMappingType.equals("*")) {
         for (XContentFieldType t : XContentFieldType.values()) {
             if (t.toString().equals(matchMappingType)) {
                 xcontentFieldType = t;
                 break;
             }
         }
         if (xcontentFieldType == null) {
-            // Validate the plugin type against the registry before storing it as the plugin match type.
-            if (!knownPluginTypes.containsKey(matchMappingType)) {
-                List<String> allTypes = new ArrayList<>();
-                for (XContentFieldType t : XContentFieldType.values()) {
-                    allTypes.add(t.toString());
-                }
-                allTypes.addAll(knownPluginTypes.keySet());
-                throw new IllegalArgumentException(
-                    "No field type matched on [" + matchMappingType + "], possible values are " + allTypes
-                );
+            if (knownPluginTypes.containsKey(matchMappingType)) {
+                pluginMatchType = matchMappingType;
+            } else {
+                // Preserve historical behavior: without a plugin registry, delegate to the strict
+                // fromString check that produced the original error message.
+                xcontentFieldType = XContentFieldType.fromString(matchMappingType);
             }
-            pluginMatchType = matchMappingType;
         }
     }
Suggestion importance[1-10]: 5

__

Why: The suggestion correctly notes a behavior change in the public parse API, but the existing code already preserves the same error semantics (throws IllegalArgumentException with the same style of message), and the test was updated to reflect this. The change appears intentional.

Low
Register dynamic mapper only after successful replay

context.addDynamicMapper(templateMapper) is called before replay; if
parseObjectOrField throws, the dynamic mapper has already been registered but no
field was indexed, potentially leaving an inconsistent dynamic mapping update.
Consider adding the dynamic mapper only after successful replay, matching what the
existing non-plugin dynamic path effectively achieves via its structured error
handling.

server/src/main/java/org/opensearch/index/mapper/DocumentParser.java [1302-1319]

     if (templateBuilder != null) {
         Mapper.BuilderContext templateBuilderContext = new Mapper.BuilderContext(context.indexSettings().getSettings(), context.path());
         Mapper templateMapper = templateBuilder.build(templateBuilderContext);
-        context.addDynamicMapper(templateMapper);
         try (
             XContentParser replayParser = contentType.xContent()
                 .createParser(context.parser().getXContentRegistry(), context.parser().getDeprecationHandler(), rawContent)
         ) {
             replayParser.nextToken();
             ParseContext replayContext = context.switchParser(replayParser);
             context.path().add(resolvedFieldName);
             try {
                 parseObjectOrField(replayContext, templateMapper);
+                context.addDynamicMapper(templateMapper);
             } finally {
-                // Release the field-name slot even if replay throws, so ContentPath is not left
-                // corrupt for subsequent fields in the same document.
                 context.path().remove();
             }
Suggestion importance[1-10]: 3

__

Why: The concern about registering a dynamic mapper before replay has some merit, but this pattern matches existing OpenSearch behavior where dynamic mappers are added before parsing values. Impact is low.

Low
Suggestions up to commit b31f7a0
CategorySuggestion                                                                                                                                    Impact
Possible issue
Remove extra token advancement after plugin inference

After tryPluginInference returns true, the method has already consumed the field
value from the parser (via buffering and replay). Calling parser.nextToken() here
advances past the next token, which is the next field name — causing that field to
be silently skipped. The outer loop's token = parser.nextToken() at the top of the
loop should handle advancement; the extra nextToken() call here is incorrect.

server/src/main/java/org/opensearch/index/mapper/DocumentParser.java [662-665]

 if (tryPluginInference(context, mapper, currentFieldName, paths)) {
-    token = parser.nextToken();
     continue;
 }
Suggestion importance[1-10]: 7

__

Why: After tryPluginInference returns true, the field value has already been consumed via buffering. The extra parser.nextToken() call would advance past the next field name, silently skipping it. However, this depends on the exact loop structure in innerParseObject — if the outer loop already calls nextToken() at the top, this would indeed be a bug causing field skipping.

Medium
Defensively copy template mapping config before mutation

dynamicTemplate.mappingForName() may return a shared or cached map. Passing it
directly to handler.adjustMappingConfig() and then to typeParser.parse() (which
calls node.remove()) could mutate the template's internal state, causing incorrect
behavior for subsequent documents that match the same template. The config map
should be defensively copied before being handed to the handler and parser.

server/src/main/java/org/opensearch/index/mapper/DocumentParser.java [2155-2159]

-Map<String, Object> mappingConfig = dynamicTemplate.mappingForName(name, pluginType);
+Map<String, Object> mappingConfig = new HashMap<>(dynamicTemplate.mappingForName(name, pluginType));
 // The handler completes the config (injects its own type when omitted, and any data-derived
 // params such as dimension) before the TypeParser builds the mapper.
 handler.adjustMappingConfig(mappingConfig, fieldValueParser);
 return typeParser.parse(name, mappingConfig, parserContext);
Suggestion importance[1-10]: 7

__

Why: If mappingForName returns a shared or cached map, mutating it via adjustMappingConfig and typeParser.parse() (which calls node.remove()) could corrupt the template's internal state for subsequent documents. This is a real correctness concern that could cause subtle bugs in production.

Medium
Ensure parent path slots are always released on exception

The context.path().add(resolvedFieldName) is called before parseObjectOrField, but
the path slots added by getDynamicParentMapper (tracked in parentMapperTuple.v1())
are removed in the outer finally block. If parseObjectOrField throws, the inner
finally removes the field-name slot, but the outer finally still runs and removes
the parent path slots — this is correct. However, the path state managed by
getDynamicParentMapper was already modified before the try block for the replay
parser, so if the XContentParser creation itself throws, the parent path slots are
leaked. The finally block for releasing parentMapperTuple.v1() slots should wrap the
entire operation from after getDynamicParentMapper is called.

server/src/main/java/org/opensearch/index/mapper/DocumentParser.java [1306-1325]

-try (
-    XContentParser replayParser = contentType.xContent()
-        .createParser(context.parser().getXContentRegistry(), context.parser().getDeprecationHandler(), rawContent)
-) {
-    replayParser.nextToken();
-    ParseContext replayContext = context.switchParser(replayParser);
-    context.path().add(resolvedFieldName);
-    try {
-        parseObjectOrField(replayContext, templateMapper);
-    } finally {
-        context.path().remove();
+try {
+    try (
+        XContentParser replayParser = contentType.xContent()
+            .createParser(context.parser().getXContentRegistry(), context.parser().getDeprecationHandler(), rawContent)
+    ) {
+        replayParser.nextToken();
+        ParseContext replayContext = context.switchParser(replayParser);
+        context.path().add(resolvedFieldName);
+        try {
+            parseObjectOrField(replayContext, templateMapper);
+        } finally {
+            context.path().remove();
+        }
     }
 } finally {
     for (int i = 0; i < parentMapperTuple.v1(); i++) {
         context.path().remove();
     }
 }
Suggestion importance[1-10]: 5

__

Why: The suggestion addresses a potential path slot leak if XContentParser creation throws, but in practice createParser rarely throws in a way that would bypass the existing finally block. The restructuring is a minor defensive improvement with limited practical impact.

Low
General
Close parser on nextToken failure to prevent resource leak

The get() method creates a parser and advances it with nextToken(), but if
nextToken() throws an IOException, the created parser is never closed, leaking the
underlying resource. The parser should be closed in a try-catch if nextToken()
fails.

server/src/main/java/org/opensearch/index/mapper/FieldValueParserSupplier.java [73-80]

 public XContentParser get() throws IOException {
     if (rawContent == null) {
         throw new IllegalStateException("No field value is available to parse");
     }
     XContentParser parser = contentType.xContent().createParser(registry, deprecationHandler, rawContent);
-    parser.nextToken(); // position at the start of the value
+    try {
+        parser.nextToken(); // position at the start of the value
+    } catch (IOException e) {
+        parser.close();
+        throw e;
+    }
     return parser;
 }
Suggestion importance[1-10]: 4

__

Why: The suggestion correctly identifies a potential resource leak if nextToken() throws, but nextToken() on a freshly created parser over in-memory bytes is extremely unlikely to throw an IOException. This is a minor defensive improvement with low practical impact.

Low
Suggestions up to commit 000d534
CategorySuggestion                                                                                                                                    Impact
General
Preserve strict validation without plugin registry

When knownPluginTypes is null (the 2-arg parse overload), any unknown
match_mapping_type is silently accepted as a plugin type — this breaks the previous
strict behavior for callers using the old API and can hide typos in mappings created
via that path. Consider preserving strict validation in the 2-arg path (throw as
before) and only relaxing it in the 3-arg path when a registry is supplied.

server/src/main/java/org/opensearch/index/mapper/DynamicTemplate.java [257-270]

 if (xcontentFieldType == null) {
     pluginMatchType = matchMappingType;
-    // Validate plugin type before constructing the template
-    if (knownPluginTypes != null && !knownPluginTypes.containsKey(pluginMatchType)) {
+    if (knownPluginTypes == null) {
+        // Preserve legacy strict behavior when no plugin registry supplied
+        throw new IllegalArgumentException(
+            "No field type matched on [" + matchMappingType + "], possible values are "
+                + Arrays.toString(XContentFieldType.values())
+        );
+    }
+    if (!knownPluginTypes.containsKey(pluginMatchType)) {
         List<String> allTypes = new ArrayList<>();
         for (XContentFieldType t : XContentFieldType.values()) {
             allTypes.add(t.toString());
         }
         allTypes.addAll(knownPluginTypes.keySet());
         throw new IllegalArgumentException(
             "No field type matched on [" + pluginMatchType + "], possible values are " + allTypes
         );
     }
 }
Suggestion importance[1-10]: 7

__

Why: Valid concern about backward compatibility — the 2-arg parse overload now silently accepts unknown types where it previously threw, which could hide mapping typos in legacy callers and contradicts the existing test expectations.

Medium
Ensure paths matches current field name

currentFieldName is captured before the else branch and is not reset per iteration;
if tryPluginInference is invoked with a stale paths value from a prior FIELD_NAME
token, path resolution may be incorrect. Ensure paths corresponds to the current
currentFieldName by resolving it here (or asserting non-null) before delegating, to
avoid mis-routing fields when the parser state and captured paths become out of
sync.

server/src/main/java/org/opensearch/index/mapper/DocumentParser.java [661-664]

+if (paths == null) {
+    paths = resolvePathForParsing(mapper, currentFieldName);
+}
 if (tryPluginInference(context, mapper, currentFieldName, paths)) {
     token = parser.nextToken();
     continue;
 }
Suggestion importance[1-10]: 3

__

Why: The concern is largely unfounded: paths is set in the FIELD_NAME branch right before the else branch executes on the next token for the same field. The suggestion adds redundant defensive code.

Low
Ensure parser state consistency on exceptions

After the plugin inference/template path completes, the original parser
(context.parser()) is still positioned at the end of the buffered structure, but the
caller in innerParseObject executes token = parser.nextToken(); — this is correct
for scalar values but for START_OBJECT/START_ARRAY the buffering via
copyCurrentStructure already advanced the parser past END_OBJECT/END_ARRAY, so
nextToken() will move to the token after the field value as expected. However,
verify switchParser restores state properly on exception; if parseObjectOrField
throws, the original parser may be left in an inconsistent state relative to the
outer loop. Consider restoring the parser explicitly in a finally block to guarantee
cleanup.

server/src/main/java/org/opensearch/index/mapper/DocumentParser.java [1305-1323]

+ParseContext replayContext = null;
 try (XContentParser replayParser = contentType.xContent()
         .createParser(context.parser().getXContentRegistry(), context.parser().getDeprecationHandler(), rawContent)
 ) {
     replayParser.nextToken();
-    ParseContext replayContext = context.switchParser(replayParser);
+    replayContext = context.switchParser(replayParser);
     context.path().add(resolvedFieldName);
     try {
         parseObjectOrField(replayContext, templateMapper);
     } finally {
-        // Release the field-name slot even if replay throws, so ContentPath is not left
-        // corrupt for subsequent fields in the same document.
         context.path().remove();
     }
 } finally {
     for (int i = 0; i < parentMapperTuple.v1(); i++) {
         context.path().remove();
     }
 }
Suggestion importance[1-10]: 2

__

Why: The suggestion is vague ("verify switchParser restores state") and the improved code is essentially identical to the existing code, just moving a variable declaration. No concrete issue is fixed.

Low
Possible issue
Guard against non-String type values from plugins

The cast (String) inferredFieldMapping.get("type") will throw ClassCastException if
a plugin returns a non-String value for the "type" key (e.g. an integer or map),
bypassing the intended graceful fallback. Retrieve the value as Object first and
validate it is a String before casting, so a misbehaving inferencer causes a
fallthrough to the existing path rather than crashing document parsing.

server/src/main/java/org/opensearch/index/mapper/DocumentParser.java [1376-1380]

-if (inferredFieldMapping == null) { // put these into one if
+if (inferredFieldMapping == null) {
     // No template and no inferencer claimed this field — fall through to existing path
     replayThroughExistingPath(context, resolvedParent, resolvedFieldName, rawContent, parentMapperTuple.v1());
     return true;
 }
 
-String inferredType = (String) inferredFieldMapping.get("type");
-if (inferredType == null) {
+Object inferredTypeObj = inferredFieldMapping.get("type");
+if (inferredTypeObj instanceof String == false) {
     replayThroughExistingPath(context, resolvedParent, resolvedFieldName, rawContent, parentMapperTuple.v1());
     return true;
 }
+String inferredType = (String) inferredTypeObj;
Suggestion importance[1-10]: 6

__

Why: Valid defensive check — a plugin returning a non-String type would cause ClassCastException and break document parsing. The fix aligns with existing graceful fallback behavior for unknown types.

Low

@naykudev naykudev changed the title Dynamic knn vector mapping Add plugin SPI for dynamic field-type inference and dynamic-template types Jul 29, 2026
Per OpenSearch triage: instead of taking the first plugin claim by
registration/load order, DocumentParser now consults ALL registered
plugin template types and ALL inferencers for an unmapped field and
throws if more than one claims it:

- Two plugin dynamic-template types matching the same field -> throw.
- Two field-type inferencers claiming the same field -> throw.

Zero claims still fall through to existing behavior; exactly one is used
as before. The exception names the conflicting claimants so the
misconfiguration is clear. Adds PluginInferenceConflictTests covering
both conflict paths and the single-match (no-throw) case.

Signed-off-by: ved naykude <vnaykude@amazon.com>
@naykudev
naykudev force-pushed the dynamic-knn-vector-mapping branch from 6f6a195 to 7863ebc Compare July 29, 2026 20:26
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 7863ebc

@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 6dae7af

@github-actions

Copy link
Copy Markdown
Contributor

❕ Gradle check result for 6dae7af: UNSTABLE

Please review all flaky tests that succeeded after retry and create an issue if one does not already exist to track the flaky failure.

@codecov

codecov Bot commented Jul 29, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 88.73874% with 25 lines in your changes missing coverage. Please review.
✅ Project coverage is 71.38%. Comparing base (e8e618b) to head (8301dbf).

Files with missing lines Patch % Lines
...va/org/opensearch/index/mapper/DocumentParser.java 83.82% 13 Missing and 9 partials ⚠️
.../org/opensearch/index/mapper/RootObjectMapper.java 93.10% 1 Missing and 1 partial ⚠️
...a/org/opensearch/index/mapper/DynamicTemplate.java 96.55% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##               main   #22607      +/-   ##
============================================
- Coverage     71.42%   71.38%   -0.05%     
+ Complexity    76853    76815      -38     
============================================
  Files          6148     6148              
  Lines        358003   358205     +202     
  Branches      52179    52216      +37     
============================================
- Hits         255718   255696      -22     
- Misses        81972    82152     +180     
- Partials      20313    20357      +44     

☔ 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.

@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 2ae39c1

@github-actions

Copy link
Copy Markdown
Contributor

❌ Gradle check result for 2ae39c1: 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?

Cover the previously-untested fall-through and error paths in
DocumentParser.tryPluginInference and RootObjectMapper eager validation:

- Throwing inferencer is caught and the field falls through to normal
  inference (not a parse failure).
- Inferencer returning a config with no "type", or an unknown type with
  no registered TypeParser, falls through instead of failing.
- A template handler that reports its config complete but throws during
  adjustMappingConfig is treated as non-fatal at index creation (deferred).

Adds PluginInferencerEdgeCaseTests and a throwing-handler case in
PluginDynamicTemplateTests. Improves patch coverage on the new branches.

Signed-off-by: ved naykude <vnaykude@amazon.com>
@naykudev
naykudev force-pushed the dynamic-knn-vector-mapping branch from 2ae39c1 to 000d534 Compare July 29, 2026 23:48
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 000d534

@github-actions

Copy link
Copy Markdown
Contributor

❌ Gradle check result for 000d534: null

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?

Comment thread server/src/main/java/org/opensearch/index/mapper/DynamicTemplate.java Outdated
Comment thread server/src/main/java/org/opensearch/index/mapper/RootObjectMapper.java Outdated
Comment thread server/src/main/java/org/opensearch/index/mapper/RootObjectMapper.java Outdated
Comment thread server/src/main/java/org/opensearch/index/mapper/RootObjectMapper.java Outdated
try {
// No document is available at index creation, so the supplier's get() throws. A complete
// config never reads the field value, so this is a no-op normalization (e.g. type injection).
handler.adjustMappingConfig(fieldTypeConfig, FieldValueParserSupplier.withoutValue());

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you help me understand why do we need to adjust mapping configuration when config is complete ?

@naykudev naykudev Jul 31, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"Complete" only means the dimension is known, not that the config is buildable. The "type" is implied by match_mapping_type: "knn_vector" so users usually omit it, but the TypeParser cannot build a mapper without it, so we inject it even for a complete config. Example: mapping { "dimension": 128 } is complete but has no type, so it becomes { "type": "knn_vector", "dimension": 128 }. The isConfigComplete check only guards whether we open the parser to infer the dimension. Added a short comment with this example.

Comment thread server/src/main/java/org/opensearch/index/mapper/RootObjectMapper.java Outdated
Comment thread server/src/main/java/org/opensearch/index/mapper/DocumentParser.java Outdated
- Restore public DynamicTemplate.parse(name, conf) to fail fast on an
  unknown match_mapping_type (pre-plugin-SPI behavior); the plugin path
  uses the registry-aware overload. No API change.
- Remove the always-false handler-null guard in validatePluginDynamicTemplate
  (the plugin type is registry-validated at parse time).
- Use proper log4j imports instead of fully-qualified names; reword the
  deferred-validation warning; extract the __dynamic__ template-name prefix
  to a constant; drop a stray inline comment.
- Update tests to the registry-aware parse and the restored strict behavior.

Signed-off-by: ved naykude <vnaykude@amazon.com>
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit b31f7a0

@github-actions

Copy link
Copy Markdown
Contributor

✅ Gradle check result for b31f7a0: SUCCESS

naykudev added 2 commits July 31, 2026 15:53
Remove plugin-specific examples (knn_vector, dimension) from the core
dynamic-mapping SPI docs and comments so the interfaces stay generic.
Also validate a plugin match_mapping_type against the registry before
assigning it as the plugin match type.

Signed-off-by: ved naykude <vnaykude@amazon.com>
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit a42ac75

@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

❌ Gradle check result for a42ac75: null

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?

@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 8301dbf

@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

❕ Gradle check result for 8301dbf: UNSTABLE

Please review all flaky tests that succeeded after retry and create an issue if one does not already exist to track the flaky failure.

@navneet1v

Copy link
Copy Markdown
Contributor

@naykudev there has been some hard blockers called out here: #22607 (comment) can we fix them or respond to them.

Comment thread server/src/main/java/org/opensearch/indices/IndicesModule.java Outdated
When two mapper plugins register the same dynamic template type, name
both plugins in the error so the misconfiguration is easy to trace.

Signed-off-by: ved naykude <vnaykude@amazon.com>
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 1e88a41

@naykudev

naykudev commented Aug 4, 2026

Copy link
Copy Markdown
Author

PR Code Analyzer ❗

AI-powered 'Code-Diff-Analyzer' found issues on commit 840945c.

Hard block: Issues at Medium severity or above will block this PR from merging.

Path Line Severity Description
server/src/main/java/org/opensearch/index/mapper/DocumentParser.java 1186 medium The new tryPluginInference SPI calls plugin-supplied inferencer code (inferencer.inferFieldType) during every document parse for unmapped fields, passing a FieldValueParserSupplier that gives each plugin direct streaming access to the raw buffered field bytes. A malicious or compromised plugin installed on the cluster could use this hook to exfiltrate document field values. This is architecturally consistent with OpenSearch's trusted-plugin model, but the hook is broader than prior plugin extension points — it fires on every unmapped field rather than only at mapping-registration time.
server/src/test/java/org/opensearch/index/mapper/DynamicTemplateTests.java 58 low The test previously verified that an unknown match_mapping_type value throws an IllegalArgumentException immediately at parse time. The change removes that assertion and instead accepts any unknown string as a pluginMatchType. Validation is now deferred to index-creation time via the registry check. This is intentional for the plugin SPI feature, but it relaxes the parse-time rejection that previously caught typos and invalid type strings before they could be stored.
The table above displays the top 10 most important findings. Total: 2 | Critical: 0 | High: 0 | Medium: 1 | Low: 1

Pull Requests Author(s): Please update your Pull Request according to the report above.

Repository Maintainer(s): You can bypass diff analyzer by adding label skip-diff-analyzer after reviewing the changes carefully, then re-run failed actions. To re-enable the analyzer, remove the label, then re-run all actions.

⚠️ Note: The Code-Diff-Analyzer helps protect against potentially harmful code patterns. Please ensure you have thoroughly reviewed the changes beforehand.

Thanks.

Medium (DocumentParser:1186, plugin inferencer hook): this is by design and consistent with the trusted-plugin model. Plugins already run in-process with full access to document content (analyzers, ingest processors, mapper parsers), so this hook does not expand the trust boundary. It only fires for unmapped fields when no dynamic template matches, and only for plugins the cluster admin explicitly installed. There is no untrusted-plugin threat model in OpenSearch to defend against here, so no code change is warranted. Requesting a maintainer apply skip-diff-analyzer after review.

Low (DynamicTemplateTests:58, deferred match_mapping_type validation): the relaxation is intentional and scoped. An unknown type is still rejected, just against the plugin registry (known builtin types plus registered plugin types) rather than only the builtin enum. Typos in a plain builtin type still fail. This is required so a plugin type like knn_vector is accepted. The public 2-arg DynamicTemplate.parse still fails fast on any unknown type, so the pre-SPI behavior is preserved where no registry is available.

Wrap the plugin-inference body in a try/finally so path slots added by
getDynamicParentMapper are released even when buffering or an ambiguous
claim throws, preventing ContentPath corruption for later fields in the
same document.

Signed-off-by: ved naykude <vnaykude@amazon.com>
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit dbd28f0

@naykudev
naykudev requested a review from navneet1v August 4, 2026 01:49
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

❌ Gradle check result for dbd28f0: 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.

3 participants