-
Notifications
You must be signed in to change notification settings - Fork 844
SOLR-3284: let ConcurrentUpdateSolrClient report which docs failed to send #4632
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| # See https://github.com/apache/solr/blob/main/dev-docs/changelog.adoc | ||
|
|
||
| title: > | ||
| ConcurrentUpdateSolrClient can now report which documents failed to reach the server. | ||
| Register a handler via the Builder's withErrorHandler(...) to recover the documents of a | ||
| failed batch, for example to route them to a retry queue or dead-letter topic. | ||
| type: added | ||
| authors: | ||
| - name: Serhiy Bzhezytskyy | ||
| links: | ||
| - name: SOLR-3284 | ||
| url: https://issues.apache.org/jira/browse/SOLR-3284 | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -22,7 +22,9 @@ | |
| import java.lang.invoke.MethodHandles; | ||
| import java.net.HttpURLConnection; | ||
| import java.util.ArrayDeque; | ||
| import java.util.ArrayList; | ||
| import java.util.Iterator; | ||
| import java.util.List; | ||
| import java.util.Queue; | ||
| import java.util.concurrent.BlockingQueue; | ||
| import java.util.concurrent.CountDownLatch; | ||
|
|
@@ -37,6 +39,7 @@ | |
| import org.apache.solr.client.solrj.request.UpdateRequest; | ||
| import org.apache.solr.client.solrj.util.ClientUtils; | ||
| import org.apache.solr.common.SolrException; | ||
| import org.apache.solr.common.SolrInputDocument; | ||
| import org.apache.solr.common.params.SolrParams; | ||
| import org.apache.solr.common.params.UpdateParams; | ||
| import org.apache.solr.common.util.ExecutorUtil; | ||
|
|
@@ -71,6 +74,40 @@ public abstract class ConcurrentUpdateBaseSolrClient extends SolrClient { | |
|
|
||
| protected StallDetection stallDetection; | ||
|
|
||
| private final UpdateErrorHandler errorHandler; | ||
|
|
||
| /** | ||
| * Callback invoked when a batch fails to reach the server. Register one via {@link | ||
| * Builder#withErrorHandler} to handle update errors comprehensively -- e.g. route the failed | ||
| * document ids to a retry queue or dead-letter topic. Implementations must be thread-safe; see | ||
| * {@link Builder#withErrorHandler} for the full threading contract. | ||
| */ | ||
| @FunctionalInterface | ||
| public interface UpdateErrorHandler { | ||
| /** | ||
| * Invoked when specific documents failed to reach the server. | ||
| * | ||
| * @param ex the error that occurred | ||
| * @param failedIds the ids of the documents that did not reach the server | ||
| * @param collection the collection the batch targeted, or null | ||
| */ | ||
| void onError(Throwable ex, List<String> failedIds, String collection); | ||
|
|
||
| /** | ||
| * Invoked for an error not tied to specific documents (e.g. a failure before any document was | ||
| * sent). Defaults to logging. | ||
| */ | ||
| default void onError(Throwable ex) { | ||
| LoggerFactory.getLogger(UpdateErrorHandler.class).error("update error", ex); | ||
| } | ||
|
|
||
| /** The id used to report a failed document; defaults to its {@code id} field. */ | ||
| default String idOf(SolrInputDocument doc) { | ||
| Object id = doc.getFieldValue("id"); | ||
| return id == null ? null : id.toString(); | ||
| } | ||
| } | ||
|
|
||
| protected static class CustomBlockingQueue<E> implements Iterable<E> { | ||
| private final BlockingQueue<E> queue; | ||
| private final Semaphore available; | ||
|
|
@@ -158,6 +195,7 @@ protected ConcurrentUpdateBaseSolrClient(Builder builder) { | |
| this.basePath = builder.baseSolrUrl; | ||
| this.defaultCollection = builder.defaultCollection; | ||
| this.pollQueueTimeMillis = builder.pollQueueTimeMillis; | ||
| this.errorHandler = builder.errorHandler; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. IMO we probably want to make this new errorHandler the preferred mechanism... maybe even eventually mandatory in 11. If we agree on this direction for 11 (I'm +1), then here we can use DeprecationLog to warn that a user forgot to pass an erroHandler, that it'll eventually be mandatory.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. +1 on making the handler the preferred mechanism, and mandatory in 11. One wrinkle: DeprecationLog lives in solr-core (org.apache.solr.logging), so solrj can't reach it — wrong direction in the module graph. I can do the equivalent with a log.warn here when a client is built without a handler (and hasn't overridden handleError), worded as "will be required in a future release". Want me to go that way, or is there a solrj-side deprecation helper you'd prefer?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Confirmed there's no solrj-side deprecation helper — solrj only has @deprecated + ad-hoc log.warn, and DeprecationLog is core-only as noted. The one thing DeprecationLog adds over a bare log.warn is log-once dedup + the org.apache.solr.DEPRECATED.* logger prefix. To avoid warning on every client construction I'd add a small log-once guard here in the client. If the project later wants solrj to share core's deprecation convention, a tiny solrj-side helper would be a clean follow-up — but I'd keep this PR scoped to the guard. Sound good?
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. sounds good; thanks |
||
|
|
||
| // Initialize stall detection | ||
| long stallTimeMillis = Integer.getInteger("solr.cloud.client.stallTime", 15000); | ||
|
|
@@ -187,6 +225,31 @@ protected ConcurrentUpdateBaseSolrClient(Builder builder) { | |
| /** Class representing an UpdateRequest and an optional collection. */ | ||
| protected record Update(UpdateRequest request, String collection) {} | ||
|
|
||
| /** | ||
| * The result of sending updates as a single stream: the response to await, plus the ids of the | ||
| * documents sent (for error reporting) and their collection. | ||
| */ | ||
| public record SentStream(StreamingResponse response, List<String> docIds, String collection) {} | ||
|
|
||
| /** | ||
| * The ids of a request's documents for error reporting, via {@link UpdateErrorHandler#idOf}. | ||
| * Empty when no error handler is registered, so the documents are not read and no ids are | ||
| * retained. | ||
| */ | ||
| protected List<String> idsForErrorReporting(UpdateRequest request) { | ||
| if (errorHandler == null || request.getDocuments() == null) { | ||
| return List.of(); | ||
| } | ||
| List<String> ids = new ArrayList<>(request.getDocuments().size()); | ||
| for (SolrInputDocument doc : request.getDocuments()) { | ||
| String id = errorHandler.idOf(doc); | ||
| if (id != null) { | ||
| ids.add(id); | ||
| } | ||
| } | ||
| return ids; | ||
| } | ||
|
|
||
| /** Opens a connection and sends everything... */ | ||
| class Runner implements Runnable { | ||
|
|
||
|
|
@@ -235,17 +298,20 @@ void sendUpdateStream() throws Exception { | |
| try { | ||
| while (!queue.isEmpty()) { | ||
| InputStream rspBody = null; | ||
| List<String> docIds = List.of(); | ||
| String collection = null; | ||
| try { | ||
| Update update; | ||
| notifyQueueAndRunnersIfEmptyQueue(); | ||
| update = queue.poll(pollQueueTimeMillis, TimeUnit.MILLISECONDS); | ||
| Update update = queue.poll(pollQueueTimeMillis, TimeUnit.MILLISECONDS); | ||
|
|
||
| if (update == null) { | ||
| break; | ||
| } | ||
|
|
||
| StreamingResponse responseListener = null; | ||
| responseListener = doSendUpdateStream(update); | ||
| SentStream sent = doSendUpdateStream(update); | ||
| StreamingResponse responseListener = sent.response(); | ||
| docIds = sent.docIds(); | ||
| collection = sent.collection(); | ||
|
|
||
| // just wait for the headers, so the idle timeout is sensible | ||
| int statusCode = responseListener.awaitResponse(idleTimeoutMillis); | ||
|
|
@@ -266,12 +332,16 @@ void sendUpdateStream() throws Exception { | |
| solrExc = new RemoteSolrException(basePath, statusCode, remoteError); | ||
| } | ||
|
|
||
| handleError(solrExc); | ||
| handleError(solrExc, docIds, collection); | ||
| } else { | ||
| onSuccess(responseListener.getUnderlyingResponse(), rspBody); | ||
| } | ||
| stallDetection.incrementProcessedCount(); | ||
|
|
||
| } catch (OutOfMemoryError | InterruptedException e) { | ||
| throw e; | ||
| } catch (Throwable e) { | ||
| handleError(e, docIds, collection); | ||
| } finally { | ||
| try { | ||
| consumeFully(rspBody); | ||
|
|
@@ -287,7 +357,7 @@ void sendUpdateStream() throws Exception { | |
| } | ||
| } | ||
|
|
||
| protected abstract StreamingResponse doSendUpdateStream(Update update) | ||
| protected abstract SentStream doSendUpdateStream(Update update) | ||
| throws IOException, InterruptedException; | ||
|
|
||
| private void consumeFully(InputStream is) { | ||
|
|
@@ -544,6 +614,22 @@ public void handleError(Throwable ex) { | |
| log.error("error", ex); | ||
| } | ||
|
|
||
| private void handleError(Throwable ex, List<String> failedIds, String collection) { | ||
| if (errorHandler == null) { | ||
| handleError(ex); | ||
| return; | ||
| } | ||
| try { | ||
| if (failedIds.isEmpty()) { | ||
| errorHandler.onError(ex); | ||
| } else { | ||
| errorHandler.onError(ex, failedIds, collection); | ||
| } | ||
| } catch (Exception handlerEx) { | ||
| log.error("errorHandler threw while handling a failed update", handlerEx); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Intended to be used as an extension point for doing post-processing after a request completes. | ||
| * | ||
|
|
@@ -627,6 +713,7 @@ public abstract static class Builder { | |
| protected boolean streamDeletes; | ||
| protected boolean closeHttpClient; | ||
| protected long pollQueueTimeMillis; | ||
| protected UpdateErrorHandler errorHandler; | ||
|
|
||
| /** | ||
| * Initialize a Builder object, based on the provided URL and client. | ||
|
|
@@ -763,6 +850,26 @@ public Builder setPollQueueTime(long pollQueueTime, TimeUnit unit) { | |
| return this; | ||
| } | ||
|
|
||
| /** | ||
| * Registers a handler invoked when a batch fails to reach the server. It receives the ids of | ||
| * the failed documents (via {@link UpdateErrorHandler#idOf}, default the {@code id} field) and | ||
| * the target collection, so callers can recover them -- for example by routing the ids to a | ||
| * retry queue or dead-letter topic. | ||
| * | ||
| * <p>The handler is invoked on the client's internal runner threads and may be called | ||
| * concurrently when several batches fail at once, so implementations must be thread-safe. The | ||
| * call happens inline on a runner thread, so implementations should return quickly and not | ||
| * block, or they will slow update throughput. An exception thrown by the handler is logged and | ||
| * does not stop the client. | ||
| * | ||
| * <p>If no handler is registered the client falls back to its default behavior of logging the | ||
| * error, and document ids are not extracted. | ||
| */ | ||
| public Builder withErrorHandler(UpdateErrorHandler errorHandler) { | ||
| this.errorHandler = errorHandler; | ||
| return this; | ||
| } | ||
|
|
||
| /** | ||
| * Create a {@link ConcurrentUpdateBaseSolrClient} based on the provided configuration options. | ||
| */ | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That's the best changelog entry I've seen in a long time; thank you!
I think our project's embrace of the 3rd party "changelog" with it's choice of wording of "title" has led to a deterioration of changelog informative quality compared to before. You are bucking that trend. Thanks again. Our project seriously needs to find a way to use a different label here like "summary". CC @janhoy
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks — that means a lot. Agree the "title" label nudges people toward terse one-liners; "summary" would invite more.