This is an automated email from the ASF dual-hosted git repository.
davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/main by this push:
new e78477968620 CAMEL-25506: camel-lucene - Query with a searcher per
exchange and do not close the analyzer on stop
e78477968620 is described below
commit e7847796862013ab78d278d20ee96ef2af21b0bd
Author: allthingssecurity <[email protected]>
AuthorDate: Sat Oct 10 13:06:45 2026 +0530
CAMEL-25506: camel-lucene - Query with a searcher per exchange and do not
close the analyzer on stop
LuceneQueryProducer used one LuceneSearcher for all exchanges, and
LuceneSearcher kept the reader, searcher and hits of the last query in
fields, so concurrent exchanges could get the hits of another exchange's
query and the readers of earlier queries were never closed. doStop()
closed the endpoint's analyzer, so every query failed after a route
restart.
LuceneQueryProducer and LuceneQueryProcessor now open, search and close a
LuceneSearcher per exchange, and LuceneSearcher.close() closes only its
reader, not the analyzer, which belongs to the endpoint or the registry.
Code that uses LuceneSearcher directly must now close its analyzer
itself, as noted in the 4.23 upgrade guide.
Closes #27639
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
.../component/lucene/LuceneQueryProducer.java | 21 +--
.../camel/component/lucene/LuceneSearcher.java | 13 +-
.../processor/lucene/LuceneQueryProcessor.java | 10 +-
.../lucene/LuceneQueryProducerLifecycleTest.java | 151 +++++++++++++++++++++
.../ROOT/pages/camel-4x-upgrade-guide-4_23.adoc | 5 +
5 files changed, 181 insertions(+), 19 deletions(-)
diff --git
a/components/camel-lucene/src/main/java/org/apache/camel/component/lucene/LuceneQueryProducer.java
b/components/camel-lucene/src/main/java/org/apache/camel/component/lucene/LuceneQueryProducer.java
index 9da9bea1758d..cc97919fb5ef 100644
---
a/components/camel-lucene/src/main/java/org/apache/camel/component/lucene/LuceneQueryProducer.java
+++
b/components/camel-lucene/src/main/java/org/apache/camel/component/lucene/LuceneQueryProducer.java
@@ -26,7 +26,6 @@ import org.apache.lucene.analysis.Analyzer;
public class LuceneQueryProducer extends DefaultProducer {
LuceneConfiguration config;
- LuceneSearcher searcher;
Analyzer analyzer;
File indexDirectory;
int maxNumberOfHits;
@@ -40,18 +39,6 @@ public class LuceneQueryProducer extends DefaultProducer {
maxNumberOfHits = config.getMaxHits();
}
- @Override
- public void doStart() throws Exception {
- searcher = new LuceneSearcher();
- super.doStart();
- }
-
- @Override
- public void doStop() throws Exception {
- searcher.close();
- super.doStop();
- }
-
@Override
public void process(Exchange exchange) throws Exception {
Hits hits;
@@ -61,8 +48,14 @@ public class LuceneQueryProducer extends DefaultProducer {
boolean isReturnLuceneDocs = returnLuceneDocs != null &&
returnLuceneDocs.equalsIgnoreCase("true");
if (phrase != null) {
+ // a searcher (and index reader) per query: the producer is shared
by concurrent exchanges
+ LuceneSearcher searcher = new LuceneSearcher();
searcher.open(indexDirectory, analyzer);
- hits = searcher.search(phrase, maxNumberOfHits,
totalHitsThreshold, isReturnLuceneDocs);
+ try {
+ hits = searcher.search(phrase, maxNumberOfHits,
totalHitsThreshold, isReturnLuceneDocs);
+ } finally {
+ searcher.close();
+ }
} else {
throw new IllegalArgumentException(
"SearchPhrase for LucenePhraseQuerySearcher not set. Set
the Header value: QUERY");
diff --git
a/components/camel-lucene/src/main/java/org/apache/camel/component/lucene/LuceneSearcher.java
b/components/camel-lucene/src/main/java/org/apache/camel/component/lucene/LuceneSearcher.java
index a354379edfe1..40f5481c7c60 100644
---
a/components/camel-lucene/src/main/java/org/apache/camel/component/lucene/LuceneSearcher.java
+++
b/components/camel-lucene/src/main/java/org/apache/camel/component/lucene/LuceneSearcher.java
@@ -35,6 +35,9 @@ import org.apache.lucene.store.NIOFSDirectory;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+/**
+ * Runs one query on its own reader of the index. An instance is used by one
exchange at a time: open, search, close.
+ */
public class LuceneSearcher {
private static final Logger LOG =
LoggerFactory.getLogger(LuceneSearcher.class);
private Analyzer analyzer;
@@ -52,9 +55,15 @@ public class LuceneSearcher {
this.analyzer = analyzer;
}
+ /**
+ * Closes the index reader. The analyzer is not closed: it belongs to the
endpoint (or the registry) and is used by
+ * the next query.
+ */
public void close() throws IOException {
- indexReader.close();
- analyzer.close();
+ if (indexReader != null) {
+ indexReader.close();
+ indexReader = null;
+ }
}
public Hits search(String searchPhrase, int maxNumberOfHits, int
totalHitsThreshold) throws Exception {
diff --git
a/components/camel-lucene/src/main/java/org/apache/camel/processor/lucene/LuceneQueryProcessor.java
b/components/camel-lucene/src/main/java/org/apache/camel/processor/lucene/LuceneQueryProcessor.java
index 00e5e023e96b..cbf0a0f53d1d 100644
---
a/components/camel-lucene/src/main/java/org/apache/camel/processor/lucene/LuceneQueryProcessor.java
+++
b/components/camel-lucene/src/main/java/org/apache/camel/processor/lucene/LuceneQueryProcessor.java
@@ -30,7 +30,6 @@ public class LuceneQueryProcessor implements Processor {
private File indexDirectory;
private Analyzer analyzer;
private IndexSearcher indexSearcher;
- private LuceneSearcher searcher;
private String searchPhrase;
private int maxNumberOfHits;
private int totalHitsThreshold;
@@ -51,9 +50,14 @@ public class LuceneQueryProcessor implements Processor {
boolean isReturnLuceneDocs = returnLuceneDocs != null &&
returnLuceneDocs.equalsIgnoreCase("true");
if (phrase != null) {
- searcher = new LuceneSearcher();
+ // a searcher (and index reader) per query, closed when the hits
are built
+ LuceneSearcher searcher = new LuceneSearcher();
searcher.open(indexDirectory, analyzer);
- hits = searcher.search(phrase, maxNumberOfHits,
totalHitsThreshold, isReturnLuceneDocs);
+ try {
+ hits = searcher.search(phrase, maxNumberOfHits,
totalHitsThreshold, isReturnLuceneDocs);
+ } finally {
+ searcher.close();
+ }
} else {
throw new IllegalArgumentException("SearchPhrase for
LuceneQueryProcessor not set. Set the Header value: QUERY");
}
diff --git
a/components/camel-lucene/src/test/java/org/apache/camel/component/lucene/LuceneQueryProducerLifecycleTest.java
b/components/camel-lucene/src/test/java/org/apache/camel/component/lucene/LuceneQueryProducerLifecycleTest.java
new file mode 100644
index 000000000000..80ee31c6e7ff
--- /dev/null
+++
b/components/camel-lucene/src/test/java/org/apache/camel/component/lucene/LuceneQueryProducerLifecycleTest.java
@@ -0,0 +1,151 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.component.lucene;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+
+import org.apache.camel.Exchange;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.processor.lucene.support.Hit;
+import org.apache.camel.processor.lucene.support.Hits;
+import org.apache.camel.spi.Registry;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.apache.lucene.analysis.standard.StandardAnalyzer;
+import org.apache.lucene.document.Document;
+import org.apache.lucene.document.Field;
+import org.apache.lucene.document.TextField;
+import org.apache.lucene.index.IndexWriter;
+import org.apache.lucene.index.IndexWriterConfig;
+import org.apache.lucene.store.FSDirectory;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * The query producer must keep working after its route is restarted, must
stop cleanly when it never queried, and must
+ * answer each exchange with the hits of its own query.
+ */
+class LuceneQueryProducerLifecycleTest extends CamelTestSupport {
+
+ @TempDir
+ static File indexDir;
+
+ @Override
+ protected void bindToRegistry(Registry registry) {
+ registry.bind("queryDir", indexDir);
+ registry.bind("queryAnalyzer", new StandardAnalyzer());
+ }
+
+ @BeforeEach
+ void createIndex() throws Exception {
+ try (FSDirectory dir = FSDirectory.open(indexDir.toPath());
+ IndexWriter writer = new IndexWriter(
+ dir, new IndexWriterConfig(new
StandardAnalyzer()).setOpenMode(IndexWriterConfig.OpenMode.CREATE))) {
+ for (int i = 0; i < 20; i++) {
+ addDoc(writer, "alpha document " + i);
+ addDoc(writer, "beta document " + i);
+ }
+ writer.commit();
+ }
+ }
+
+ private static void addDoc(IndexWriter writer, String contents) throws
Exception {
+ Document doc = new Document();
+ doc.add(new TextField("contents", contents, Field.Store.YES));
+ writer.addDocument(doc);
+ }
+
+ @Override
+ protected RouteBuilder createRouteBuilder() {
+ return new RouteBuilder() {
+ @Override
+ public void configure() {
+ from("direct:query").routeId("query")
+
.to("lucene:queryIndex:query?analyzer=#queryAnalyzer&indexDir=#queryDir&maxHits=20");
+ }
+ };
+ }
+
+ private Exchange query(String phrase) {
+ return template.send("direct:query", e ->
e.getIn().setHeader(LuceneConstants.HEADER_QUERY, phrase));
+ }
+
+ @Test
+ void queryWorksAfterRouteRestart() throws Exception {
+ Exchange before = query("alpha");
+ assertNull(before.getException(), "query before the restart");
+ assertEquals(20,
before.getMessage().getBody(Hits.class).getNumberOfHits());
+
+ context.getRouteController().stopRoute("query");
+ context.getRouteController().startRoute("query");
+
+ Exchange after = query("alpha");
+ assertNull(after.getException(), "a query after the route was
restarted must succeed");
+ assertEquals(20,
after.getMessage().getBody(Hits.class).getNumberOfHits());
+ }
+
+ /**
+ * The producer is shared by concurrent exchanges. Before the fix, its
searcher kept the reader, the searcher and
+ * the hits of the last query in fields, so an exchange could get the hits
of another exchange's query (a few dozen
+ * of 20000 queries on a laptop). This test cannot force that
interleaving, it only makes it very likely.
+ */
+ @Test
+ void concurrentQueriesGetTheirOwnHits() throws Exception {
+ ExecutorService pool = Executors.newFixedThreadPool(8);
+ try {
+ List<Future<String>> results = new ArrayList<>();
+ for (int i = 0; i < 20000; i++) {
+ String term = i % 2 == 0 ? "alpha" : "beta";
+ Callable<String> task = () -> {
+ Exchange exchange = query(term);
+ if (exchange.getException() != null) {
+ return term + ": " + exchange.getException();
+ }
+ for (Hit hit :
exchange.getMessage().getBody(Hits.class).getHit()) {
+ if (!hit.getData().startsWith(term)) {
+ return term + ": got hit '" + hit.getData() + "'";
+ }
+ }
+ return null;
+ };
+ results.add(pool.submit(task));
+ }
+ List<String> wrong = new ArrayList<>();
+ for (Future<String> result : results) {
+ String error = result.get(30, TimeUnit.SECONDS);
+ if (error != null) {
+ wrong.add(error);
+ }
+ }
+ assertTrue(wrong.isEmpty(), "each query must get the hits of its
own query, but " + wrong.size()
+ + " of 20000 did not, e.g. " +
wrong.stream().limit(3).toList());
+ } finally {
+ pool.shutdownNow();
+ }
+ }
+}
diff --git
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
index 7b50fb025307..4cfbd7d495c8 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
@@ -2593,6 +2593,11 @@ would pass `null` to the underlying store's `remove()`
method, which threw an op
from langchain4j. It now throws `IllegalArgumentException` with a clear
message listing the
expected inputs.
+=== camel-lucene
+
+`LuceneSearcher.close()` no longer closes the `Analyzer` passed to `open()`;
code that uses `LuceneSearcher` directly
+must close its analyzer itself when it is no longer needed.
+
=== camel-ftp, camel-sftp, camel-ftps, camel-mina-sftp, camel-azure-files,
camel-smb
The remote-file consumers now ensure the path resolved for a polled file stays
within the directory being