Skip to content

Size limited bulk requests - #3357

Merged
sothawo merged 6 commits into
spring-projects:mainfrom
rursprung:size-limited-bulk-requests
Oct 28, 2026
Merged

sothawo merged 6 commits into
spring-projects:mainfrom
rursprung:size-limited-bulk-requests

Conversation

@rursprung

Copy link
Copy Markdown
Contributor

Based on #3352 — please review only the commits after 31ce183f. I will rebase once #3352 is merged.

(generated by copilot/claude, reviewed by me)

  • You have read the Spring Data contribution guidelines.
  • There is a ticket in the bug tracker for the project in our issue tracker. Add the issue number to the Closes #issue-number line below
  • You use the code formatters provided here and have them applied to your changes. Don’t submit any formatting related changes.
  • You submit test cases (unit or integration tests) that back your changes.
  • You added yourself as author in the headers of the classes you touched. Amend the date range in the Apache license header if needed. For new types, add the license header (copy from another file and set the current year only).

Closes #3345

What this does

Bulk operations can now be split into several bulk requests by number of operations and/or size in bytes:

BulkOptions.builder()
    .withMaxOperations(1000)
    .withMaxSize(5 * 1024 * 1024)
    .build();

Both limits are unset by default, so nothing changes unless they are used.

Design (as proposed in the issue)

  • No BulkIngester. The splitting is done in client.elc (BulkRequestSplitter, RequestConverter#documentBulkRequests()). The ingester starts its own threads, can't stop at the first failed request, refreshes after every request, can go over maxSize by one operation and blocks, so it can't be used from the reactive template.
  • Size measurement: each document (or update action) is serialized once with BinaryData.of(...) and put back into the operation, so it is not serialized again when the request is sent. Same idea as the ingester's IngesterOperation. The action/metadata line is measured exactly. This is only done when maxSize is set.
  • Splitting: a new request is started before an operation would go over a limit. An operation that is larger than maxSize on its own is sent alone.
  • Failures: requests are sent one after the other (reactive: concatMap). A request that fails as a whole stops the processing. Item failures of all requests are collected into one BulkFailureException at the end. Successful requests are not rolled back (documented).
  • Result order: the returned IndexedObjectInformations / response items cover all queries in their original order, so ids, versions etc. are still written back.
  • Refresh: the refresh policy is only applied to the last request; earlier requests use refresh=false.
  • Template default: setBulkOptions()/getBulkOptions() on both abstract templates (kept in copies) and a bulkOptions() hook in ElasticsearchConfigurationSupport, used by all four ELC configurations. If the BulkOptions of a call don't set a limit, the template default limit is used. This makes the limits work for save(Iterable), saveAll(...) and reactive save(Flux, ...).

Open questions

  1. Observations: there is still one BULK observation around the whole call. Should there also be one per request?
  2. Refresh only on the last request: OK, or should every request get the refresh policy?
  3. Template default: only maxOperations/maxSize are taken from the template default right now. Should the other fields (timeout, pipeline, …) also fall back to it?
  4. Reuse by spring-data-opensearch: the splitting is package-private in client.elc. spring-data-opensearch keeps compiling and working unchanged, but it ignores the new limits until it implements them (opensearch-java has the same BinaryData/BulkOperation structure, so a port is straightforward). Should the client-independent batching logic be a public helper in core so it can be reused, or is a per-client copy preferred?

Compatibility

Only additions to the public API (BulkOptions builder methods and getters, template setter/getter, configuration hook), all with @since 6.2. BulkOptions stays client independent. spring-data-opensearch (which extends the abstract templates and ElasticsearchConfigurationSupport) compiles and behaves as before; the limits are simply not applied there yet. The new splitting code is package-private.

Tests

New tests: BulkOptionsUnitTests, BulkRequestSplitterTest (count/size limits, oversized operation, order, exact size calculation, properties kept when the document is replaced), RequestConverterTest (one request without limits, refresh only on the last request, template default fallback), and integration tests for split by count/size, ids written back, failures aggregated across requests with earlier writes kept, and the template default applied to save(Iterable)/saveAll (checked with a spied client: 3 requests, refresh false, false, true).

@rursprung

Copy link
Copy Markdown
Contributor Author

CC @sothawo & @reta


// the requests are sent one after the other; a failing request stops the processing, item failures are
// collected and reported together after all requests have been sent. Successful requests are not rolled back.
return Flux.fromIterable(bulkRequests) //

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I am wondering if we keep checkForBulkOperationFailure signature unchanged and:

		return Flux.fromIterable(bulkRequests) //
				.concatMap(bulkRequest -> client.bulk(bulkRequest) //
						.onErrorMap(e -> new UncategorizedElasticsearchException("Error executing bulk request", e))) //
				.flatMap(this::checkForBulkOperationFailure) //
				.flatMap(response -> Flux.fromIterable(response.items()));

(so we don't need to collect to list before moving on)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

acting as a meat proxy:

Thanks! I thought about that, but it changes the behaviour on item failures:

  • checkForBulkOperationFailure returns Mono.error for the first response with item failures. concatMap then cancels, so the remaining requests are not sent.
  • The BulkFailureException would only contain the failures of that one request, not of all requests.

In the issue we agreed to send all requests and report all item failures in one exception at the end. The imperative template does the same, so both behave alike.

The collectList() only keeps the BulkResponses (ids, status, versions), not the documents, so the memory cost is small compared to the requests.

If you and @sothawo prefer fail-fast (stop at the first request with item failures), I'm happy to switch. But then I'd change the imperative template too, so both stay consistent. What do you think?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks @rursprung , this is a good point, I think the behavior that would be desired here (my opinion):

  • do collect all individual failures for each bulk request item (so checkForBulkOperationFailure should collect but not fail with the exception)
  • fail on first bulk response error (fe 429)

private final @Nullable ActiveShardCount waitForActiveShards;
private final @Nullable String pipeline;
private final @Nullable String routingId;
private final @Nullable Integer maxOperations;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Could we make those non-nullable:

private final int maxOperations = Integer.MAX_VALUE;
private final long maxSize = Long.MAX_VALUE;

I think it will simplify a lot the split part (no need to check nulls etc)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

AI response:

Good point about the null checks. I've applied it to the splitter: BulkRequestSplitter#split() now takes int/long with Integer.MAX_VALUE/Long.MAX_VALUE as "no limit", so the loop has no null checks anymore.

I kept BulkOptions nullable though. null means "not set", which is needed for the template default (setBulkOptions() / bulkOptions() in the configuration). If a call doesn't set a limit, the template default is used. With MAX_VALUE as the default we couldn't tell "not set" from "explicitly no limit", so a call couldn't switch off a limit configured on the template. RequestConverter now resolves this in one place: per-call value → template default → no limit.

case Index -> {
final var index = operation.index();
final var document = toBinaryData(index.document());
binaryOperation = BulkOperation.of(bo -> bo.index(i -> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I am wondering why do we need to make a copy of operation to binaryOperation (vs just using operation)?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

AI response:

To measure the size, the document has to be serialized. If we kept operation, the client would serialize the document a second time when sending the request. That's double the work, and it can be a lot for large documents.

BulkOperation is immutable, so we build a copy that holds the already serialized bytes (BinaryData) instead of the document object. The client writes BinaryData as it is, so each document is serialized only once. The client's own BulkIngester does the same (IngesterOperation). We can't use rebuild() because in 9.5.4 it doesn't keep id/index/routing; there's a test for that.

I added a comment in the code to explain it (88e00039).

see elastic/elasticsearch-java#1353 for the rebuild bug in elasticsearch-java

@rursprung
rursprung force-pushed the size-limited-bulk-requests branch from ee1ed57 to 5da6780 Compare October 19, 2026 08:12
rursprung and others added 6 commits October 26, 2026 10:44
Add `withMaxOperations(int)` and `withMaxSize(long)` to the
`BulkOptions` builder, with matching getters. They define the maximum
number of operations and the maximum size in bytes of one bulk
request. Both are unset by default, so existing callers keep sending
exactly one request.

`BulkOptions` stays client independent; the splitting itself is done
in the client implementation.

See spring-projects#3345

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Ralph Ursprung <Ralph.Ursprung@avaloq.com>
Add the package-private `BulkRequestSplitter` and
`RequestConverter#documentBulkRequests()`. They turn a list of queries
into one or more `BulkRequest`s that respect
`BulkOptions#getMaxOperations()` and `BulkOptions#getMaxSize()`.

To measure the size, the document or update action of each operation
is serialized once with `BinaryData.of()` and put back into the
operation, so it is not serialized a second time when the request is
sent. This is the same approach as the `BulkIngester` of the
Elasticsearch client, which can't be used directly here: it starts its
own threads, can't stop at the first failed request, refreshes after
every request and can't be used from reactive code.

A new request is started before an operation would go over a limit.
An operation that is larger than the size limit on its own is sent in
a request of its own. The refresh policy is only applied to the last
request. Without limits exactly one request is created, as before.

See spring-projects#3345

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Ralph Ursprung <Ralph.Ursprung@avaloq.com>
`ElasticsearchTemplate#doBulkOperation()` now uses
`RequestConverter#documentBulkRequests()` and sends the resulting
requests one after the other.

- A request that fails as a whole stops the processing and the error
  is translated as before.
- Item failures of all requests are collected and thrown together in
  one `BulkFailureException` after the last request.
- Requests that were sent successfully are not rolled back.
- The returned `IndexedObjectInformation`s cover all queries in their
  original order, so ids, versions and so on are still written back
  to the entities.

The single `BULK` observation around the whole call is kept. Without
limits in the `BulkOptions` exactly one request is sent, as before.

See spring-projects#3345

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Ralph Ursprung <Ralph.Ursprung@avaloq.com>
`ReactiveElasticsearchTemplate#doBulkOperation()` now uses
`RequestConverter#documentBulkRequests()` and sends the requests one
after the other with `concatMap()`, so the order of the response
items still matches the order of the queries.

The behaviour is the same as in the imperative template: a request
that fails as a whole stops the processing, item failures of all
requests are reported in one `BulkFailureException` at the end, and
requests that were sent successfully are not rolled back.

See spring-projects#3345

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Ralph Ursprung <Ralph.Ursprung@avaloq.com>
Add `setBulkOptions()`/`getBulkOptions()` to
`AbstractElasticsearchTemplate` and
`AbstractReactiveElasticsearchTemplate`, and a `bulkOptions()` hook
in `ElasticsearchConfigurationSupport` that all four ELC
configuration classes pass to their template, like `refreshPolicy()`.
The value is kept when the template is copied.

Methods like `save(Iterable)`, `saveAll()` and the reactive
`save(Flux, ...)` always pass `BulkOptions.defaultOptions()`, so the
limits could not be used with them so far. Now, if the `BulkOptions`
of a call don't set `maxOperations` or `maxSize`, the value of the
template default is used. For now only these two limits are taken
from the template default.

The default is `BulkOptions.defaultOptions()`, so nothing changes
unless an application configures it.

See spring-projects#3345

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Ralph Ursprung <Ralph.Ursprung@avaloq.com>
Describe the new `BulkOptions` limits, how the operations are split,
how failures are reported, that successful requests are not rolled
back, that the refresh policy only applies to the last request, and
how to set a template default in the configuration.

See spring-projects#3345

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Ralph Ursprung <Ralph.Ursprung@avaloq.com>
@rursprung
rursprung force-pushed the size-limited-bulk-requests branch from 5da6780 to 26df7df Compare October 26, 2026 10:03
@rursprung
rursprung marked this pull request as ready for review October 26, 2026 10:03
@rursprung

Copy link
Copy Markdown
Contributor Author

since #3352 is now merged this is ready for review

@sothawo sothawo left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Nice work, thanks a lot for that. I have just one minor comment/change

@sothawo
sothawo merged commit 6b5dbbc into spring-projects:main Oct 28, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

status: waiting-for-triage An issue we've not yet triaged

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Allow bulkIndex/bulkUpdate/save(Iterable) to split into size-limited bulk requests

4 participants