Skip to content

Make BulkItemRequest immutable - #20831

Merged
andrross merged 7 commits into
opensearch-project:mainfrom
msfroh:make_bulk_item_request_immutable
Apr 10, 2026
Merged

Make BulkItemRequest immutable#20831
andrross merged 7 commits into
opensearch-project:mainfrom
msfroh:make_bulk_item_request_immutable

Conversation

@msfroh

@msfroh msfroh commented Mar 10, 2026

Copy link
Copy Markdown
Contributor

Description

I was talking with @itschrispeck about some JIT optimization issues in BulkItemRequest's serialization. While looking at the code, the volatile keyword on the primaryResponse field made me cringe. Why is a BulkItemRequest mutable at all?

It turns out that we modify the existing BulkItemRequest instances on the primary shard. These modified requests (as part of a BulkShardRequest) are sent to the replicas. That is, the PrimaryExecutionContext returns the modified-in-place BulkShardRequest that it was created with, which is sent to the replicas.

This change makes BulkItemRequest immutable. The PrimaryExecutionContext collects all of the primary responses, then produces a new BulkShardRequest that combines the original BulkItemRequests with the responses, which gets forwarded to replicas.

Related Issues

N/A

Check List

  • Functionality includes testing.
  • 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.
For more information on following Developer Certificate of Origin and signing off your commits, please check here.

@github-actions

github-actions Bot commented Mar 10, 2026

Copy link
Copy Markdown
Contributor

PR Reviewer Guide 🔍

(Review updated until commit dffb34c)

Here are some key observations to aid the review process:

🧪 PR contains tests
🔒 No security concerns identified
✅ No TODO sections
🔀 Multiple PR themes

Sub-PR theme: Convert BulkItemRequest to immutable record

Relevant files:

  • server/src/main/java/org/opensearch/action/bulk/BulkItemRequest.java
  • server/src/main/java/org/opensearch/action/bulk/BulkItemResponse.java

Sub-PR theme: Refactor primary execution context to use immutable BulkItemRequest

Relevant files:

  • server/src/main/java/org/opensearch/action/bulk/BulkShardRequest.java
  • server/src/main/java/org/opensearch/action/support/replication/ReplicationRequest.java
  • server/src/main/java/org/opensearch/action/bulk/BulkPrimaryExecutionContext.java
  • server/src/main/java/org/opensearch/action/bulk/TransportShardBulkAction.java

⚡ Recommended focus areas for review

Incomplete Clone

The setPrimaryResponses method calls cloneProperties to copy ReplicationRequest fields, but it does not copy the refreshPolicy field — it passes getRefreshPolicy() directly to the constructor, which should be fine. However, it's worth verifying that cloneProperties in ReplicationRequest copies ALL mutable state (e.g., shardId is passed via constructor, but other fields like canReturnNullResponseIfNoShardAvailable or any subclass-specific fields in BulkShardRequest itself may be missed).

BulkShardRequest setPrimaryResponses(BulkItemResponse[] primaryResponses) {
    if (primaryResponses == null || primaryResponses.length != items.length) {
        throw new IllegalArgumentException("Primary responses must have same length as BulkItemRequests");
    }
    BulkItemRequest[] newRequests = new BulkItemRequest[items.length];
    for (int i = 0; i < items.length; i++) {
        BulkItemRequest request = items[i];
        if (request == null) {
            newRequests[i] = null;
        } else {
            newRequests[i] = new BulkItemRequest(request.id(), request.request(), primaryResponses[i]);
        }
    }
    BulkShardRequest bulkShardRequest = new BulkShardRequest(shardId, getRefreshPolicy(), newRequests);
    cloneProperties(bulkShardRequest);
    return bulkShardRequest;
}
Aborted Items Handling

The original code supported pre-aborting BulkItemRequest items (via abort()) before execution, and findNextNonAborted would skip them. With the new immutable design, BulkItemRequest no longer has an abort() method. The findNextNonAborted now checks request.items()[startIndex].primaryResponse() on the original request array. However, since BulkItemRequest is now immutable and abort() is removed, it's unclear how pre-aborted items are handled — the test testAbortedSkipped and testSkipBulkIndexRequestIfAborted were removed. This behavior change should be validated to ensure no regressions.

private int findNextNonAborted(int startIndex) {
    final int length = request.items().length;
    while (startIndex < length && isAborted(request.items()[startIndex].primaryResponse())) {
        startIndex++;
    }
    return startIndex;
Null primaryResponses

In buildShardResponse, primaryResponses array is passed directly to BulkShardResponse. Some entries may be null (for items that were skipped/aborted or null items). It should be verified that BulkShardResponse and downstream consumers handle null entries in the primaryResponses array correctly, especially since the old code used getPrimaryResponse() which could also return null.

return new BulkShardResponse(request.shardId(), primaryResponses, serviceTimeEWMAInNanos, nodeQueueSize);
Incomplete Clone

The cloneProperties method copies waitForActiveShards, timeout, routedBasedOnClusterVersion, and parentTask, but may miss other mutable fields in ReplicationRequest or its subclasses (e.g., index, shardId is handled via constructor). Any future additions to ReplicationRequest fields would silently not be cloned.

protected void cloneProperties(ReplicationRequest<?> target) {
    target.waitForActiveShards(waitForActiveShards());
    target.timeout(timeout());
    target.routedBasedOnClusterVersion(routedBasedOnClusterVersion());
    target.setParentTask(getParentTask());
}

@github-actions

github-actions Bot commented Mar 10, 2026

Copy link
Copy Markdown
Contributor

PR Code Suggestions ✨

Latest suggestions up to dffb34c

Explore these optional code suggestions:

CategorySuggestion                                                                                                                                    Impact
General
Guard against premature bulk request retrieval

getBulkShardRequest() is called multiple times in tests and potentially in
production code (e.g., after each item is processed). Each call creates a new
BulkShardRequest clone with setPrimaryResponses, which is expensive and may return
inconsistent snapshots if called before all items are completed. Consider caching
the result or only allowing this call after all operations are complete (i.e.,
hasMoreOperationsToExecute() == false).

server/src/main/java/org/opensearch/action/bulk/BulkPrimaryExecutionContext.java [132-134]

 public BulkShardRequest getBulkShardRequest() {
+    assert hasMoreOperationsToExecute() == false : "getBulkShardRequest() called before all operations are complete";
     return request.setPrimaryResponses(primaryResponses);
 }
Suggestion importance[1-10]: 5

__

Why: Adding an assertion to prevent getBulkShardRequest() from being called before all operations are complete is a reasonable defensive programming practice. However, looking at the test code, getBulkShardRequest() is called after hasMoreOperationsToExecute() returns false in most cases, so this is a moderate improvement for correctness.

Low
Possible issue
Ensure all fields are copied when cloning

The cloneProperties method in ReplicationRequest does not copy all relevant fields.
Specifically, BulkShardRequest has its own waitForActiveShards and other fields that
may be set via setRefreshPolicy. More critically, BulkShardRequest may have
additional state (e.g., canReturnNullResponseIfRejected) that is not copied by
cloneProperties, leading to silent data loss when cloning. Ensure all
BulkShardRequest-specific fields are also copied in this method.

server/src/main/java/org/opensearch/action/bulk/BulkShardRequest.java [71-87]

 BulkShardRequest setPrimaryResponses(BulkItemResponse[] primaryResponses) {
     if (primaryResponses == null || primaryResponses.length != items.length) {
         throw new IllegalArgumentException("Primary responses must have same length as BulkItemRequests");
     }
     BulkItemRequest[] newRequests = new BulkItemRequest[items.length];
     for (int i = 0; i < items.length; i++) {
         BulkItemRequest request = items[i];
         if (request == null) {
             newRequests[i] = null;
         } else {
             newRequests[i] = new BulkItemRequest(request.id(), request.request(), primaryResponses[i]);
         }
     }
     BulkShardRequest bulkShardRequest = new BulkShardRequest(shardId, getRefreshPolicy(), newRequests);
     cloneProperties(bulkShardRequest);
+    bulkShardRequest.canReturnNullResponseIfRejected(canReturnNullResponseIfRejected());
     return bulkShardRequest;
 }
Suggestion importance[1-10]: 4

__

Why: The suggestion raises a valid concern about missing BulkShardRequest-specific fields not being copied by cloneProperties. However, the improved_code references canReturnNullResponseIfRejected() which may not exist in the codebase, and the suggestion is speculative without confirming this field exists. The concern is legitimate but the fix is unverified.

Low

Previous suggestions

Suggestions up to commit 96da1db
CategorySuggestion                                                                                                                                    Impact
Possible issue
Assert invariants before advancing index

When the item is replaced with a new BulkItemRequest (for translated update
requests), the new item is stored in request.items()[currentIndex], but
primaryResponses[currentIndex] is stored separately. Later, setPrimaryResponses
pairs items[i] with primaryResponses[i] by index, which is correct. However, the
assertInvariants for COMPLETED state checks primaryResponses[currentIndex] != null,
but currentIndex may have already been advanced by advance() called at the end of
markAsCompleted. The assert should be checked before advance() is called.

server/src/main/java/org/opensearch/action/bulk/BulkPrimaryExecutionContext.java [337-343]

 if (translatedResponse.isFailed() == false && requestToExecute != null && requestToExecute != getCurrent()) {
     request.items()[currentIndex] = new BulkItemRequest(request.items()[currentIndex].id(), requestToExecute);
 }
 primaryResponses[currentIndex] = translatedResponse;
+currentItemState = ItemProcessingState.COMPLETED;
+assertInvariants(ItemProcessingState.COMPLETED);
+advance();
Suggestion importance[1-10]: 6

__

Why: The suggestion correctly identifies that assertInvariants(ItemProcessingState.COMPLETED) checks primaryResponses[currentIndex] != null, but advance() may change currentIndex before the assert runs. The improved code reorders the operations to assert before advancing, which is a valid correctness fix. However, looking at the PR diff, assertInvariants is already called before advance() in the new code (line 342 before line 343), so this may already be addressed.

Low
Guard against incomplete state when building request

getBulkShardRequest() is called multiple times in tests (e.g., once before
hasMoreOperationsToExecute() returns false), and each call creates a new
BulkShardRequest via setPrimaryResponses. If called before all items are completed,
some entries in primaryResponses may still be null, leading to a BulkShardRequest
with incomplete responses. Consider adding an assertion or guard that all operations
are complete before building the final request.

server/src/main/java/org/opensearch/action/bulk/BulkPrimaryExecutionContext.java [132-134]

 public BulkShardRequest getBulkShardRequest() {
+    assert hasMoreOperationsToExecute() == false : "getBulkShardRequest() called before all operations are complete";
     return request.setPrimaryResponses(primaryResponses);
 }
Suggestion importance[1-10]: 5

__

Why: This is a valid defensive programming suggestion — adding an assertion that hasMoreOperationsToExecute() == false before building the final BulkShardRequest prevents misuse when primaryResponses may still have null entries. It aligns with how buildShardResponse already has this assertion.

Low
General
Ensure all properties are correctly cloned

The cloneProperties method in ReplicationRequest does not copy all relevant fields.
Specifically, BulkShardRequest has its own waitForActiveShards and other fields that
may be set via setRefreshPolicy. More critically, the BulkShardRequest constructor
calls setRefreshPolicy(refreshPolicy) which is already handled, but the
cloneProperties call also sets waitForActiveShards — however, the BulkShardRequest
constructor may override waitForActiveShards internally. You should verify that
cloneProperties is called after the constructor so its values are not overwritten,
which appears correct here, but the refreshPolicy passed to the constructor and then
waitForActiveShards set by cloneProperties could conflict if the constructor
internally sets waitForActiveShards based on refreshPolicy.

server/src/main/java/org/opensearch/action/bulk/BulkShardRequest.java [71-87]

 BulkShardRequest setPrimaryResponses(BulkItemResponse[] primaryResponses) {
     if (primaryResponses == null || primaryResponses.length != items.length) {
         throw new IllegalArgumentException("Primary responses must have same length as BulkItemRequests");
     }
     BulkItemRequest[] newRequests = new BulkItemRequest[items.length];
     for (int i = 0; i < items.length; i++) {
         BulkItemRequest request = items[i];
         if (request == null) {
             newRequests[i] = null;
         } else {
             newRequests[i] = new BulkItemRequest(request.id(), request.request(), primaryResponses[i]);
         }
     }
+    // Use RefreshPolicy.NONE to avoid constructor side-effects on waitForActiveShards,
+    // then restore all properties via cloneProperties
     BulkShardRequest bulkShardRequest = new BulkShardRequest(shardId, getRefreshPolicy(), newRequests);
     cloneProperties(bulkShardRequest);
+    bulkShardRequest.setRefreshPolicy(getRefreshPolicy());
     return bulkShardRequest;
 }
Suggestion importance[1-10]: 2

__

Why: The suggestion points out a potential conflict between refreshPolicy set in the constructor and waitForActiveShards set by cloneProperties, but the improved code actually calls setRefreshPolicy again after cloneProperties, which is redundant since the constructor already sets it. The concern is speculative and the improved_code doesn't meaningfully differ from the existing_code in terms of correctness.

Low
Suggestions up to commit 5b520fd
CategorySuggestion                                                                                                                                    Impact
General
Guard against premature access to completed request

getBulkShardRequest() is called multiple times in tests (e.g., after
executeBulkItemRequest) and each call creates a new BulkShardRequest clone via
setPrimaryResponses. This is wasteful and potentially inconsistent if the method is
called at different points during execution. Consider caching the result or only
allowing it to be called once processing is complete (e.g., assert
hasMoreOperationsToExecute() == false).

server/src/main/java/org/opensearch/action/bulk/BulkPrimaryExecutionContext.java [132-134]

 public BulkShardRequest getBulkShardRequest() {
+    assert hasMoreOperationsToExecute() == false : "getBulkShardRequest() called before all operations are complete";
     return request.setPrimaryResponses(primaryResponses);
 }
Suggestion importance[1-10]: 5

__

Why: Adding an assertion that getBulkShardRequest() is only called after all operations are complete is a reasonable defensive check. The tests do call it after hasMoreOperationsToExecute() returns false, but adding the guard would prevent misuse and improve correctness guarantees.

Low
Verify all mutable state is copied during cloning

The cloneProperties method in ReplicationRequest does not copy the index field (from
IndicesRequest) or other fields that may be set on BulkShardRequest itself (e.g.,
waitForActiveShards is set via setRefreshPolicy indirectly, but other fields like
the index from IndicesRequest may be missed). More critically, BulkShardRequest
extends ReplicationRequest which extends IndicesRequest, and the shardId is passed
to the constructor, but any other mutable state on BulkShardRequest (beyond what
cloneProperties covers) would be lost. Verify that cloneProperties captures all
necessary state, and consider whether BulkShardRequest has additional fields (e.g.,
from IndicesRequest) that need to be copied.

server/src/main/java/org/opensearch/action/bulk/BulkShardRequest.java [71-87]

 BulkShardRequest setPrimaryResponses(BulkItemResponse[] primaryResponses) {
     if (primaryResponses == null || primaryResponses.length != items.length) {
         throw new IllegalArgumentException("Primary responses must have same length as BulkItemRequests");
     }
     BulkItemRequest[] newRequests = new BulkItemRequest[items.length];
     for (int i = 0; i < items.length; i++) {
         BulkItemRequest request = items[i];
         if (request == null) {
             newRequests[i] = null;
         } else {
             newRequests[i] = new BulkItemRequest(request.id(), request.request(), primaryResponses[i]);
         }
     }
-    BulkShardRequest bulkShardRequest = new BulkShardRequest(shardId, getRefreshPolicy(), newRequests);
+    BulkShardRequest bulkShardRequest = new BulkShardRequest(shardId(), getRefreshPolicy(), newRequests);
     cloneProperties(bulkShardRequest);
     return bulkShardRequest;
 }
Suggestion importance[1-10]: 3

__

Why: The suggestion asks to verify that cloneProperties captures all necessary state, but the improved_code only changes shardId to shardId() which is a minor accessor change. The existing code already uses shardId directly (a field), and the suggestion's concern about missing state is valid but the fix shown doesn't actually address the stated concern about IndicesRequest fields.

Low
Document invariant about intermediate null primary responses

When a translated request (e.g., an update converted to an index) is stored back
into request.items()[currentIndex], the new BulkItemRequest is created without a
primaryResponse. However, primaryResponses[currentIndex] is stored separately.
Later, when setPrimaryResponses is called, it will pair primaryResponses[i] with the
updated item. This is correct, but the item stored in request.items()[currentIndex]
still has primaryResponse == null. If any code path reads
request.items()[currentIndex].primaryResponse() before getBulkShardRequest() is
called, it will get null. Ensure no code reads primaryResponse() directly from
request.items() during execution.

server/src/main/java/org/opensearch/action/bulk/BulkPrimaryExecutionContext.java [337-340]

+if (translatedResponse.isFailed() == false && requestToExecute != null && requestToExecute != getCurrent()) {
+    request.items()[currentIndex] = new BulkItemRequest(request.items()[currentIndex].id(), requestToExecute);
+}
+primaryResponses[currentIndex] = translatedResponse;
 
-
Suggestion importance[1-10]: 1

__

Why: The existing_code and improved_code are identical, meaning no actual code change is proposed. This is purely a documentation/verification suggestion with no actionable code fix, so it should receive a very low score.

Low
Possible issue
Validate non-null items have corresponding primary responses

The primaryResponses array may contain null entries (e.g., for null items), but
non-null items could also have a null primaryResponse if they were skipped or not
yet processed. This could cause NullPointerException downstream in performOnReplica
where response.isFailed() is called without a null check (only an assert is added).
Consider validating that all non-null items have a non-null corresponding
primaryResponse, or ensure the assert is sufficient for production safety.

server/src/main/java/org/opensearch/action/bulk/BulkShardRequest.java [71-74]

 BulkShardRequest setPrimaryResponses(BulkItemResponse[] primaryResponses) {
     if (primaryResponses == null || primaryResponses.length != items.length) {
         throw new IllegalArgumentException("Primary responses must have same length as BulkItemRequests");
     }
+    for (int i = 0; i < items.length; i++) {
+        if (items[i] != null && primaryResponses[i] == null) {
+            throw new IllegalArgumentException("Primary response at index " + i + " must not be null for a non-null item");
+        }
+    }
Suggestion importance[1-10]: 4

__

Why: Adding validation that non-null items have non-null primary responses is a reasonable defensive check that could prevent NullPointerException in performOnReplica. However, the PR already adds an assert response != null in performOnReplica, so this would be a redundant but more explicit validation at the entry point.

Low
Suggestions up to commit c49aeb5
CategorySuggestion                                                                                                                                    Impact
Possible issue
Fix assertion checking wrong response storage location

The COMPLETED state assertion now checks getCurrentItem().primaryResponse() on the
original (immutable) BulkItemRequest, but primary responses are now stored in the
separate primaryResponses array rather than in the item itself. This assertion will
always fail since BulkItemRequest.primaryResponse() will be null for items that
haven't been cloned with a response yet. The assertion should check
primaryResponses[currentIndex] instead.

server/src/main/java/org/opensearch/action/bulk/BulkPrimaryExecutionContext.java [378]

-assert getCurrentItem().primaryResponse() != null;
+assert primaryResponses[currentIndex] != null;
Suggestion importance[1-10]: 8

__

Why: This is a valid bug - the COMPLETED state assertion checks getCurrentItem().primaryResponse() on the original immutable BulkItemRequest, but responses are now stored in the separate primaryResponses array. The assertion would always fail since the item's primaryResponse() field won't be set until getBulkShardRequest() is called. Changing to primaryResponses[currentIndex] != null correctly validates the new storage location.

Medium
General
Validate non-null items have non-null responses

The primaryResponses array may contain null entries for non-null items (e.g., items
that were skipped/aborted), but the validation only checks the array length.
Consider also verifying that each non-null item has a corresponding non-null primary
response, or at minimum document that null responses are intentionally allowed.

server/src/main/java/org/opensearch/action/bulk/BulkShardRequest.java [71-74]

 BulkShardRequest setPrimaryResponses(BulkItemResponse[] primaryResponses) {
     if (primaryResponses == null || primaryResponses.length != items.length) {
         throw new IllegalArgumentException("Primary responses must have same length as BulkItemRequests");
     }
+    for (int i = 0; i < items.length; i++) {
+        if (items[i] != null && primaryResponses[i] == null) {
+            throw new IllegalArgumentException("Primary response must not be null for non-null item at index " + i);
+        }
+    }
Suggestion importance[1-10]: 4

__

Why: The suggestion adds validation to ensure non-null items have corresponding non-null primary responses. However, looking at the code, null responses may be intentional for aborted items, and the existing code in BulkPrimaryExecutionContext already handles null responses in primaryResponses array. This could break valid use cases.

Low
Suggestions up to commit d2a6232
CategorySuggestion                                                                                                                                    Impact
Possible issue
Fix abort-skipping logic to use new responses array

After making BulkItemRequest immutable, aborted items can no longer be pre-marked
via setPrimaryResponse. The findNextNonAborted method now checks primaryResponse()
on the original request items, but aborted responses are stored in
primaryResponses[]. This means aborted items will never be skipped, breaking the
abort-skipping logic entirely.

server/src/main/java/org/opensearch/action/bulk/BulkPrimaryExecutionContext.java [105]

-while (startIndex < length && isAborted(request.items()[startIndex].primaryResponse())) {
+while (startIndex < length && isAborted(primaryResponses[startIndex])) {
Suggestion importance[1-10]: 9

__

Why: This is a critical correctness issue: since BulkItemRequest is now immutable, request.items()[startIndex].primaryResponse() will always return the original value (never an aborted response), so the abort-skipping logic is completely broken. The fix to use primaryResponses[startIndex] is correct and necessary.

High
Fix incorrect assertion on immutable record field

The COMPLETED state assertion now checks getCurrentItem().primaryResponse() on the
original (immutable) BulkItemRequest, but primary responses are now stored in the
separate primaryResponses array rather than in the item itself. This assertion will
always fail since the record's primaryResponse field is never mutated. It should
check primaryResponses[currentIndex] instead.

server/src/main/java/org/opensearch/action/bulk/BulkPrimaryExecutionContext.java [390]

-assert getCurrentItem().primaryResponse() != null;
+assert primaryResponses[currentIndex] != null;
Suggestion importance[1-10]: 8

__

Why: This is a valid bug: BulkItemRequest is now an immutable record, so getCurrentItem().primaryResponse() will always return the original value (likely null) rather than the one stored in primaryResponses[currentIndex]. The assertion would incorrectly fail or pass depending on the original record state.

Medium
General
Guard against premature bulk shard request construction

getBulkShardRequest() is called multiple times in tests and potentially in
production code paths. Each call creates a new BulkShardRequest with new
BulkItemRequest instances, which is expensive and may cause inconsistencies if
called before all items are completed. Consider computing this once lazily or only
when hasMoreOperationsToExecute() is false.

server/src/main/java/org/opensearch/action/bulk/BulkPrimaryExecutionContext.java [133-145]

 public BulkShardRequest getBulkShardRequest() {
+    assert hasMoreOperationsToExecute() == false : "getBulkShardRequest() called before all operations are completed";
     BulkItemRequest[] newRequests = new BulkItemRequest[request.items().length];
     for (int i = 0; i < newRequests.length; i++) {
         BulkItemRequest oldRequest = request.items()[i];
         newRequests[i] = new BulkItemRequest(oldRequest.id(), oldRequest.request(), primaryResponses[i]);
     }
     BulkShardRequest bulkShardRequest = new BulkShardRequest(request.shardId(), request.getRefreshPolicy(), newRequests);
-    ...
+    bulkShardRequest.waitForActiveShards(request.waitForActiveShards());
+    bulkShardRequest.timeout(request.timeout());
+    bulkShardRequest.routedBasedOnClusterVersion(request.routedBasedOnClusterVersion());
+    bulkShardRequest.setParentTask(request.getParentTask());
     return bulkShardRequest;
 }
Suggestion importance[1-10]: 5

__

Why: Adding an assertion to guard against calling getBulkShardRequest() before all operations are completed is a reasonable defensive measure, but it's a minor improvement since buildShardResponse already has such an assertion and the method is primarily called after completion in tests.

Low
Suggestions up to commit 93814fc
CategorySuggestion                                                                                                                                    Impact
General
Avoid mutating original request items array

When markAsCompleted updates request.items()[currentIndex] with a new
BulkItemRequest (for translated requests like updates), the getBulkShardRequest()
method later reads oldRequest.request() from request.items()[i]. This means the
translated requestToExecute is correctly captured in the items array. However,
primaryResponses[currentIndex] is stored separately and then combined in
getBulkShardRequest(). This is consistent, but the update to request.items() is
still mutating the original BulkShardRequest's items array, which undermines the
immutability goal. Consider storing translated requests in a separate array instead
of mutating request.items().

server/src/main/java/org/opensearch/action/bulk/BulkPrimaryExecutionContext.java [342-345]

+// In BulkPrimaryExecutionContext, add a field:
+private final DocWriteRequest<?>[] translatedRequests;
+
+// In constructor:
+this.translatedRequests = new DocWriteRequest<?>[request.items().length];
+
+// In markAsCompleted:
 if (translatedResponse.isFailed() == false && requestToExecute != null && requestToExecute != getCurrent()) {
-    request.items()[currentIndex] = new BulkItemRequest(request.items()[currentIndex].id(), requestToExecute);
+    translatedRequests[currentIndex] = requestToExecute;
 }
 primaryResponses[currentIndex] = translatedResponse;
 
+// In getBulkShardRequest:
+BulkItemRequest oldRequest = request.items()[i];
+DocWriteRequest<?> effectiveRequest = translatedRequests[i] != null ? translatedRequests[i] : oldRequest.request();
+newRequests[i] = new BulkItemRequest(oldRequest.id(), effectiveRequest, primaryResponses[i]);
+
Suggestion importance[1-10]: 5

__

Why: The suggestion correctly identifies that request.items()[currentIndex] is still being mutated in markAsCompleted, which partially undermines the immutability goal of the PR. However, the improved_code is a multi-part snippet spanning multiple methods and fields, making it harder to evaluate precisely. The concern is valid but the implementation is complex and the getBulkShardRequest() method already reads from request.items() to get the (potentially mutated) request, so the current approach is at least consistent.

Low
Guard against premature bulk request snapshot

The getBulkShardRequest() method creates a new BulkShardRequest on every call, which
is expensive and may produce inconsistent snapshots if called multiple times during
processing. Since this method is called both during processing (to update item
requests) and at the end to retrieve results, consider only building the new request
once after all operations are complete, or caching the result after processing is
done.

server/src/main/java/org/opensearch/action/bulk/BulkPrimaryExecutionContext.java [133-139]

 public BulkShardRequest getBulkShardRequest() {
+    assert hasMoreOperationsToExecute() == false : "getBulkShardRequest() called before all operations are complete";
     BulkItemRequest[] newRequests = new BulkItemRequest[request.items().length];
     for (int i = 0; i < newRequests.length; i++) {
         BulkItemRequest oldRequest = request.items()[i];
         newRequests[i] = new BulkItemRequest(oldRequest.id(), oldRequest.request(), primaryResponses[i]);
     }
     return new BulkShardRequest(request.shardId(), request.getRefreshPolicy(), newRequests);
 }
Suggestion importance[1-10]: 4

__

Why: Adding an assertion to prevent getBulkShardRequest() from being called before all operations complete is a reasonable defensive check. However, looking at the test code, getBulkShardRequest() is called after assertFalse(context.hasMoreOperationsToExecute()), so this is more of a safety guard than fixing a real bug. The improvement is minor.

Low
Possible issue
Verify request identity preserved after immutability refactor

The loop iterates over completedRequest[0].items() but calls verify with
eq(updateRequest) where updateRequest is cast from item.request(). Since
getBulkShardRequest() now creates new BulkItemRequest objects, the updateRequest
reference from the completed request may differ from the original UpdateRequest used
in when(updateHelper.prepare(...)). Verify that the UpdateRequest identity is
preserved through the new BulkItemRequest construction to ensure Mockito's eq()
matcher works correctly.

server/src/test/java/org/opensearch/action/bulk/TransportShardBulkActionTests.java [1232-1243]

 for (BulkItemRequest item : completedRequest[0].items()) {
+    assertNotNull(item.getPrimaryResponse());
     assertEquals(item.getPrimaryResponse().getFailure().getCause().getClass(), VersionConflictEngineException.class);
 
-    // this assertion is based on the assumption that all bulk item requests are updates and are hence calling
-    // UpdateRequest::prepareRequest
     UpdateRequest updateRequest = (UpdateRequest) item.request();
+    // Ensure the same UpdateRequest instance is used for verification
+    assertSame(updateRequest, items.stream()
+        .filter(orig -> orig.id() == item.id())
+        .findFirst().get().request());
     verify(updateHelper, times(updateRequest.retryOnConflict() + 1)).prepare(
         eq(updateRequest),
         any(IndexShard.class),
         any(LongSupplier.class)
     );
 }
Suggestion importance[1-10]: 5

__

Why: The concern about UpdateRequest identity through the new BulkItemRequest construction is valid - since getBulkShardRequest() creates new BulkItemRequest objects wrapping the same DocWriteRequest references, the eq() matcher should still work as object identity is preserved. However, the improved_code adds unnecessary complexity with a stream lookup. The suggestion raises a legitimate concern but the actual risk is low since the request references are passed through unchanged.

Low

@github-actions

Copy link
Copy Markdown
Contributor

❌ Gradle check result for 0450fb9: 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?

@msfroh
msfroh force-pushed the make_bulk_item_request_immutable branch from 0450fb9 to 1201d59 Compare March 19, 2026 23:31
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 1201d59

@github-actions

Copy link
Copy Markdown
Contributor

❌ Gradle check result for 1201d59: 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?

@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 93814fc

Comment thread server/src/main/java/org/opensearch/action/bulk/BulkItemRequest.java Outdated
@github-actions

Copy link
Copy Markdown
Contributor

❌ Gradle check result for 93814fc: 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?

@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit d2a6232

@github-actions

Copy link
Copy Markdown
Contributor

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

@github-actions

Copy link
Copy Markdown
Contributor

Failed to generate code suggestions for PR

@github-actions

Copy link
Copy Markdown
Contributor

❌ Gradle check result for 8bde4b5: 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?

@msfroh
msfroh force-pushed the make_bulk_item_request_immutable branch from 8bde4b5 to ea74e1a Compare March 25, 2026 19:36
@github-actions

Copy link
Copy Markdown
Contributor

Failed to generate code suggestions for PR

@msfroh
msfroh force-pushed the make_bulk_item_request_immutable branch from ea74e1a to 3ca0f4a Compare March 25, 2026 19:44
@github-actions

Copy link
Copy Markdown
Contributor

Failed to generate code suggestions for PR

@github-actions

Copy link
Copy Markdown
Contributor

✅ Gradle check result for 3ca0f4a: SUCCESS

@codecov

codecov Bot commented Mar 25, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 77.77778% with 8 lines in your changes missing coverage. Please review.
✅ Project coverage is 73.16%. Comparing base (9bfcc1d) to head (dffb34c).
⚠️ Report is 17 commits behind head on main.

Files with missing lines Patch % Lines
...earch/action/bulk/BulkPrimaryExecutionContext.java 66.66% 1 Missing and 2 partials ⚠️
...a/org/opensearch/action/bulk/BulkShardRequest.java 81.81% 1 Missing and 1 partial ⚠️
...ensearch/action/bulk/TransportShardBulkAction.java 50.00% 0 Missing and 2 partials ⚠️
...va/org/opensearch/action/bulk/BulkItemRequest.java 85.71% 1 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##               main   #20831      +/-   ##
============================================
+ Coverage     73.10%   73.16%   +0.05%     
- Complexity    73213    73220       +7     
============================================
  Files          5968     5968              
  Lines        334539   334532       -7     
  Branches      48174    48171       -3     
============================================
+ Hits         244572   244766     +194     
+ Misses        70421    70176     -245     
- Partials      19546    19590      +44     

☔ View full report in Codecov by Sentry.
📢 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.

@msfroh
msfroh marked this pull request as ready for review March 26, 2026 06:48
@msfroh
msfroh requested a review from a team as a code owner March 26, 2026 06:48
@msfroh

msfroh commented Mar 26, 2026

Copy link
Copy Markdown
Contributor Author

The comments from the PR bot (#20831 (comment)) are not bad.

  • Null dereference: I believe we're guaranteed that every BulkItemRequest will have a non-null response after processing on the primary. Still, maybe it's worth adding a defensive null-check just in case?
  • Abort handling: I found that the abort logic was dead code. I searched for references and it was only being called from tests. So, I cleaned it up.
  • The missing properties in the cloning logic is tricky -- as the bot called out, if we ever add more properties to BulkShardRequest, we could miss copying them here. Maybe it would be safer if we move the logic into BulkShardRequest itself and add a dedicated unit test. That might be less of a gotcha.

@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit c49aeb5

@github-actions

Copy link
Copy Markdown
Contributor

✅ Gradle check result for c49aeb5: SUCCESS

@github-actions

github-actions Bot commented Apr 2, 2026

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 5b520fd

@github-actions

github-actions Bot commented Apr 2, 2026

Copy link
Copy Markdown
Contributor

❌ Gradle check result for 5b520fd: 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?

Comment thread server/src/main/java/org/opensearch/action/bulk/BulkItemRequest.java Outdated
msfroh added 6 commits April 7, 2026 12:39
I was talking with @itschrispeck about some JIT optimization issues in
BulkItemRequest's serialization. While looking at the code, the
`volatile` keyword on the `primaryResponse` field made me cringe. Why
is a `BulkItemRequest` mutable at all?

It turns out that we modify the existing `BulkItemRequest` instances
on the primary shard. These modified requests are send to the replicas.

This change makes `BulkItemRequest` immutable. The primary execution
context collects all of the primary responses, then produces a new
`BulkShardRequest` that gets forwarded to replicas.

Signed-off-by: Michael Froh <msfroh@apache.org>
These tests relied on the assumption that the BulkShardRequest would be
mutated on the primary.

In particular, there were some ridiculous tests that were verifying
that the output was unchanged from the input, when the output was the
same object as the input, which had been changed in place.

Signed-off-by: Michael Froh <msfroh@apache.org>
Follow @andrross's suggestion of converting BulkItemRequest to a record,
since it's immutable now.

Also, we were seeing test failures because the cloned BulkShardRequest
(that is propagated to replicas) did not copy the primary request's
parent TaskId. Along with that, I made sure it copied all other
properties from the primary shard request.

Signed-off-by: Michael Froh <msfroh@apache.org>
1. Add null-check (assert) on primary responses when running on replica.
2. Move BulkShardRequest cloning logic from BulkPrimaryExecutionContext
   to BulkShardRequest and ReplicationRequest. Add a dedicated unit test
   for it.

Signed-off-by: Michael Froh <msfroh@apache.org>
Signed-off-by: Michael Froh <msfroh@apache.org>
We previously weren't checking invariants for COMPLETED operations,
so the assertions never ran. As called out by @andrross, there are
two possible code paths that mark an operation as completed without
setting requestToExecute.

Signed-off-by: Michael Froh <msfroh@apache.org>
@msfroh
msfroh force-pushed the make_bulk_item_request_immutable branch from 5b520fd to 96da1db Compare April 7, 2026 20:03
@github-actions

github-actions Bot commented Apr 7, 2026

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 96da1db

@github-actions

github-actions Bot commented Apr 7, 2026

Copy link
Copy Markdown
Contributor

❌ Gradle check result for 96da1db: 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?

Signed-off-by: Michael Froh <msfroh@apache.org>
@github-actions

github-actions Bot commented Apr 8, 2026

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit dffb34c

@github-actions

github-actions Bot commented Apr 8, 2026

Copy link
Copy Markdown
Contributor

❌ Gradle check result for dffb34c:

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 Apr 9, 2026

Copy link
Copy Markdown
Contributor

❕ Gradle check result for dffb34c: 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.

@andrross
andrross merged commit 92b0c07 into opensearch-project:main Apr 10, 2026
18 of 20 checks passed
@msfroh
msfroh deleted the make_bulk_item_request_immutable branch April 10, 2026 18:19
aparajita31pandey pushed a commit to aparajita31pandey/OpenSearch that referenced this pull request Apr 18, 2026
* Make BulkItemRequest immutable

I was talking with @itschrispeck about some JIT optimization issues in
BulkItemRequest's serialization. While looking at the code, the
`volatile` keyword on the `primaryResponse` field made me cringe. Why
is a `BulkItemRequest` mutable at all?

It turns out that we modify the existing `BulkItemRequest` instances
on the primary shard. These modified requests are send to the replicas.

This change makes `BulkItemRequest` immutable. The primary execution
context collects all of the primary responses, then produces a new
`BulkShardRequest` that gets forwarded to replicas.

Signed-off-by: Michael Froh <msfroh@apache.org>

* Fix TransportShardBulkActionTests

These tests relied on the assumption that the BulkShardRequest would be
mutated on the primary.

In particular, there were some ridiculous tests that were verifying
that the output was unchanged from the input, when the output was the
same object as the input, which had been changed in place.

Signed-off-by: Michael Froh <msfroh@apache.org>

* Make BulkItemRequest a record and fix BulkShardRequest clone

Follow @andrross's suggestion of converting BulkItemRequest to a record,
since it's immutable now.

Also, we were seeing test failures because the cloned BulkShardRequest
(that is propagated to replicas) did not copy the primary request's
parent TaskId. Along with that, I made sure it copied all other
properties from the primary shard request.

Signed-off-by: Michael Froh <msfroh@apache.org>

* Address PR bot comments

1. Add null-check (assert) on primary responses when running on replica.
2. Move BulkShardRequest cloning logic from BulkPrimaryExecutionContext
   to BulkShardRequest and ReplicationRequest. Add a dedicated unit test
   for it.

Signed-off-by: Michael Froh <msfroh@apache.org>

* Fix (and use) broken branch in assertInvariants

Signed-off-by: Michael Froh <msfroh@apache.org>

* Remove assertion on requestToExecute

We previously weren't checking invariants for COMPLETED operations,
so the assertions never ran. As called out by @andrross, there are
two possible code paths that mark an operation as completed without
setting requestToExecute.

Signed-off-by: Michael Froh <msfroh@apache.org>

* Remove reference copy of primaryReponse

Signed-off-by: Michael Froh <msfroh@apache.org>

---------

Signed-off-by: Michael Froh <msfroh@apache.org>
Signed-off-by: Aparajita Pandey <aparajita31pandey@gmail.com>
pradeep-L pushed a commit to pradeep-L/OpenSearch that referenced this pull request Apr 21, 2026
* Make BulkItemRequest immutable

I was talking with @itschrispeck about some JIT optimization issues in
BulkItemRequest's serialization. While looking at the code, the
`volatile` keyword on the `primaryResponse` field made me cringe. Why
is a `BulkItemRequest` mutable at all?

It turns out that we modify the existing `BulkItemRequest` instances
on the primary shard. These modified requests are send to the replicas.

This change makes `BulkItemRequest` immutable. The primary execution
context collects all of the primary responses, then produces a new
`BulkShardRequest` that gets forwarded to replicas.

Signed-off-by: Michael Froh <msfroh@apache.org>

* Fix TransportShardBulkActionTests

These tests relied on the assumption that the BulkShardRequest would be
mutated on the primary.

In particular, there were some ridiculous tests that were verifying
that the output was unchanged from the input, when the output was the
same object as the input, which had been changed in place.

Signed-off-by: Michael Froh <msfroh@apache.org>

* Make BulkItemRequest a record and fix BulkShardRequest clone

Follow @andrross's suggestion of converting BulkItemRequest to a record,
since it's immutable now.

Also, we were seeing test failures because the cloned BulkShardRequest
(that is propagated to replicas) did not copy the primary request's
parent TaskId. Along with that, I made sure it copied all other
properties from the primary shard request.

Signed-off-by: Michael Froh <msfroh@apache.org>

* Address PR bot comments

1. Add null-check (assert) on primary responses when running on replica.
2. Move BulkShardRequest cloning logic from BulkPrimaryExecutionContext
   to BulkShardRequest and ReplicationRequest. Add a dedicated unit test
   for it.

Signed-off-by: Michael Froh <msfroh@apache.org>

* Fix (and use) broken branch in assertInvariants

Signed-off-by: Michael Froh <msfroh@apache.org>

* Remove assertion on requestToExecute

We previously weren't checking invariants for COMPLETED operations,
so the assertions never ran. As called out by @andrross, there are
two possible code paths that mark an operation as completed without
setting requestToExecute.

Signed-off-by: Michael Froh <msfroh@apache.org>

* Remove reference copy of primaryReponse

Signed-off-by: Michael Froh <msfroh@apache.org>

---------

Signed-off-by: Michael Froh <msfroh@apache.org>
imRishN pushed a commit to imRishN/OpenSearch that referenced this pull request May 8, 2026
* Make BulkItemRequest immutable

I was talking with @itschrispeck about some JIT optimization issues in
BulkItemRequest's serialization. While looking at the code, the
`volatile` keyword on the `primaryResponse` field made me cringe. Why
is a `BulkItemRequest` mutable at all?

It turns out that we modify the existing `BulkItemRequest` instances
on the primary shard. These modified requests are send to the replicas.

This change makes `BulkItemRequest` immutable. The primary execution
context collects all of the primary responses, then produces a new
`BulkShardRequest` that gets forwarded to replicas.

Signed-off-by: Michael Froh <msfroh@apache.org>

* Fix TransportShardBulkActionTests

These tests relied on the assumption that the BulkShardRequest would be
mutated on the primary.

In particular, there were some ridiculous tests that were verifying
that the output was unchanged from the input, when the output was the
same object as the input, which had been changed in place.

Signed-off-by: Michael Froh <msfroh@apache.org>

* Make BulkItemRequest a record and fix BulkShardRequest clone

Follow @andrross's suggestion of converting BulkItemRequest to a record,
since it's immutable now.

Also, we were seeing test failures because the cloned BulkShardRequest
(that is propagated to replicas) did not copy the primary request's
parent TaskId. Along with that, I made sure it copied all other
properties from the primary shard request.

Signed-off-by: Michael Froh <msfroh@apache.org>

* Address PR bot comments

1. Add null-check (assert) on primary responses when running on replica.
2. Move BulkShardRequest cloning logic from BulkPrimaryExecutionContext
   to BulkShardRequest and ReplicationRequest. Add a dedicated unit test
   for it.

Signed-off-by: Michael Froh <msfroh@apache.org>

* Fix (and use) broken branch in assertInvariants

Signed-off-by: Michael Froh <msfroh@apache.org>

* Remove assertion on requestToExecute

We previously weren't checking invariants for COMPLETED operations,
so the assertions never ran. As called out by @andrross, there are
two possible code paths that mark an operation as completed without
setting requestToExecute.

Signed-off-by: Michael Froh <msfroh@apache.org>

* Remove reference copy of primaryReponse

Signed-off-by: Michael Froh <msfroh@apache.org>

---------

Signed-off-by: Michael Froh <msfroh@apache.org>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants