Skip to content

fix: Prevent NPE in query_lookup when hit index differs from configur… - #7098

Open
nagendramohan wants to merge 2 commits into
opensearch-project:mainfrom
nagendramohan:fix/query-lookup-npe-alias-index-mismatch-6902
Open

fix: Prevent NPE in query_lookup when hit index differs from configur…#7098
nagendramohan wants to merge 2 commits into
opensearch-project:mainfrom
nagendramohan:fix/query-lookup-npe-alias-index-mismatch-6902

Conversation

@nagendramohan

Copy link
Copy Markdown

…ed index

ExistingDocumentQueryManager.dropAndReleaseFoundEvents looked up pending bulk operations using hit.index(), the index reported on each search hit. When the OpenSearch sink writes to an alias or datastream, the hit reports the concrete backing index (e.g. my-alias-000001), which differs from the configured index/alias key (my-alias) used to store the pending operations. The lookup returned null and threw a NullPointerException on every query cycle, wedging the query loop: the affected documents were never indexed, dropped, or sent to the DLQ, and only a full pipeline restart recovered (losing buffered events).

Correlate each msearch response to the index key of the request that produced it. buildMultiSearchRequest now records the index key per search request in order, and dropAndReleaseFoundEvents resolves the pending operations from that ordered key list (msearch preserves request order) instead of hit.index(). A null-guard ensures an unexpected response can never NPE and block the loop.

Adds a regression test asserting that a found duplicate is dropped and released when the hit's index differs from the configured index. Verified the test fails (NPE) against the previous implementation.

Resolves #6902

Description

ExistingDocumentQueryManager.dropAndReleaseFoundEvents looked up pending bulk operations by hit.index(). When the OpenSearch sink writes to an alias or datastream, each search hit reports the concrete backing index (e.g. my-alias-000001), which differs from the configured index/alias key (my-alias) used to store the pending operations. bulkOperationsWaitingForQuery.get(hit.index()) returned null and threw a NullPointerException on every query cycle, wedging the query loop — affected documents were never indexed, dropped, or sent to the DLQ, and only a full pipeline restart recovered (losing buffered events).

Fix

Correlate each msearch response to the index key of the request that produced it. buildMultiSearchRequest now records the index key per search request in order, and dropAndReleaseFoundEvents resolves the pending operations from that ordered key list (msearch preserves request order) instead of hit.index(). A null-guard ensures an unexpected response can never NPE and block the loop.

Issues Resolved

Resolves #6902

Check List

  • New functionality includes testing.
  • New functionality has a documentation issue. Please link to it in this PR.
    • New functionality has javadoc added
  • Commits are signed with a real name per the DCO

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.

…ed index

ExistingDocumentQueryManager.dropAndReleaseFoundEvents looked up pending
bulk operations using hit.index(), the index reported on each search hit.
When the OpenSearch sink writes to an alias or datastream, the hit reports
the concrete backing index (e.g. my-alias-000001), which differs from the
configured index/alias key (my-alias) used to store the pending operations.
The lookup returned null and threw a NullPointerException on every query
cycle, wedging the query loop: the affected documents were never indexed,
dropped, or sent to the DLQ, and only a full pipeline restart recovered
(losing buffered events).

Correlate each msearch response to the index key of the request that
produced it. buildMultiSearchRequest now records the index key per search
request in order, and dropAndReleaseFoundEvents resolves the pending
operations from that ordered key list (msearch preserves request order)
instead of hit.index(). A null-guard ensures an unexpected response can
never NPE and block the loop.

Adds a regression test asserting that a found duplicate is dropped and
released when the hit's index differs from the configured index. Verified
the test fails (NPE) against the previous implementation.

Resolves opensearch-project#6902

Signed-off-by: Nagendra Mohan <nagendramohan1990@gmail.com>
@github-actions

github-actions Bot commented Aug 15, 2026

Copy link
Copy Markdown

PR Reviewer Guide 🔍

(Review updated until commit 632ea79)

Here are some key observations to aid the review process:

🧪 PR contains tests
🔒 No security concerns identified
✅ No TODO sections
🔀 No multiple PR themes
⚡ No major issues detected

@github-actions

github-actions Bot commented Aug 15, 2026

Copy link
Copy Markdown

PR Code Suggestions ✨

Latest suggestions up to 632ea79
Explore these optional code suggestions:

CategorySuggestion                                                                                                                                    Impact
Possible issue
Guard against null result and missing term

response.result() can be null for a failed shard/response even when isFailure()
returns false in some client versions; additionally,
sourceForHit.findValue(queryTerm) may return null if the queried field is missing
from the source, causing an NPE on .textValue(). Guard against both to avoid
re-introducing a loop-wedging NPE.

data-prepper-plugins/opensearch/src/main/java/org/opensearch/dataprepper/plugins/sink/opensearch/index/ExistingDocumentQueryManager.java [296-302]

+if (response.result() == null) {
+    LOG.warn("Search response result is null for index key {}, skipping", indexKey);
+    continue;
+}
 response.result().hits().hits().forEach(hit -> {
     final ObjectNode sourceForHit = hit.source();
-    final String queryTermValue = sourceForHit.findValue(queryTerm).textValue();
+    final JsonNode termNode = sourceForHit == null ? null : sourceForHit.findValue(queryTerm);
+    if (termNode == null) {
+        LOG.warn("Hit {} missing query term {}, skipping", hit.id(), queryTerm);
+        return;
+    }
+    final String queryTermValue = termNode.textValue();
 
     lockWaitingForQuery.lock();
     try {
         if (bulkOperationsForIndex == null) {
Suggestion importance[1-10]: 4

__

Why: The suggestion adds defensive null checks that could prevent potential NPEs, but the concerns are somewhat speculative given the PR's focus. It's a reasonable robustness improvement but not critical to the fix at hand.

Low

Previous suggestions

Suggestions up to commit a278c27
CategorySuggestion                                                                                                                                    Impact
Possible issue
Perform map lookup inside the lock

The bulkOperationsForIndex map is captured outside the lock but read/mutated inside
it, which is inconsistent with the previous locking discipline and could race with
concurrent modifications to bulkOperationsWaitingForQuery. Move the
bulkOperationsWaitingForQuery.get(indexKey) lookup inside the lockWaitingForQuery
critical section to preserve the original thread-safety guarantees.

data-prepper-plugins/opensearch/src/main/java/org/opensearch/dataprepper/plugins/sink/opensearch/index/ExistingDocumentQueryManager.java [283-292]

 response.result().hits().hits().forEach(hit -> {
     final ObjectNode sourceForHit = hit.source();
     final String queryTermValue = sourceForHit.findValue(queryTerm).textValue();
 
     lockWaitingForQuery.lock();
     try {
+        final Map<String, QueryManagerBulkOperation> bulkOperationsForIndex =
+                indexKey == null ? null : bulkOperationsWaitingForQuery.get(indexKey);
         if (bulkOperationsForIndex == null) {
Suggestion importance[1-10]: 5

__

Why: The suggestion is reasonable in preserving the original locking discipline by moving the map lookup inside the lockWaitingForQuery critical section. However, since bulkOperationsWaitingForQuery is a ConcurrentHashMap, the get() call itself is thread-safe, so the practical impact is limited.

Low

Address review feedback on the request/response correlation. Previously
orderedIndexKeys was populated inside the MsearchRequest.of(...) builder
lambda, so alignment with the response order relied on the lambda running
synchronously and in order. Precompute the (index key, term-value chunk)
pairs into a deterministic list first, then build both orderedIndexKeys
and the msearch searches from that same list in the same order, so the
alignment is guaranteed by construction. No behavior change.

Signed-off-by: Nagendra Mohan <nagendramohan1990@gmail.com>
@github-actions

Copy link
Copy Markdown

Persistent review updated to latest commit 632ea79

@nagendramohan

Copy link
Copy Markdown
Author

Gentle bump on this one 🙂 — it fixes an NPE in ExistingDocumentQueryManager.dropAndReleaseFoundEvents where, for alias/datastream sinks, hit.index() (the concrete backing index) never matches the configured alias key, so the lookup NPEs every query cycle and wedges the loop — affected documents are never indexed, dropped, or sent to the DLQ until a full restart. CI is green and there's a regression test that fails against the old code. @dlvenable would appreciate your review when you have a cycle. Thanks!

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

Labels

None yet

Projects

None yet

1 participant