Repository navigation
Size limited bulk requests - #3357
Conversation
|
|
||
| // 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) // |
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
acting as a meat proxy:
Thanks! I thought about that, but it changes the behaviour on item failures:
checkForBulkOperationFailurereturnsMono.errorfor the first response with item failures.concatMapthen cancels, so the remaining requests are not sent.- The
BulkFailureExceptionwould 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 theBulkResponses (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?
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
AI response:
Good point about the null checks. I've applied it to the splitter:
BulkRequestSplitter#split()now takesint/longwithInteger.MAX_VALUE/Long.MAX_VALUEas "no limit", so the loop has no null checks anymore.I kept
BulkOptionsnullable though.nullmeans "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. WithMAX_VALUEas 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.RequestConverternow 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 -> { |
There was a problem hiding this comment.
I am wondering why do we need to make a copy of operation to binaryOperation (vs just using operation)?
There was a problem hiding this comment.
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.
BulkOperationis immutable, so we build a copy that holds the already serialized bytes (BinaryData) instead of the document object. The client writesBinaryDataas it is, so each document is serialized only once. The client's ownBulkIngesterdoes the same (IngesterOperation). We can't userebuild()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
ee1ed57 to
5da6780
Compare
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>
5da6780 to
26df7df
Compare
|
since #3352 is now merged this is ready for review |
sothawo
left a comment
There was a problem hiding this comment.
Nice work, thanks a lot for that. I have just one minor comment/change
(generated by copilot/claude, reviewed by me)
Closes #3345
What this does
Bulk operations can now be split into several bulk requests by number of operations and/or size in bytes:
Both limits are unset by default, so nothing changes unless they are used.
Design (as proposed in the issue)
BulkIngester. The splitting is done inclient.elc(BulkRequestSplitter,RequestConverter#documentBulkRequests()). The ingester starts its own threads, can't stop at the first failed request, refreshes after every request, can go overmaxSizeby one operation and blocks, so it can't be used from the reactive template.BinaryData.of(...)and put back into the operation, so it is not serialized again when the request is sent. Same idea as the ingester'sIngesterOperation. The action/metadata line is measured exactly. This is only done whenmaxSizeis set.maxSizeon its own is sent alone.concatMap). A request that fails as a whole stops the processing. Item failures of all requests are collected into oneBulkFailureExceptionat the end. Successful requests are not rolled back (documented).IndexedObjectInformations / response items cover all queries in their original order, so ids, versions etc. are still written back.refresh=false.setBulkOptions()/getBulkOptions()on both abstract templates (kept in copies) and abulkOptions()hook inElasticsearchConfigurationSupport, used by all four ELC configurations. If theBulkOptionsof a call don't set a limit, the template default limit is used. This makes the limits work forsave(Iterable),saveAll(...)and reactivesave(Flux, ...).Open questions
BULKobservation around the whole call. Should there also be one per request?maxOperations/maxSizeare taken from the template default right now. Should the other fields (timeout, pipeline, …) also fall back to it?client.elc. spring-data-opensearch keeps compiling and working unchanged, but it ignores the new limits until it implements them (opensearch-java has the sameBinaryData/BulkOperationstructure, 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 (
BulkOptionsbuilder methods and getters, template setter/getter, configuration hook), all with@since 6.2.BulkOptionsstays client independent. spring-data-opensearch (which extends the abstract templates andElasticsearchConfigurationSupport) 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 tosave(Iterable)/saveAll(checked with a spied client: 3 requests, refreshfalse, false, true).