This is an automated email from the ASF dual-hosted git repository.

jamesnetherton pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel-quarkus.git


The following commit(s) were added to refs/heads/main by this push:
     new 4367b04b9e Fixes #9162. Read the document id from the 
CamelLangChain4jIngestDocumentId header
4367b04b9e is described below

commit 4367b04b9eff6ded181c4e1d7cdd2b5075fb0def
Author: Jiří Ondrušek <[email protected]>
AuthorDate: Tue Sep 15 08:34:09 2026 +0200

    Fixes #9162. Read the document id from the CamelLangChain4jIngestDocumentId 
header
    
    The default id header of consumer-fed pipelines becomes the
    camel-langchain4j-ingest component's CamelLangChain4jIngestDocumentId,
    so the identity contract taught in 3.40 LTS survives the engine
    delegation (#9142). The 3.39 name CamelIngestDocumentId is still read
    as a deprecated fallback when the new header is absent - the new one
    wins when both are set - and IngestHeaders keeps it as
    LEGACY_DOCUMENT_ID. Documented in the migration guide.
    
    Verified: 23 deployment test suites, 13 JVM integration tests green.
    
    Co-authored-by: Claude Fable 5 <[email protected]>
---
 .../modules/ROOT/pages/migration-guide/3.40.0.adoc |  6 ++++
 .../reference/extensions/langchain4j-ingest.adoc   |  5 +--
 .../runtime/src/main/doc/usage.adoc                |  2 +-
 .../langchain4j/ingest/IngestHeaders.java          | 14 ++++++++-
 .../component/langchain4j/ingest/IngestRoutes.java | 36 ++++++++++++++++++++--
 .../langchain4j/ingest/IngestRunTimeConfig.java    |  3 +-
 .../langchain4j/ingest/it/IngestResource.java      | 33 ++++++++++++++++++++
 .../src/main/resources/application.properties      |  2 +-
 .../ingest/it/Langchain4jIngestIdempotentTest.java | 33 ++++++++++++++++++++
 9 files changed, 125 insertions(+), 9 deletions(-)

diff --git a/docs/modules/ROOT/pages/migration-guide/3.40.0.adoc 
b/docs/modules/ROOT/pages/migration-guide/3.40.0.adoc
index e62955d7a0..27e367f7ba 100644
--- a/docs/modules/ROOT/pages/migration-guide/3.40.0.adoc
+++ b/docs/modules/ROOT/pages/migration-guide/3.40.0.adoc
@@ -49,6 +49,12 @@ Nothing else about the metadata changed: the values and the 
segment text are wri
 Newly ingested segments carry the new keys, while segments already in a store 
keep the old ones — retrieval that filters or cites by these keys sees the 
mismatch.
 Either re-ingest the affected documents (with the default in-memory register a 
restart re-ingests the directory; clear a persistent register first) so 
everything carries the new keys, or make retrieval read both key pairs during 
the transition.
 
+=== The default document id header is now CamelLangChain4jIngestDocumentId
+
+A consumer-fed pipeline without `source.document-id` now reads its id from the 
`CamelLangChain4jIngestDocumentId` header — the `camel-langchain4j-ingest` 
component's convention.
+The 3.39 name `CamelIngestDocumentId` is still read as a deprecated fallback 
(the new header wins when both are set), so existing routes keep working; it 
will be removed in a future release.
+`IngestHeaders.DOCUMENT_ID` carries the new name, the old one stays as the 
deprecated `LEGACY_DOCUMENT_ID`.
+
 == LangChain4j EmbeddingStore extension changes
 
 === The RAG augmentor bridge moved to the EmbeddingStore extension
diff --git 
a/docs/modules/ROOT/pages/reference/extensions/langchain4j-ingest.adoc 
b/docs/modules/ROOT/pages/reference/extensions/langchain4j-ingest.adoc
index d481cc2adf..f76dc2865b 100644
--- a/docs/modules/ROOT/pages/reference/extensions/langchain4j-ingest.adoc
+++ b/docs/modules/ROOT/pages/reference/extensions/langchain4j-ingest.adoc
@@ -126,7 +126,7 @@ IngestPipeline events() {
 
 Typing `dsl.` lists a factory for every component, each completing its own 
typed options. The method runs once at startup; it must return 
`IngestPipeline`, take no parameters and use a name no configured pipeline 
uses. `enabled=false` in configuration switches a Java-declared pipeline off. A 
component missing from the classpath fails at startup with an error naming the 
extension artifact that provides it — the DSL compiles regardless, since its 
factories all ship in one artifact.
 
-Ingestion needs a stable id per document, and where it lives is the consumer's 
business: `documentId` names the header — the record key `CamelKafkaKey` above, 
`CamelAwsS3Key` for S3 — or gives a simple-language expression. Without it, the 
pipeline expects the `CamelIngestDocumentId` header and fails the exchange when 
it is absent. Mind each component's own defaults, too: the `aws2-s3` consumer 
*deletes objects after reading them* unless `deleteAfterRead(false)` is set — a 
knowledge base  [...]
+Ingestion needs a stable id per document, and where it lives is the consumer's 
business: `documentId` names the header — the record key `CamelKafkaKey` above, 
`CamelAwsS3Key` for S3 — or gives a simple-language expression. Without it, the 
pipeline expects the `CamelLangChain4jIngestDocumentId` header — the 
`camel-langchain4j-ingest` component's convention; the 3.39 name 
`CamelIngestDocumentId` is still read as a fallback, see the migration guide — 
and fails the exchange when both are abs [...]
 
 After such a pipeline ingests a document, the exchange body is replaced with 
the `IngestResult`, so a request-reply caller receives the outcome of its call.
 
@@ -267,7 +267,8 @@ expression in the `+$simple{...}+` form — MicroProfile 
Config passes it throug
 untouched, while a `+${...}+` in a properties file would be consumed as a 
config
 expansion before Camel ever saw it. When not set, a pipeline reading a 
directory
 uses the file name, and one consuming from a component uses the
-`CamelIngestDocumentId` header.
+`CamelLangChain4jIngestDocumentId` header (the deprecated 3.39 name
+`CamelIngestDocumentId` is still read as a fallback).
 | `string`
 | 
 |===
diff --git a/extensions/langchain4j-ingest/runtime/src/main/doc/usage.adoc 
b/extensions/langchain4j-ingest/runtime/src/main/doc/usage.adoc
index 62663db5e1..3eb73506ca 100644
--- a/extensions/langchain4j-ingest/runtime/src/main/doc/usage.adoc
+++ b/extensions/langchain4j-ingest/runtime/src/main/doc/usage.adoc
@@ -82,7 +82,7 @@ IngestPipeline events() {
 
 Typing `dsl.` lists a factory for every component, each completing its own 
typed options. The method runs once at startup; it must return 
`IngestPipeline`, take no parameters and use a name no configured pipeline 
uses. `enabled=false` in configuration switches a Java-declared pipeline off. A 
component missing from the classpath fails at startup with an error naming the 
extension artifact that provides it — the DSL compiles regardless, since its 
factories all ship in one artifact.
 
-Ingestion needs a stable id per document, and where it lives is the consumer's 
business: `documentId` names the header — the record key `CamelKafkaKey` above, 
`CamelAwsS3Key` for S3 — or gives a simple-language expression. Without it, the 
pipeline expects the `CamelIngestDocumentId` header and fails the exchange when 
it is absent. Mind each component's own defaults, too: the `aws2-s3` consumer 
*deletes objects after reading them* unless `deleteAfterRead(false)` is set — a 
knowledge base  [...]
+Ingestion needs a stable id per document, and where it lives is the consumer's 
business: `documentId` names the header — the record key `CamelKafkaKey` above, 
`CamelAwsS3Key` for S3 — or gives a simple-language expression. Without it, the 
pipeline expects the `CamelLangChain4jIngestDocumentId` header — the 
`camel-langchain4j-ingest` component's convention; the 3.39 name 
`CamelIngestDocumentId` is still read as a fallback, see the migration guide — 
and fails the exchange when both are abs [...]
 
 After such a pipeline ingests a document, the exchange body is replaced with 
the `IngestResult`, so a request-reply caller receives the outcome of its call.
 
diff --git 
a/extensions/langchain4j-ingest/runtime/src/main/java/org/apache/camel/quarkus/component/langchain4j/ingest/IngestHeaders.java
 
b/extensions/langchain4j-ingest/runtime/src/main/java/org/apache/camel/quarkus/component/langchain4j/ingest/IngestHeaders.java
index d2834bce41..1ad316d6fc 100644
--- 
a/extensions/langchain4j-ingest/runtime/src/main/java/org/apache/camel/quarkus/component/langchain4j/ingest/IngestHeaders.java
+++ 
b/extensions/langchain4j-ingest/runtime/src/main/java/org/apache/camel/quarkus/component/langchain4j/ingest/IngestHeaders.java
@@ -25,8 +25,20 @@ public final class IngestHeaders {
      * The stable document id, when {@code source.document-id} does not name 
another header.
      * Identity is what future update and delete semantics will be built on, 
so a generated
      * fallback would silently break replacement — an absent id is an error.
+     *
+     * <p>
+     * The {@code camel-langchain4j-ingest} component's name; the 3.39 name is 
still read, see
+     * {@link #LEGACY_DOCUMENT_ID}.
      */
-    public static final String DOCUMENT_ID = "CamelIngestDocumentId";
+    public static final String DOCUMENT_ID = 
"CamelLangChain4jIngestDocumentId";
+
+    /**
+     * The 3.39 name, read as a fallback when {@link #DOCUMENT_ID} is absent.
+     *
+     * @deprecated set {@link #DOCUMENT_ID} instead
+     */
+    @Deprecated(since = "3.40.0", forRemoval = true)
+    public static final String LEGACY_DOCUMENT_ID = "CamelIngestDocumentId";
 
     private IngestHeaders() {
     }
diff --git 
a/extensions/langchain4j-ingest/runtime/src/main/java/org/apache/camel/quarkus/component/langchain4j/ingest/IngestRoutes.java
 
b/extensions/langchain4j-ingest/runtime/src/main/java/org/apache/camel/quarkus/component/langchain4j/ingest/IngestRoutes.java
index 1461e16f52..9155f9db91 100644
--- 
a/extensions/langchain4j-ingest/runtime/src/main/java/org/apache/camel/quarkus/component/langchain4j/ingest/IngestRoutes.java
+++ 
b/extensions/langchain4j-ingest/runtime/src/main/java/org/apache/camel/quarkus/component/langchain4j/ingest/IngestRoutes.java
@@ -20,6 +20,7 @@ import java.nio.charset.StandardCharsets;
 import java.util.List;
 import java.util.Set;
 import java.util.TreeSet;
+import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.stream.Collectors;
 import java.util.stream.StreamSupport;
 
@@ -38,6 +39,7 @@ import org.apache.camel.builder.RouteBuilder;
 import org.apache.camel.quarkus.component.langchain4j.ingest.core.IngestResult;
 import 
org.apache.camel.quarkus.component.langchain4j.ingest.core.IngestService;
 import org.apache.camel.spi.IdempotentRepository;
+import org.apache.camel.support.ExpressionAdapter;
 import org.apache.camel.support.builder.ExpressionBuilder;
 import 
org.apache.camel.support.processor.idempotent.MemoryIdempotentRepository;
 import org.apache.camel.util.URISupport;
@@ -180,7 +182,7 @@ public class IngestRoutes extends RouteBuilder {
         // not consume it), idempotent keeps the same file from being ingested 
twice, and the
         // changed read lock waits for a file still being copied in rather 
than embedding half
         // of it
-        Expression documentId = documentIdExpression(runtime, 
Exchange.FILE_NAME);
+        Expression documentId = documentIdExpression(name, runtime, 
Exchange.FILE_NAME);
         maybeAutoCreateRepository(name, runtime);
         String repositoryName = runtime == null ? null : 
runtime.source().idempotentRepository().orElse(null);
         var endpoint = file(directory)
@@ -219,7 +221,7 @@ public class IngestRoutes extends RouteBuilder {
      */
     private void configureEndpointSource(String name, String uri,
             IngestRunTimeConfig.PipelineRunTimeConfig runtime, IngestService 
service) {
-        Expression documentId = documentIdExpression(runtime, 
IngestHeaders.DOCUMENT_ID);
+        Expression documentId = documentIdExpression(name, runtime, 
IngestHeaders.DOCUMENT_ID);
         maybeAutoCreateRepository(name, runtime);
         String repositoryName = runtime == null ? null : 
runtime.source().idempotentRepository().orElse(null);
         if (repositoryName == null) {
@@ -272,10 +274,38 @@ public class IngestRoutes extends RouteBuilder {
         return id;
     }
 
-    private Expression 
documentIdExpression(IngestRunTimeConfig.PipelineRunTimeConfig runtime,
+    @SuppressWarnings("deprecation")
+    private Expression documentIdExpression(String name, 
IngestRunTimeConfig.PipelineRunTimeConfig runtime,
             String defaultHeader) {
         String configured = runtime == null ? null : 
runtime.source().documentId().orElse(null);
         if (configured == null) {
+            if (IngestHeaders.DOCUMENT_ID.equals(defaultHeader)) {
+                // the current name first, the 3.39 name as fallback; each 
name keeps Camel's
+                // header-then-exchange-property lookup. The deprecation is 
warned once per
+                // pipeline - literal-setting producers get no compiler signal
+                Expression current = 
ExpressionBuilder.headerExpression(IngestHeaders.DOCUMENT_ID);
+                Expression legacy = 
ExpressionBuilder.headerExpression(IngestHeaders.LEGACY_DOCUMENT_ID);
+                current.init(getContext());
+                legacy.init(getContext());
+                return new ExpressionAdapter() {
+                    private final AtomicBoolean warned = new AtomicBoolean();
+
+                    @Override
+                    public Object evaluate(Exchange exchange) {
+                        Object id = current.evaluate(exchange, Object.class);
+                        if (id != null) {
+                            return id;
+                        }
+                        id = legacy.evaluate(exchange, Object.class);
+                        if (id != null && warned.compareAndSet(false, true)) {
+                            LOG.warnf("Ingestion pipeline '%s': document id 
read from the deprecated %s name"
+                                    + " - switch the producer to %s",
+                                    name, IngestHeaders.LEGACY_DOCUMENT_ID, 
IngestHeaders.DOCUMENT_ID);
+                        }
+                        return id;
+                    }
+                };
+            }
             return ExpressionBuilder.headerExpression(defaultHeader);
         }
         // a bare header name is read as a header directly rather than parsed: 
a dotted header
diff --git 
a/extensions/langchain4j-ingest/runtime/src/main/java/org/apache/camel/quarkus/component/langchain4j/ingest/IngestRunTimeConfig.java
 
b/extensions/langchain4j-ingest/runtime/src/main/java/org/apache/camel/quarkus/component/langchain4j/ingest/IngestRunTimeConfig.java
index ce84df44c2..f6320321d5 100644
--- 
a/extensions/langchain4j-ingest/runtime/src/main/java/org/apache/camel/quarkus/component/langchain4j/ingest/IngestRunTimeConfig.java
+++ 
b/extensions/langchain4j-ingest/runtime/src/main/java/org/apache/camel/quarkus/component/langchain4j/ingest/IngestRunTimeConfig.java
@@ -93,7 +93,8 @@ public interface IngestRunTimeConfig {
              * untouched, while a `+${...}+` in a properties file would be 
consumed as a config
              * expansion before Camel ever saw it. When not set, a pipeline 
reading a directory
              * uses the file name, and one consuming from a component uses the
-             * `CamelIngestDocumentId` header.
+             * `CamelLangChain4jIngestDocumentId` header (the deprecated 3.39 
name
+             * `CamelIngestDocumentId` is still read as a fallback).
              */
             Optional<String> documentId();
         }
diff --git 
a/integration-tests/langchain4j-ingest/src/main/java/org/apache/camel/quarkus/component/langchain4j/ingest/it/IngestResource.java
 
b/integration-tests/langchain4j-ingest/src/main/java/org/apache/camel/quarkus/component/langchain4j/ingest/it/IngestResource.java
index 3d8c3b8fad..d496f4f474 100644
--- 
a/integration-tests/langchain4j-ingest/src/main/java/org/apache/camel/quarkus/component/langchain4j/ingest/it/IngestResource.java
+++ 
b/integration-tests/langchain4j-ingest/src/main/java/org/apache/camel/quarkus/component/langchain4j/ingest/it/IngestResource.java
@@ -19,6 +19,7 @@ package 
org.apache.camel.quarkus.component.langchain4j.ingest.it;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.util.List;
+import java.util.Map;
 
 import dev.langchain4j.data.segment.TextSegment;
 import dev.langchain4j.model.embedding.EmbeddingModel;
@@ -68,6 +69,10 @@ public class IngestResource {
     @Named("events-store")
     EmbeddingStore<TextSegment> eventsStore;
 
+    @Inject
+    @Named("jdbc-store")
+    EmbeddingStore<TextSegment> jdbcStore;
+
     @Inject
     ProducerTemplate producerTemplate;
 
@@ -106,6 +111,7 @@ public class IngestResource {
         case "datasheets" -> datasheetsStore;
         case "s3" -> s3Store;
         case "events" -> eventsStore;
+        case "jdbc" -> jdbcStore;
         default -> productsStore;
         };
         // the deterministic test model gives a query no semantic pull towards 
any document, so
@@ -142,4 +148,31 @@ public class IngestResource {
         return result.outcome().label();
     }
 
+    /** Feeds a pipeline carrying different ids in the current and the 
deprecated header, so tests can assert precedence. */
+    @POST
+    
@jakarta.ws.rs.Path("/feed-both/{pipeline}/{documentId}/{legacyDocumentId}")
+    @Consumes(MediaType.TEXT_PLAIN)
+    @Produces(MediaType.TEXT_PLAIN)
+    @SuppressWarnings("deprecation")
+    public String feedBoth(@PathParam("pipeline") String pipeline, 
@PathParam("documentId") String documentId,
+            @PathParam("legacyDocumentId") String legacyDocumentId, String 
content) {
+        IngestResult result = producerTemplate.requestBodyAndHeaders("direct:" 
+ pipeline + "-feed", content,
+                Map.of(IngestHeaders.DOCUMENT_ID, documentId, 
IngestHeaders.LEGACY_DOCUMENT_ID, legacyDocumentId),
+                IngestResult.class);
+        return result.outcome().label();
+    }
+
+    /** Feeds a pipeline carrying the id in the deprecated 3.39 header, so 
tests can assert the fallback. */
+    @POST
+    @jakarta.ws.rs.Path("/feed-legacy/{pipeline}/{documentId:.+}")
+    @Consumes(MediaType.TEXT_PLAIN)
+    @Produces(MediaType.TEXT_PLAIN)
+    @SuppressWarnings("deprecation")
+    public String feedLegacy(@PathParam("pipeline") String pipeline, 
@PathParam("documentId") String documentId,
+            String content) {
+        IngestResult result = producerTemplate.requestBodyAndHeader("direct:" 
+ pipeline + "-feed", content,
+                IngestHeaders.LEGACY_DOCUMENT_ID, documentId, 
IngestResult.class);
+        return result.outcome().label();
+    }
+
 }
diff --git 
a/integration-tests/langchain4j-ingest/src/main/resources/application.properties
 
b/integration-tests/langchain4j-ingest/src/main/resources/application.properties
index f8caf3a942..91ce3bd0b6 100644
--- 
a/integration-tests/langchain4j-ingest/src/main/resources/application.properties
+++ 
b/integration-tests/langchain4j-ingest/src/main/resources/application.properties
@@ -35,7 +35,7 @@ 
quarkus.camel.langchain4j.ingest.products.source.idempotent-repository=propertie
 # consumer-fed pipeline; the document-id exercises the $simple{...} form and 
the register gives
 # first-write-wins dedup by document id
 quarkus.camel.langchain4j.ingest.custom.source.uri=direct:custom-feed
-quarkus.camel.langchain4j.ingest.custom.source.document-id=$simple{header.CamelIngestDocumentId}
+quarkus.camel.langchain4j.ingest.custom.source.document-id=$simple{header.CamelLangChain4jIngestDocumentId}
 
quarkus.camel.langchain4j.ingest.custom.source.idempotent-repository=test-register
 # auto-create plus the CDI-produced test-register bean: the existing bean wins 
over auto-creation
 
quarkus.camel.langchain4j.ingest.custom.source.idempotent-repository-auto-create=true
diff --git 
a/integration-tests/langchain4j-ingest/src/test/java/org/apache/camel/quarkus/component/langchain4j/ingest/it/Langchain4jIngestIdempotentTest.java
 
b/integration-tests/langchain4j-ingest/src/test/java/org/apache/camel/quarkus/component/langchain4j/ingest/it/Langchain4jIngestIdempotentTest.java
index 74a98d5c20..7a67fea8b0 100644
--- 
a/integration-tests/langchain4j-ingest/src/test/java/org/apache/camel/quarkus/component/langchain4j/ingest/it/Langchain4jIngestIdempotentTest.java
+++ 
b/integration-tests/langchain4j-ingest/src/test/java/org/apache/camel/quarkus/component/langchain4j/ingest/it/Langchain4jIngestIdempotentTest.java
@@ -178,4 +178,37 @@ class Langchain4jIngestIdempotentTest {
                 .then().statusCode(200).extract().asString();
         assertEquals("true", contains);
     }
+
+    /** When both headers are set, the current one wins. */
+    @Test
+    void currentDocumentIdHeaderWinsOverLegacy() {
+        String outcome = RestAssured.given().contentType(ContentType.TEXT)
+                .body("The SIGMA-8 relay carries two ids.")
+                
.post("/langchain4j-ingest/feed-both/jdbcdocs/current-sigma.txt/legacy-sigma.txt")
+                .then().statusCode(200).extract().asString();
+        assertEquals("ingested", outcome);
+
+        assertEquals("current-sigma.txt",
+                Langchain4jIngestTest.hit("Which relay carries two ids?", 
"jdbc", "SIGMA-8").get("documentId"));
+    }
+
+    /**
+     * The deprecated 3.39 header still works and addresses the same identity: 
a legacy delivery
+     * is deduplicated against a current-header one.
+     */
+    @Test
+    void legacyDocumentIdHeaderStillWorks() {
+        String first = RestAssured.given().contentType(ContentType.TEXT)
+                .body("The LAMBDA-6 valve arrives under the legacy header.")
+                
.post("/langchain4j-ingest/feed-legacy/jdbcdocs/legacy/lambda.txt")
+                .then().statusCode(200).extract().asString();
+        assertEquals("ingested", first);
+
+        // the same id through the current header: one identity, so the 
register answers skipped
+        String second = RestAssured.given().contentType(ContentType.TEXT)
+                .body("The LAMBDA-6 valve tries again under the current 
header.")
+                .post("/langchain4j-ingest/feed/jdbcdocs/legacy/lambda.txt")
+                .then().statusCode(200).extract().asString();
+        assertEquals("skipped", second);
+    }
 }

Reply via email to