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 1963d4755813 CAMEL-26227: camel-lucene - index the fields that insert
writes, one document per exchange (#28529)
1963d4755813 is described below
commit 1963d4755813cc8f5ad104ab06a8d8655962e253
Author: Vignesh Nayak Manel <[email protected]>
AuthorDate: Wed Oct 28 11:40:28 2026 +0530
CAMEL-26227: camel-lucene - index the fields that insert writes, one
document per exchange (#28529)
* CAMEL-26227: camel-lucene - index the fields that insert writes, one
document per exchange
The Lucene 5 upgrade (CAMEL-9360) commented out FieldType.setIndexed(true)
and did not replace it with setIndexOptions, so the fields written by the
insert endpoint and by the srcDir indexing kept IndexOptions.NONE: they
were stored but never indexed, and a query never found them.
Each field was also written as a document of its own, so a hit on the
body could not be tied to the headers of its exchange or the path of its
file. One exchange, or one source file, is now one document.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* CAMEL-26227: camel-lucene - a header named contents does not replace the
body in the hit
The hit data is Document.get("contents"), which returns the first value of
the field. With one document per exchange, a header named contents was
added before the body and so became the hit data. Add exchangeId and
contents before the headers.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* CAMEL-26227: camel-lucene - a failed srcDir insert releases the index
write lock
On an index written by an older Camel, addDocument of a srcDir file throws
IllegalArgumentException (a field cannot go from index options NONE to
indexed).
The index writer was then never closed, and Lucene keeps its write lock
held in
the JVM, so every later IndexWriter on that indexDir failed with
LockObtainFailedException. Roll the writer back on failure, as index() does.
The component doc now says that srcDir files in subdirectories are indexed
too.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* CAMEL-26227: camel-lucene - indexing srcDir again replaces the documents
of its files
The files of srcDir are indexed each time the insert endpoint is created,
into
an index opened with CREATE_OR_APPEND, so every application start added
another
copy of each file. The copies could not be seen while nothing was indexed;
now
a query on a word of a file gave one hit per start. Replace the document
with
the same path (an indexed, untokenized field) instead of adding one.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
---
.../camel/catalog/docs/lucene-component.adoc | 8 +-
.../src/main/docs/lucene-component.adoc | 8 +-
.../camel/component/lucene/LuceneIndexer.java | 41 ++++--
.../lucene/LuceneIndexProducerLifecycleTest.java | 52 +++++++
.../component/lucene/LuceneInsertAndQueryTest.java | 161 +++++++++++++++++++++
.../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc | 19 +++
6 files changed, 275 insertions(+), 14 deletions(-)
diff --git
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/lucene-component.adoc
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/lucene-component.adoc
index bb73af022fd3..f4b88396aae8 100644
---
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/lucene-component.adoc
+++
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/lucene-component.adoc
@@ -61,8 +61,12 @@ include::partial$component-endpoint-headers.adoc[]
This component supports 2 producer endpoints.
*insert*: the insert producer builds a searchable index by analyzing
-the body in incoming exchanges and associating it with a token
-("content").
+the body in incoming exchanges and associating it with the field
+`contents`. Each exchange is indexed as one document, which holds the
+body in the field `contents`, the exchange id in the field `exchangeId`,
+and each header in a field of the header name. Each file under the `srcDir`
+directory, including its subdirectories, is indexed as one document, which
holds the file contents in
+the field `contents` and the file path, not tokenized, in the field `path`.
*query*: the query producer performs searches on a pre-created index.
The query uses the searchable index to perform score & relevance based
searches. Queries are sent via the incoming exchange contains a header
diff --git a/components/camel-lucene/src/main/docs/lucene-component.adoc
b/components/camel-lucene/src/main/docs/lucene-component.adoc
index bb73af022fd3..f4b88396aae8 100644
--- a/components/camel-lucene/src/main/docs/lucene-component.adoc
+++ b/components/camel-lucene/src/main/docs/lucene-component.adoc
@@ -61,8 +61,12 @@ include::partial$component-endpoint-headers.adoc[]
This component supports 2 producer endpoints.
*insert*: the insert producer builds a searchable index by analyzing
-the body in incoming exchanges and associating it with a token
-("content").
+the body in incoming exchanges and associating it with the field
+`contents`. Each exchange is indexed as one document, which holds the
+body in the field `contents`, the exchange id in the field `exchangeId`,
+and each header in a field of the header name. Each file under the `srcDir`
+directory, including its subdirectories, is indexed as one document, which
holds the file contents in
+the field `contents` and the file path, not tokenized, in the field `path`.
*query*: the query producer performs searches on a pre-created index.
The query uses the searchable index to perform score & relevance based
searches. Queries are sent via the incoming exchange contains a header
diff --git
a/components/camel-lucene/src/main/java/org/apache/camel/component/lucene/LuceneIndexer.java
b/components/camel-lucene/src/main/java/org/apache/camel/component/lucene/LuceneIndexer.java
index 4234cb8141d2..b02b64239c94 100644
---
a/components/camel-lucene/src/main/java/org/apache/camel/component/lucene/LuceneIndexer.java
+++
b/components/camel-lucene/src/main/java/org/apache/camel/component/lucene/LuceneIndexer.java
@@ -30,9 +30,11 @@ import org.apache.lucene.analysis.Analyzer;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.FieldType;
+import org.apache.lucene.index.IndexOptions;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.index.IndexWriterConfig.OpenMode;
+import org.apache.lucene.index.Term;
import org.apache.lucene.store.NIOFSDirectory;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -75,18 +77,23 @@ public class LuceneIndexer {
LOG.debug("Indexing {}", exchange);
openIndexWriter();
try {
+ // one document per exchange, so a hit on the body or on a header
gives all the fields of its exchange
+ Document doc = new Document();
+ // before the headers: Document.get returns the first value of a
field, so a header named
+ // exchangeId or contents does not replace them in the hit
+ add(doc, "exchangeId", exchange.getExchangeId(), true);
+ add(doc, "contents",
exchange.getIn().getMandatoryBody(String.class), true);
Map<String, Object> headers = exchange.getIn().getHeaders();
- add("exchangeId", exchange.getExchangeId(), true);
for (Entry<String, Object> entry : headers.entrySet()) {
String field = entry.getKey();
String value =
exchange.getContext().getTypeConverter().mandatoryConvertTo(String.class,
entry.getValue());
- add(field, value, true);
+ add(doc, field, value, true);
}
- add("contents", exchange.getIn().getMandatoryBody(String.class),
true);
+ indexWriter.addDocument(doc);
indexWriter.commit();
} catch (Exception e) {
- // discard the documents of this exchange and release the index
write lock
+ // discard the document of this exchange and release the index
write lock
try {
indexWriter.rollback();
} catch (Exception re) {
@@ -121,15 +128,13 @@ public class LuceneIndexer {
this.analyzer = analyzer;
}
- private void add(String field, String value, boolean analyzed) throws
IOException {
+ private static void add(Document doc, String field, String value, boolean
analyzed) {
if (LOG.isTraceEnabled()) {
LOG.trace("Adding field: {}", field);
LOG.trace(" value: {}", value);
}
- Document doc = new Document();
doc.add(new Field(field, value, analyzed ? TOKENIZED_FIELD_TYPE :
NON_TOKENIZED_FIELD_TYPE));
- indexWriter.addDocument(doc);
}
private void add(File file) throws IOException {
@@ -151,10 +156,24 @@ public class LuceneIndexer {
IOHelper.copy(IOHelper.buffered(is), bos);
contents = bos.toString();
}
+ // one document per file, so a hit on the contents gives the
path of its file
+ Document doc = new Document();
+ add(doc, "path", file.getPath(), false);
+ add(doc, "contents", contents, true);
openIndexWriter();
- add("path", file.getPath(), false);
- add("contents", contents, true);
- closeIndexWriter();
+ try {
+ // replace the document of this path: srcDir is indexed
again each time the endpoint is created
+ indexWriter.updateDocument(new Term("path",
file.getPath()), doc);
+ closeIndexWriter();
+ } catch (Exception e) {
+ // discard the document of this file and release the index
write lock
+ try {
+ indexWriter.rollback();
+ } catch (Exception re) {
+ e.addSuppressed(re);
+ }
+ throw e;
+ }
LOG.trace("Added {} successfully", file);
}
@@ -179,6 +198,8 @@ public class LuceneIndexer {
private static FieldType createFieldType(boolean tokenized) {
FieldType answer = new FieldType();
+ // a FieldType is not indexed unless its index options are set (the
default is IndexOptions.NONE)
+ answer.setIndexOptions(IndexOptions.DOCS_AND_FREQS_AND_POSITIONS);
answer.setStored(true);
answer.setTokenized(tokenized);
diff --git
a/components/camel-lucene/src/test/java/org/apache/camel/component/lucene/LuceneIndexProducerLifecycleTest.java
b/components/camel-lucene/src/test/java/org/apache/camel/component/lucene/LuceneIndexProducerLifecycleTest.java
index 5ef5a1991651..7e33f9c8a2a5 100644
---
a/components/camel-lucene/src/test/java/org/apache/camel/component/lucene/LuceneIndexProducerLifecycleTest.java
+++
b/components/camel-lucene/src/test/java/org/apache/camel/component/lucene/LuceneIndexProducerLifecycleTest.java
@@ -18,6 +18,8 @@ package org.apache.camel.component.lucene;
import java.io.File;
import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
import java.util.Collection;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
@@ -29,8 +31,15 @@ import org.apache.camel.builder.RouteBuilder;
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.StoredField;
import org.apache.lucene.index.DirectoryReader;
+import org.apache.lucene.index.IndexWriter;
+import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.index.StoredFields;
+import org.apache.lucene.index.Term;
+import org.apache.lucene.search.IndexSearcher;
+import org.apache.lucene.search.TermQuery;
import org.apache.lucene.store.FSDirectory;
import org.apache.lucene.store.NIOFSDirectory;
import org.junit.jupiter.api.Test;
@@ -41,6 +50,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
@@ -132,6 +142,48 @@ class LuceneIndexProducerLifecycleTest extends
CamelTestSupport {
assertEquals(1, countIndexedMessages());
}
+ @Test
+ void failedSourceDirectoryInsertDoesNotHoldTheLock(@TempDir File
oldIndexDir, @TempDir File srcDir) throws Exception {
+ Files.writeString(new File(srcDir, "quote.txt").toPath(), "A day
without sunshine is like night",
+ StandardCharsets.UTF_8);
+ // an index written by an older Camel: contents is stored but not
indexed
+ try (FSDirectory dir = FSDirectory.open(oldIndexDir.toPath());
+ IndexWriter writer = new IndexWriter(dir, new
IndexWriterConfig(new StandardAnalyzer()))) {
+ Document doc = new Document();
+ doc.add(new StoredField("contents", "old"));
+ writer.addDocument(doc);
+ }
+
+ IllegalArgumentException e =
assertThrows(IllegalArgumentException.class,
+ () -> new LuceneIndexer(srcDir, oldIndexDir, new
StandardAnalyzer()));
+ assertTrue(e.getMessage().contains("from index options=NONE"),
e.getMessage());
+
+ try (FSDirectory dir = FSDirectory.open(oldIndexDir.toPath());
+ IndexWriter writer = new IndexWriter(dir, new
IndexWriterConfig(new StandardAnalyzer()))) {
+ assertTrue(writer.isOpen(), "a failed source directory insert must
not leave the index write lock held");
+ }
+ }
+
+ @Test
+ void sourceDirectoryIndexedAgainReplacesTheDocumentsOfItsFiles(@TempDir
File srcIndexDir, @TempDir File srcDir)
+ throws Exception {
+ File file = new File(srcDir, "quote.txt");
+ Files.writeString(file.toPath(), "A day without sunshine is like
night", StandardCharsets.UTF_8);
+ new LuceneIndexer(srcDir, srcIndexDir, new StandardAnalyzer());
+
+ // as when the application starts again, with a file that was changed
in the meantime
+ Files.writeString(file.toPath(), "A day without rain is like night",
StandardCharsets.UTF_8);
+ new LuceneIndexer(srcDir, srcIndexDir, new StandardAnalyzer());
+
+ try (FSDirectory dir = FSDirectory.open(srcIndexDir.toPath());
+ DirectoryReader reader = DirectoryReader.open(dir)) {
+ assertEquals(1, reader.numDocs(), "indexing the source directory
again must not add a copy of its file");
+ IndexSearcher searcher = new IndexSearcher(reader);
+ assertEquals(1, searcher.count(new TermQuery(new Term("contents",
"rain"))), "the new contents");
+ assertEquals(0, searcher.count(new TermQuery(new Term("contents",
"sunshine"))), "the old contents");
+ }
+ }
+
@Test
void concurrentInsertsAreIndexed() throws Exception {
CountDownLatch writerOpen = new CountDownLatch(1);
diff --git
a/components/camel-lucene/src/test/java/org/apache/camel/component/lucene/LuceneInsertAndQueryTest.java
b/components/camel-lucene/src/test/java/org/apache/camel/component/lucene/LuceneInsertAndQueryTest.java
new file mode 100644
index 000000000000..721668b433cc
--- /dev/null
+++
b/components/camel-lucene/src/test/java/org/apache/camel/component/lucene/LuceneInsertAndQueryTest.java
@@ -0,0 +1,161 @@
+/*
+ * 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.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+
+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.index.DirectoryReader;
+import org.apache.lucene.index.Term;
+import org.apache.lucene.search.IndexSearcher;
+import org.apache.lucene.search.TermQuery;
+import org.apache.lucene.store.FSDirectory;
+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.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+/**
+ * What the insert endpoint writes must be found by the query endpoint, and
the fields of one exchange (or one source
+ * file) must be one document.
+ */
+class LuceneInsertAndQueryTest extends CamelTestSupport {
+
+ @TempDir
+ File indexDir;
+
+ @TempDir
+ File srcIndexDir;
+
+ @TempDir
+ File srcDir;
+
+ @Override
+ protected void bindToRegistry(Registry registry) throws Exception {
+ Files.writeString(new File(srcDir, "quote.txt").toPath(), "A day
without sunshine is like night",
+ StandardCharsets.UTF_8);
+ registry.bind("indexDir", indexDir);
+ registry.bind("srcIndexDir", srcIndexDir);
+ registry.bind("srcDir", srcDir);
+ registry.bind("stdAnalyzer", new StandardAnalyzer());
+ }
+
+ @Override
+ protected RouteBuilder createRouteBuilder() {
+ return new RouteBuilder() {
+ @Override
+ public void configure() {
+
from("direct:insert").to("lucene:insertIndex:insert?analyzer=#stdAnalyzer&indexDir=#indexDir");
+
from("direct:query").to("lucene:queryIndex:query?analyzer=#stdAnalyzer&indexDir=#indexDir&maxHits=20");
+ from("direct:insertWithSrcDir")
+
.to("lucene:srcIndex:insert?analyzer=#stdAnalyzer&indexDir=#srcIndexDir&srcDir=#srcDir");
+ from("direct:querySrcDir")
+
.to("lucene:srcQuery:query?analyzer=#stdAnalyzer&indexDir=#srcIndexDir&maxHits=20");
+ }
+ };
+ }
+
+ private Hits query(String endpoint, String phrase) {
+ Exchange exchange = template.send(endpoint, e -> {
+ e.getIn().setHeader(LuceneConstants.HEADER_QUERY, phrase);
+ e.getIn().setHeader(LuceneConstants.HEADER_RETURN_LUCENE_DOCS,
"true");
+ });
+ assertNull(exchange.getException(), "query " + phrase);
+ return exchange.getMessage().getBody(Hits.class);
+ }
+
+ @Test
+ void insertedBodyIsFoundByQuery() {
+ Exchange inserted = template.send("direct:insert", e ->
e.getIn().setBody("the quick brown fox"));
+ assertNull(inserted.getException(), "insert");
+
+ Hits hits = query("direct:query", "fox");
+ assertEquals(1, hits.getNumberOfHits(), "a word of the inserted body
must be found");
+ assertEquals("the quick brown fox", hits.getHit().get(0).getData());
+ }
+
+ @Test
+ void headersAndBodyOfOneExchangeAreOneDocument() throws Exception {
+ Exchange inserted = template.send("direct:insert", e -> {
+ e.getIn().setHeader("author", "carlin");
+ e.getIn().setBody("I think, therefore I am");
+ });
+ assertNull(inserted.getException(), "insert");
+
+ try (FSDirectory dir = FSDirectory.open(indexDir.toPath());
+ DirectoryReader reader = DirectoryReader.open(dir)) {
+ assertEquals(1, reader.numDocs(), "one exchange must be one
document");
+ }
+
+ Hits hits = query("direct:query", "author:carlin");
+ assertEquals(1, hits.getNumberOfHits(), "a header of the exchange must
be found");
+ Hit hit = hits.getHit().get(0);
+ assertEquals("I think, therefore I am", hit.getData(), "a hit on a
header must carry the body of its exchange");
+ Document document = hit.getDocument();
+ assertEquals("carlin", document.get("author"));
+ assertEquals(inserted.getExchangeId(), document.get("exchangeId"));
+ }
+
+ @Test
+ void headersNamedLikeTheExchangeFieldsDoNotReplaceThem() {
+ Exchange inserted = template.send("direct:insert", e -> {
+ e.getIn().setHeader("contents", "header value");
+ e.getIn().setHeader("exchangeId", "header id");
+ e.getIn().setBody("the real body");
+ });
+ assertNull(inserted.getException(), "insert");
+
+ Hits hits = query("direct:query", "real");
+ assertEquals(1, hits.getNumberOfHits());
+ Hit hit = hits.getHit().get(0);
+ assertEquals("the real body", hit.getData(), "a header named contents
must not replace the body");
+ assertEquals(inserted.getExchangeId(),
hit.getDocument().get("exchangeId"),
+ "a header named exchangeId must not replace the exchange id");
+ }
+
+ @Test
+ void sourceFileIsFoundByQueryWithItsPath() throws Exception {
+ // the source directory is indexed when the insert endpoint is created
+ Exchange inserted = template.send("direct:insertWithSrcDir", e ->
e.getIn().setBody("unrelated"));
+ assertNull(inserted.getException(), "insert");
+
+ Hits hits = query("direct:querySrcDir", "sunshine");
+ assertEquals(1, hits.getNumberOfHits(), "a word of a source file must
be found");
+ Document document = hits.getHit().get(0).getDocument();
+ assertNotNull(document, "the hit must carry its document");
+ String path = new File(srcDir, "quote.txt").getPath();
+ assertEquals(path, document.get("path"), "the path must be in the
document of the file contents");
+
+ try (FSDirectory dir = FSDirectory.open(srcIndexDir.toPath());
+ DirectoryReader reader = DirectoryReader.open(dir)) {
+ // the path is not tokenized: the whole path is one term
+ int pathHits = new IndexSearcher(reader).count(new TermQuery(new
Term("path", path)));
+ assertEquals(1, pathHits, "the path must be indexed as one term");
+ }
+ }
+}
diff --git
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
index 0c7ffb516af8..36d16341c357 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
@@ -3817,6 +3817,25 @@ Applications whose custom code (such as beans calling
`Customer.retrieve(...)` d
`camel-stripe` setting `Stripe.apiKey` globally in the JVM at startup must now
configure `Stripe.apiKey` directly or
pass `RequestOptions` explicitly.
+=== camel-lucene - inserted documents are indexed, one document per exchange
+
+The `lucene:...:insert` endpoint now indexes the fields it writes, so a
`lucene:...:query` finds the inserted messages
+and the files of `srcDir`. Before, the fields were stored but not indexed, and
a query never found them.
+
+*Breaking change:* an index directory written by an earlier Camel version
cannot be appended to. Lucene does not let a
+field change from not indexed to indexed, so the insert fails with
`IllegalArgumentException: cannot change field
+"exchangeId" from index options=NONE to inconsistent index
options=DOCS_AND_FREQS_AND_POSITIONS` (or the same for
+another field). Delete the index directory (`indexDir`) and insert the
messages again. A query on such an index does not
+fail, but it finds none of its documents, as before.
+
+Each exchange is now indexed as one document, with the body in `contents`, the
exchange id in `exchangeId` and each
+header in a field of the header name. Each file of `srcDir` is one document
with the `contents` and `path` fields.
+Before, each of these fields was a document of its own. The files of `srcDir`
are indexed each time the insert endpoint
+is created, such as when the application starts; the document of a file now
replaces the one with the same `path`
+instead of adding a copy. A query on a header, such as `author:carlin`, now
gives a hit
+whose data is the body of the exchange, and with `CamelLuceneReturnLuceneDocs`
the document of a hit carries all the
+fields of its exchange or file.
+
=== camel-wordpress - requests and responses are no longer logged
The WordPress client no longer logs every HTTP request and response it
exchanges with the WordPress server at `INFO`