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 c0ddf09b3f Fixes #9039. Add a configurable idempotent repository to 
langchain4j-ingest
c0ddf09b3f is described below

commit c0ddf09b3fb83d18ce74f474e07aa073a5637704
Author: Jiří Ondrušek <[email protected]>
AuthorDate: Tue Sep 1 15:03:04 2026 +0200

    Fixes #9039. Add a configurable idempotent repository to langchain4j-ingest
    
    The per-pipeline option source.idempotent-repository names an
    IdempotentRepository bean replacing the built-in in-memory register
    (100k keys, lost on restart). The bean can be auto-created, defined via
    camel.beans.* or produced by CDI (an existing bean wins); it resolves
    eagerly by name, so a missing bean fails the start, and native mode
    registers the camel-core repositories plus every indexed
    IdempotentRepository implementation for reflection.
    
    An edited file re-ingests under the path:modified:size key with its old
    segments kept; a consumer-fed pipeline deduplicates by document id,
    first write wins, answering the new outcome SKIPPED. Documented in
    usage.adoc; tests cover the three definition styles, missing-bean
    failures, key release on failed delivery and an H2-backed
    JdbcMessageIdRepository through Quarkus Dev Services.
    
    Co-authored-by: Claude Fable 5 <[email protected]>
---
 .../reference/extensions/langchain4j-ingest.adoc   |  70 +++++++-
 .../deployment/Langchain4jIngestProcessor.java     |  23 +++
 .../IngestAutoCreateWithoutNameTest.java           |  43 +++++
 .../IngestBuilderConfigIdempotentOverrideTest.java |  54 ++++++
 ...estMissingIdempotentRepositoryEndpointTest.java |  44 +++++
 .../IngestMissingIdempotentRepositoryTest.java     |  44 +++++
 .../runtime/src/main/doc/usage.adoc                |  52 +++++-
 .../ingest/IngestComponentPresence.java            |   4 +-
 .../component/langchain4j/ingest/IngestRoutes.java | 126 ++++++++++++--
 .../langchain4j/ingest/IngestRunTimeConfig.java    |  16 ++
 .../component/langchain4j/ingest/Source.java       |  24 +++
 .../langchain4j/ingest/core/IngestResult.java      |   4 +-
 integration-tests/langchain4j-ingest/pom.xml       |  21 +++
 .../ingest/it/DeterministicEmbeddingModel.java     |   6 +
 .../langchain4j/ingest/it/IngestItProducers.java   |  32 +++-
 .../langchain4j/ingest/it/IngestResource.java      |  26 ++-
 .../langchain4j/ingest/it/ItBuilderPipelines.java  |   7 +-
 .../src/main/resources/application.properties      |  25 ++-
 .../ingest/it/Langchain4jIngestIdempotentIT.java   |  22 +--
 .../ingest/it/Langchain4jIngestIdempotentTest.java | 181 +++++++++++++++++++++
 .../ingest/it/Langchain4jIngestTest.java           |   4 +-
 21 files changed, 776 insertions(+), 52 deletions(-)

diff --git 
a/docs/modules/ROOT/pages/reference/extensions/langchain4j-ingest.adoc 
b/docs/modules/ROOT/pages/reference/extensions/langchain4j-ingest.adoc
index 614bdc88fa..151e7a8c3a 100644
--- a/docs/modules/ROOT/pages/reference/extensions/langchain4j-ingest.adoc
+++ b/docs/modules/ROOT/pages/reference/extensions/langchain4j-ingest.adoc
@@ -55,9 +55,60 @@ Each file is read as UTF-8 text — there is no format 
parsing, so convert a PDF
 
 [NOTE]
 ====
-This experimental extension ingests what it is given and keeps no record of 
it: a document ingested twice leaves two copies in the store, a restart 
re-reads the whole directory (the duplicate register is in-memory, sized for 
100,000 files), and a polled consumer re-reads its source on every poll. 
Keeping the store in step with a changing source — skipping unchanged 
documents, replacing edited ones, removing deleted ones — needs that record and 
arrives with the synchronising engine in a l [...]
+This experimental extension keeps no record of what it wrote: an edited 
document re-ingests on top of its old segments, and removing a document from 
the source removes nothing from the store. Replace and delete arrive with the 
synchronising engine in a later release. What is remembered is which documents 
were already ingested — the idempotent repository below.
 ====
 
+[id="extensions-langchain4j-ingest-usage-the-idempotent-repository"]
+=== The idempotent repository
+
+A directory pipeline registers what it ingested, keyed on the file's path, 
modification time and size: unchanged files are skipped, edited files re-ingest 
(their previous segments remain, see the note above). The default register is 
in-memory (100,000 keys) and is there for the poll loop, not for restarts: the 
consumer re-scans the directory on every poll, so without a register a running 
application would re-embed the whole directory every few seconds. A restart 
loses it and re-ingests t [...]
+
+[source,properties]
+----
+quarkus.camel.langchain4j.ingest.products.source.idempotent-repository=productsRegister
+----
+
+The bean can be provided three ways.
+
+*Auto-created*: an in-memory register (100,000 keys) is created and bound 
under the configured name — no bean definition needed, still lost on restart. 
If a bean with the name already exists, the existing bean wins:
+
+[source,properties]
+----
+quarkus.camel.langchain4j.ingest.products.source.idempotent-repository=productsRegister
+quarkus.camel.langchain4j.ingest.products.source.idempotent-repository-auto-create=true
+----
+
+*Defined in properties* through Camel's `camel.beans.` syntax — here a 
file-backed register that survives restarts (camel-core, no extra dependency). 
Use a name without dashes; Camel normalises dashed `camel.beans.` keys to 
camelCase:
+
+[source,properties]
+----
+camel.beans.productsRegister=#class:org.apache.camel.support.processor.idempotent.FileIdempotentRepository
+camel.beans.productsRegister.fileStore=/var/data/ingest/register.dat
+camel.beans.productsRegister.cacheSize=100000
+# keys beyond this size are dropped oldest-first and their files re-ingest (32 
MB here)
+camel.beans.productsRegister.maxFileStoreSize=33554432
+----
+
+`#class:` beans are created reflectively. For native mode the extension 
registers the camel-core repositories shown here and every 
`IdempotentRepository` implementation found in the Jandex index — application 
classes always, component-provided ones through their jar's index. A class from 
a jar without an index needs `@RegisterForReflection(targets = ...)` or a 
`quarkus.index-dependency.*` entry.
+
+*Defined as a CDI producer* — needed when construction takes other beans. A 
JDBC register also deduplicates across instances: reliably for consumer-fed 
pipelines keyed on the document id, for directory pipelines only when all 
instances see identical paths and modification times:
+
+[source,java]
+----
+@Produces @Singleton @Named("productsRegister")
+IdempotentRepository productsRegister(DataSource dataSource) {
+    return new JdbcMessageIdRepository(dataSource, "ingest-products"); // 
camel-quarkus-sql
+}
+----
+
+More repository types — Caffeine, Infinispan, MongoDB, Cassandra, Hazelcast, 
Kafka and others, each provided by its component's extension — along with the 
pattern's details are covered in the 
xref:{cq-camel-components}:eips:idempotentConsumer-eip.adoc[Idempotent Consumer 
EIP guide].
+
+Sizing. An in-memory register smaller than the directory evicts keys and 
re-ingests those files during normal operation — hence the 100,000 default. 
`FileIdempotentRepository` falls back to its file store on a cache miss, so 
`cacheSize` is a performance setting; the correctness limit is 
`maxFileStoreSize`: the default of about 1 MB holds roughly 10–17 thousand of 
these keys, beyond it the oldest 1000 are dropped with a warning. A JDBC 
register has no size limit. Every edit adds a new key [...]
+
+The register cannot be switched off: the consumer leaves files in place 
(`noop`), so without a register every poll would re-ingest the directory. To 
force a full re-ingest, restart (default register) or clear the persistent one. 
A pipeline that should consume its source — move or delete files after 
ingestion — uses `source.uri` (for example 
`file:/inbox?delete=true&idempotent=false` with 
`source.document-id=CamelFileName`), where all consumer options are available.
+
+On a consumer-fed pipeline (`source.uri` or `@Ingest`), a configured register 
deduplicates deliveries by document id: a redelivered record or re-listed 
object ingests once, and a request-reply caller receives `skipped` instead of 
`ingested`. First write wins per id — an update with the same id is skipped, 
not replaced; streams that carry updates need a version-aware 
`source.document-id`. Only a delivery that wrote segments claims its id: a 
blank document answers `empty` and releases the  [...]
+
 [id="extensions-langchain4j-ingest-usage-other-sources-declared-in-java"]
 === Other sources, declared in Java
 
@@ -190,6 +241,23 @@ Whether subdirectories are ingested too, when reading a 
directory.
 | `boolean`
 | `true`
 
+a| 
[[quarkus-camel-langchain4j-ingest-pipeline-name-source-idempotent-repository]]`link:#quarkus-camel-langchain4j-ingest-pipeline-name-source-idempotent-repository[quarkus.camel.langchain4j.ingest."pipeline-name".source.idempotent-repository]`
+
+Name of the `IdempotentRepository` bean remembering already ingested documents,
+instead of the built-in in-memory one (100 000 keys, lost on restart). Looked 
up
+by name only. On a pipeline consuming from a component it deduplicates 
deliveries
+by document id, first write wins.
+| `string`
+| 
+
+a| 
[[quarkus-camel-langchain4j-ingest-pipeline-name-source-idempotent-repository-auto-create]]`link:#quarkus-camel-langchain4j-ingest-pipeline-name-source-idempotent-repository-auto-create[quarkus.camel.langchain4j.ingest."pipeline-name".source.idempotent-repository-auto-create]`
+
+When `true`, an in-memory register (100 000 keys) is created and bound under 
the
+`idempotent-repository` name, unless a bean with that name exists — the 
existing
+bean wins.
+| `boolean`
+| `false`
+
 a| 
[[quarkus-camel-langchain4j-ingest-pipeline-name-source-document-id]]`link:#quarkus-camel-langchain4j-ingest-pipeline-name-source-document-id[quarkus.camel.langchain4j.ingest."pipeline-name".source.document-id]`
 
 Where the document id lives in the exchange the consumer delivers: normally the
diff --git 
a/extensions/langchain4j-ingest/deployment/src/main/java/org/apache/camel/quarkus/component/langchain4j/ingest/deployment/Langchain4jIngestProcessor.java
 
b/extensions/langchain4j-ingest/deployment/src/main/java/org/apache/camel/quarkus/component/langchain4j/ingest/deployment/Langchain4jIngestProcessor.java
index 2e5cfc0f77..3f1c1148ba 100644
--- 
a/extensions/langchain4j-ingest/deployment/src/main/java/org/apache/camel/quarkus/component/langchain4j/ingest/deployment/Langchain4jIngestProcessor.java
+++ 
b/extensions/langchain4j-ingest/deployment/src/main/java/org/apache/camel/quarkus/component/langchain4j/ingest/deployment/Langchain4jIngestProcessor.java
@@ -73,6 +73,29 @@ class Langchain4jIngestProcessor {
                 .build();
     }
 
+    /**
+     * {@code #class:} beans are instantiated reflectively, which native mode 
allows only for
+     * registered classes: the two camel-core repositories the documentation 
recommends, plus
+     * every {@code IdempotentRepository} implementation the Jandex index 
knows — application
+     * classes always, third-party ones when their jar carries an index.
+     */
+    @BuildStep
+    void repositoryReflection(CombinedIndexBuildItem combinedIndex,
+            BuildProducer<ReflectiveClassBuildItem> reflectiveClasses) {
+        Set<String> repositories = new HashSet<>(Set.of(
+                
"org.apache.camel.support.processor.idempotent.MemoryIdempotentRepository",
+                
"org.apache.camel.support.processor.idempotent.FileIdempotentRepository"));
+        combinedIndex.getIndex()
+                
.getAllKnownImplementations(DotName.createSimple("org.apache.camel.spi.IdempotentRepository"))
+                .stream()
+                .filter(repository -> !Modifier.isAbstract(repository.flags()))
+                .map(repository -> repository.name().toString())
+                .forEach(repositories::add);
+        
reflectiveClasses.produce(ReflectiveClassBuildItem.builder(repositories.toArray(new
 String[0]))
+                .methods()
+                .build());
+    }
+
     /**
      * Discovers {@code @Ingest} builder methods: validated here (return type, 
no parameters,
      * unique names, no collision with configuration-declared pipelines), 
invoked reflectively once
diff --git 
a/extensions/langchain4j-ingest/deployment/src/test/java/org/apache/camel/quarkus/component/langchain4j/ingest/deployment/IngestAutoCreateWithoutNameTest.java
 
b/extensions/langchain4j-ingest/deployment/src/test/java/org/apache/camel/quarkus/component/langchain4j/ingest/deployment/IngestAutoCreateWithoutNameTest.java
new file mode 100644
index 0000000000..750527574c
--- /dev/null
+++ 
b/extensions/langchain4j-ingest/deployment/src/test/java/org/apache/camel/quarkus/component/langchain4j/ingest/deployment/IngestAutoCreateWithoutNameTest.java
@@ -0,0 +1,43 @@
+/*
+ * 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.quarkus.component.langchain4j.ingest.deployment;
+
+import io.quarkus.test.QuarkusExtensionTest;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+/** Auto-create without a repository name to create it under fails the start. 
*/
+class IngestAutoCreateWithoutNameTest {
+
+    @RegisterExtension
+    static final QuarkusExtensionTest CONFIG = new QuarkusExtensionTest()
+            .withApplicationRoot(jar -> 
jar.addClasses(TestEmbeddingBeans.class))
+            
.overrideConfigKey("quarkus.camel.langchain4j.ingest.docs.embedding-store", 
"store")
+            
.overrideConfigKey("quarkus.camel.langchain4j.ingest.docs.embedding-model", 
"model")
+            
.overrideConfigKey("quarkus.camel.langchain4j.ingest.docs.source.directory", 
"target/auto-docs")
+            
.overrideConfigKey("quarkus.camel.langchain4j.ingest.docs.source.idempotent-repository-auto-create",
+                    "true")
+            .assertException(t -> ValidationTestSupport.assertFailure(t,
+                    "sets source.idempotent-repository-auto-create",
+                    "no source.idempotent-repository name"));
+
+    @Test
+    void startMustFail() {
+        Assertions.fail("The application start was expected to fail");
+    }
+}
diff --git 
a/extensions/langchain4j-ingest/deployment/src/test/java/org/apache/camel/quarkus/component/langchain4j/ingest/deployment/IngestBuilderConfigIdempotentOverrideTest.java
 
b/extensions/langchain4j-ingest/deployment/src/test/java/org/apache/camel/quarkus/component/langchain4j/ingest/deployment/IngestBuilderConfigIdempotentOverrideTest.java
new file mode 100644
index 0000000000..81245c7894
--- /dev/null
+++ 
b/extensions/langchain4j-ingest/deployment/src/test/java/org/apache/camel/quarkus/component/langchain4j/ingest/deployment/IngestBuilderConfigIdempotentOverrideTest.java
@@ -0,0 +1,54 @@
+/*
+ * 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.quarkus.component.langchain4j.ingest.deployment;
+
+import io.quarkus.test.QuarkusExtensionTest;
+import jakarta.enterprise.context.ApplicationScoped;
+import org.apache.camel.quarkus.component.langchain4j.ingest.Ingest;
+import org.apache.camel.quarkus.component.langchain4j.ingest.IngestPipeline;
+import org.apache.camel.quarkus.component.langchain4j.ingest.Source;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+/**
+ * {@code source.idempotent-repository} is owned by a builder-declared 
pipeline; configuring it
+ * externally fails at startup like the rest of {@code source.*}.
+ */
+class IngestBuilderConfigIdempotentOverrideTest {
+
+    @RegisterExtension
+    static final QuarkusExtensionTest CONFIG = new QuarkusExtensionTest()
+            .withApplicationRoot(jar -> jar.addClasses(Pipelines.class))
+            
.overrideConfigKey("quarkus.camel.langchain4j.ingest.docs.source.idempotent-repository",
 "external-repo")
+            .assertException(t -> ValidationTestSupport.assertFailure(t,
+                    "declared in Java", "Remove 
quarkus.camel.langchain4j.ingest.docs.source.*"));
+
+    @Test
+    void startMustFail() {
+        Assertions.fail("The application start was expected to fail");
+    }
+
+    @ApplicationScoped
+    public static class Pipelines {
+
+        @Ingest("docs")
+        IngestPipeline docs() {
+            return IngestPipeline.from(Source.file("target/docs"));
+        }
+    }
+}
diff --git 
a/extensions/langchain4j-ingest/deployment/src/test/java/org/apache/camel/quarkus/component/langchain4j/ingest/deployment/IngestMissingIdempotentRepositoryEndpointTest.java
 
b/extensions/langchain4j-ingest/deployment/src/test/java/org/apache/camel/quarkus/component/langchain4j/ingest/deployment/IngestMissingIdempotentRepositoryEndpointTest.java
new file mode 100644
index 0000000000..1d31c131e4
--- /dev/null
+++ 
b/extensions/langchain4j-ingest/deployment/src/test/java/org/apache/camel/quarkus/component/langchain4j/ingest/deployment/IngestMissingIdempotentRepositoryEndpointTest.java
@@ -0,0 +1,44 @@
+/*
+ * 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.quarkus.component.langchain4j.ingest.deployment;
+
+import io.quarkus.test.QuarkusExtensionTest;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+/**
+ * The missing-repository failure on the consumer-fed path: resolution happens 
while the route is
+ * built, so both source kinds fail the start with the same message naming the 
bean.
+ */
+class IngestMissingIdempotentRepositoryEndpointTest {
+
+    @RegisterExtension
+    static final QuarkusExtensionTest CONFIG = new QuarkusExtensionTest()
+            .withApplicationRoot(jar -> 
jar.addClasses(TestEmbeddingBeans.class))
+            
.overrideConfigKey("quarkus.camel.langchain4j.ingest.docs.embedding-store", 
"store")
+            
.overrideConfigKey("quarkus.camel.langchain4j.ingest.docs.embedding-model", 
"model")
+            
.overrideConfigKey("quarkus.camel.langchain4j.ingest.docs.source.uri", 
"file:target/endpoint-repo-missing")
+            
.overrideConfigKey("quarkus.camel.langchain4j.ingest.docs.source.idempotent-repository",
 "no-such-repo")
+            .assertException(t -> ValidationTestSupport.assertFailure(t,
+                    "references idempotent repository 'no-such-repo'", "no 
such bean exists"));
+
+    @Test
+    void startMustFail() {
+        Assertions.fail("The application start was expected to fail");
+    }
+}
diff --git 
a/extensions/langchain4j-ingest/deployment/src/test/java/org/apache/camel/quarkus/component/langchain4j/ingest/deployment/IngestMissingIdempotentRepositoryTest.java
 
b/extensions/langchain4j-ingest/deployment/src/test/java/org/apache/camel/quarkus/component/langchain4j/ingest/deployment/IngestMissingIdempotentRepositoryTest.java
new file mode 100644
index 0000000000..00711ce5eb
--- /dev/null
+++ 
b/extensions/langchain4j-ingest/deployment/src/test/java/org/apache/camel/quarkus/component/langchain4j/ingest/deployment/IngestMissingIdempotentRepositoryTest.java
@@ -0,0 +1,44 @@
+/*
+ * 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.quarkus.component.langchain4j.ingest.deployment;
+
+import io.quarkus.test.QuarkusExtensionTest;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+/**
+ * The idempotent repository is resolved by name when the route starts — after 
Camel Main has
+ * bound {@code camel.beans.*} definitions — and a missing bean fails the 
start naming it.
+ */
+class IngestMissingIdempotentRepositoryTest {
+
+    @RegisterExtension
+    static final QuarkusExtensionTest CONFIG = new QuarkusExtensionTest()
+            .withApplicationRoot(jar -> 
jar.addClasses(TestEmbeddingBeans.class))
+            
.overrideConfigKey("quarkus.camel.langchain4j.ingest.docs.embedding-store", 
"store")
+            
.overrideConfigKey("quarkus.camel.langchain4j.ingest.docs.embedding-model", 
"model")
+            
.overrideConfigKey("quarkus.camel.langchain4j.ingest.docs.source.directory", 
"target/idem-docs")
+            
.overrideConfigKey("quarkus.camel.langchain4j.ingest.docs.source.idempotent-repository",
 "no-such-repo")
+            .assertException(t -> ValidationTestSupport.assertFailure(t,
+                    "references idempotent repository 'no-such-repo'", "no 
such bean exists"));
+
+    @Test
+    void startMustFail() {
+        Assertions.fail("The application start was expected to fail");
+    }
+}
diff --git a/extensions/langchain4j-ingest/runtime/src/main/doc/usage.adoc 
b/extensions/langchain4j-ingest/runtime/src/main/doc/usage.adoc
index 243fc58286..7af9811e88 100644
--- a/extensions/langchain4j-ingest/runtime/src/main/doc/usage.adoc
+++ b/extensions/langchain4j-ingest/runtime/src/main/doc/usage.adoc
@@ -13,9 +13,59 @@ Each file is read as UTF-8 text — there is no format 
parsing, so convert a PDF
 
 [NOTE]
 ====
-This experimental extension ingests what it is given and keeps no record of 
it: a document ingested twice leaves two copies in the store, a restart 
re-reads the whole directory (the duplicate register is in-memory, sized for 
100,000 files), and a polled consumer re-reads its source on every poll. 
Keeping the store in step with a changing source — skipping unchanged 
documents, replacing edited ones, removing deleted ones — needs that record and 
arrives with the synchronising engine in a l [...]
+This experimental extension keeps no record of what it wrote: an edited 
document re-ingests on top of its old segments, and removing a document from 
the source removes nothing from the store. Replace and delete arrive with the 
synchronising engine in a later release. What is remembered is which documents 
were already ingested — the idempotent repository below.
 ====
 
+=== The idempotent repository
+
+A directory pipeline registers what it ingested, keyed on the file's path, 
modification time and size: unchanged files are skipped, edited files re-ingest 
(their previous segments remain, see the note above). The default register is 
in-memory (100,000 keys) and is there for the poll loop, not for restarts: the 
consumer re-scans the directory on every poll, so without a register a running 
application would re-embed the whole directory every few seconds. A restart 
loses it and re-ingests t [...]
+
+[source,properties]
+----
+quarkus.camel.langchain4j.ingest.products.source.idempotent-repository=productsRegister
+----
+
+The bean can be provided three ways.
+
+*Auto-created*: an in-memory register (100,000 keys) is created and bound 
under the configured name — no bean definition needed, still lost on restart. 
If a bean with the name already exists, the existing bean wins:
+
+[source,properties]
+----
+quarkus.camel.langchain4j.ingest.products.source.idempotent-repository=productsRegister
+quarkus.camel.langchain4j.ingest.products.source.idempotent-repository-auto-create=true
+----
+
+*Defined in properties* through Camel's `camel.beans.` syntax — here a 
file-backed register that survives restarts (camel-core, no extra dependency). 
Use a name without dashes; Camel normalises dashed `camel.beans.` keys to 
camelCase:
+
+[source,properties]
+----
+camel.beans.productsRegister=#class:org.apache.camel.support.processor.idempotent.FileIdempotentRepository
+camel.beans.productsRegister.fileStore=/var/data/ingest/register.dat
+camel.beans.productsRegister.cacheSize=100000
+# keys beyond this size are dropped oldest-first and their files re-ingest (32 
MB here)
+camel.beans.productsRegister.maxFileStoreSize=33554432
+----
+
+`#class:` beans are created reflectively. For native mode the extension 
registers the camel-core repositories shown here and every 
`IdempotentRepository` implementation found in the Jandex index — application 
classes always, component-provided ones through their jar's index. A class from 
a jar without an index needs `@RegisterForReflection(targets = ...)` or a 
`quarkus.index-dependency.*` entry.
+
+*Defined as a CDI producer* — needed when construction takes other beans. A 
JDBC register also deduplicates across instances: reliably for consumer-fed 
pipelines keyed on the document id, for directory pipelines only when all 
instances see identical paths and modification times:
+
+[source,java]
+----
+@Produces @Singleton @Named("productsRegister")
+IdempotentRepository productsRegister(DataSource dataSource) {
+    return new JdbcMessageIdRepository(dataSource, "ingest-products"); // 
camel-quarkus-sql
+}
+----
+
+More repository types — Caffeine, Infinispan, MongoDB, Cassandra, Hazelcast, 
Kafka and others, each provided by its component's extension — along with the 
pattern's details are covered in the 
xref:{cq-camel-components}:eips:idempotentConsumer-eip.adoc[Idempotent Consumer 
EIP guide].
+
+Sizing. An in-memory register smaller than the directory evicts keys and 
re-ingests those files during normal operation — hence the 100,000 default. 
`FileIdempotentRepository` falls back to its file store on a cache miss, so 
`cacheSize` is a performance setting; the correctness limit is 
`maxFileStoreSize`: the default of about 1 MB holds roughly 10–17 thousand of 
these keys, beyond it the oldest 1000 are dropped with a warning. A JDBC 
register has no size limit. Every edit adds a new key [...]
+
+The register cannot be switched off: the consumer leaves files in place 
(`noop`), so without a register every poll would re-ingest the directory. To 
force a full re-ingest, restart (default register) or clear the persistent one. 
A pipeline that should consume its source — move or delete files after 
ingestion — uses `source.uri` (for example 
`file:/inbox?delete=true&idempotent=false` with 
`source.document-id=CamelFileName`), where all consumer options are available.
+
+On a consumer-fed pipeline (`source.uri` or `@Ingest`), a configured register 
deduplicates deliveries by document id: a redelivered record or re-listed 
object ingests once, and a request-reply caller receives `skipped` instead of 
`ingested`. First write wins per id — an update with the same id is skipped, 
not replaced; streams that carry updates need a version-aware 
`source.document-id`. Only a delivery that wrote segments claims its id: a 
blank document answers `empty` and releases the  [...]
+
 === Other sources, declared in Java
 
 Any Camel consumer can feed a pipeline — the roughly 300 components, each with 
its own options and its own documentation. Such a pipeline is declared in Java 
with `@Ingest` and the Camel Endpoint DSL:
diff --git 
a/extensions/langchain4j-ingest/runtime/src/main/java/org/apache/camel/quarkus/component/langchain4j/ingest/IngestComponentPresence.java
 
b/extensions/langchain4j-ingest/runtime/src/main/java/org/apache/camel/quarkus/component/langchain4j/ingest/IngestComponentPresence.java
index 1e7eb3c220..7fb10e8ae9 100644
--- 
a/extensions/langchain4j-ingest/runtime/src/main/java/org/apache/camel/quarkus/component/langchain4j/ingest/IngestComponentPresence.java
+++ 
b/extensions/langchain4j-ingest/runtime/src/main/java/org/apache/camel/quarkus/component/langchain4j/ingest/IngestComponentPresence.java
@@ -61,7 +61,9 @@ final class IngestComponentPresence {
                 continue;
             }
             if (external != null && (external.source().directory().isPresent()
-                    || external.source().documentId().isPresent())) {
+                    || external.source().documentId().isPresent()
+                    || external.source().idempotentRepository().isPresent()
+                    || external.source().idempotentRepositoryAutoCreate())) {
                 // the route builder refuses this conflict with its own error; 
invoking the
                 // method here first would change which failure the user sees
                 continue;
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 498a1ad499..1461e16f52 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
@@ -31,11 +31,13 @@ import jakarta.enterprise.inject.Any;
 import jakarta.enterprise.inject.Instance;
 import jakarta.enterprise.inject.literal.NamedLiteral;
 import jakarta.inject.Inject;
+import org.apache.camel.CamelContextAware;
 import org.apache.camel.Exchange;
 import org.apache.camel.Expression;
 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.builder.ExpressionBuilder;
 import 
org.apache.camel.support.processor.idempotent.MemoryIdempotentRepository;
 import org.apache.camel.util.URISupport;
@@ -71,6 +73,9 @@ public class IngestRoutes extends RouteBuilder {
     @Any
     Instance<EmbeddingModel> modelCandidates;
 
+    /** Exchange property carrying the resolved document id. */
+    private static final String DOCUMENT_ID_PROPERTY = 
"CamelQuarkusIngestDocumentId";
+
     @Override
     public void configure() {
         // a pipeline may be declared entirely through runtime properties - 
the documented
@@ -136,7 +141,9 @@ public class IngestRoutes extends RouteBuilder {
         // (source.recursive cannot be told apart from its default, so it 
alone goes undetected -
         // Source.recursive() is its builder twin)
         if (external != null && (external.source().directory().isPresent()
-                || external.source().documentId().isPresent())) {
+                || external.source().documentId().isPresent()
+                || external.source().idempotentRepository().isPresent()
+                || external.source().idempotentRepositoryAutoCreate())) {
             throw new IllegalStateException("Ingestion pipeline '" + name + "' 
is declared in Java, so its source "
                     + "comes from the @Ingest method. Remove 
quarkus.camel.langchain4j.ingest." + name + ".source.* , or "
                     + "declare the pipeline in configuration instead.");
@@ -171,15 +178,24 @@ public class IngestRoutes extends RouteBuilder {
         // is honoured ahead of noop and would delete the user's documents 
after reading them.
         // noop leaves the documents where they are (a knowledge base reads 
its source, it does
         // 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 - the truncation would be permanent, since idempotent keys on 
the path. The register
-        // is sized explicitly: Camel's default caps at 1000 entries, and 
beyond that eviction
-        // would re-ingest a large directory steadily during normal operation, 
not just on restart
+        // changed read lock waits for a file still being copied in rather 
than embedding half
+        // of it
         Expression documentId = documentIdExpression(runtime, 
Exchange.FILE_NAME);
-        from(file(directory)
+        maybeAutoCreateRepository(name, runtime);
+        String repositoryName = runtime == null ? null : 
runtime.source().idempotentRepository().orElse(null);
+        var endpoint = file(directory)
                 .noop(true)
-                .idempotent(true)
-                
.idempotentRepository(MemoryIdempotentRepository.memoryIdempotentRepository(100_000))
+                .idempotent(true);
+        if (repositoryName != null) {
+            endpoint.idempotentRepository(resolveRepository(name, 
repositoryName));
+        } else {
+            // in-memory default, sized above Camel's 1000-entry cap so 
eviction does not
+            // re-ingest large directories; lost on restart
+            
endpoint.idempotentRepository(MemoryIdempotentRepository.memoryIdempotentRepository(100_000));
+        }
+        from(endpoint
+                // an edited file gets a new key and re-ingests; old segments 
remain (append)
+                
.idempotentKey("${file:absolute.path}:${file:modified}:${file:size}")
                 .recursive(runtime.source().recursive())
                 .readLock("changed")
                 .charset(StandardCharsets.UTF_8.name()))
@@ -204,20 +220,58 @@ public class IngestRoutes extends RouteBuilder {
     private void configureEndpointSource(String name, String uri,
             IngestRunTimeConfig.PipelineRunTimeConfig runtime, IngestService 
service) {
         Expression documentId = documentIdExpression(runtime, 
IngestHeaders.DOCUMENT_ID);
+        maybeAutoCreateRepository(name, runtime);
+        String repositoryName = runtime == null ? null : 
runtime.source().idempotentRepository().orElse(null);
+        if (repositoryName == null) {
+            from(uri)
+                    .routeId(routeId(name))
+                    .process(exchange -> {
+                        String id = requireDocumentId(name, documentId, 
exchange);
+                        exchange.getIn().setBody(service.ingest(id, 
exchange.getIn().getBody(String.class)));
+                    });
+            return;
+        }
+        // duplicates skip the block and the tail processor answers SKIPPED; 
first write wins
+        // per id. The EIP keys on the validated id property, evaluating the 
expression once
+        IdempotentRepository repository = resolveRepository(name, 
repositoryName);
         from(uri)
                 .routeId(routeId(name))
+                .process(exchange -> exchange.setProperty(DOCUMENT_ID_PROPERTY,
+                        requireDocumentId(name, documentId, exchange)))
+                .idempotentConsumer(exchangeProperty(DOCUMENT_ID_PROPERTY), 
repository)
+                .process(exchange -> {
+                    String id = (String) 
exchange.getProperty(DOCUMENT_ID_PROPERTY);
+                    IngestResult result = service.ingest(id, 
exchange.getIn().getBody(String.class));
+                    if (result.outcome() == IngestResult.Outcome.EMPTY) {
+                        // a blank document wrote nothing, so it must not keep 
the eager claim
+                        // on the id - a later, populated delivery under the 
same id would be
+                        // answered SKIPPED. The completion-time confirm() of 
the removed key
+                        // is a no-op in the memory, file and JDBC 
repositories alike
+                        repository.remove(id);
+                    }
+                    exchange.getIn().setBody(result);
+                })
+                .end()
                 .process(exchange -> {
-                    String id = documentId.evaluate(exchange, String.class);
-                    if (id == null) {
-                        throw new IllegalArgumentException("Ingestion pipeline 
'" + name + "': no document id. "
-                                + "Set the " + IngestHeaders.DOCUMENT_ID + " 
header, or point "
-                                + "quarkus.camel.langchain4j.ingest." + name + 
".source.document-id at where the "
-                                + "consumer puts it.");
+                    if (exchange.getProperty(Exchange.DUPLICATE_MESSAGE, 
false, Boolean.class)) {
+                        exchange.getIn().setBody(new IngestResult(name,
+                                (String) 
exchange.getProperty(DOCUMENT_ID_PROPERTY), 0,
+                                IngestResult.Outcome.SKIPPED));
                     }
-                    exchange.getIn().setBody(service.ingest(id, 
exchange.getIn().getBody(String.class)));
                 });
     }
 
+    private static String requireDocumentId(String name, Expression 
documentId, Exchange exchange) {
+        String id = documentId.evaluate(exchange, String.class);
+        if (id == null) {
+            throw new IllegalArgumentException("Ingestion pipeline '" + name + 
"': no document id. "
+                    + "Set the " + IngestHeaders.DOCUMENT_ID + " header, or 
point "
+                    + "quarkus.camel.langchain4j.ingest." + name + 
".source.document-id at where the "
+                    + "consumer puts it.");
+        }
+        return id;
+    }
+
     private Expression 
documentIdExpression(IngestRunTimeConfig.PipelineRunTimeConfig runtime,
             String defaultHeader) {
         String configured = runtime == null ? null : 
runtime.source().documentId().orElse(null);
@@ -265,6 +319,48 @@ public class IngestRoutes extends RouteBuilder {
      * discovered first. A raw-typed registry search cannot serve here: it 
never matches a bean
      * typed {@code EmbeddingStore<TextSegment>}.
      */
+    /**
+     * Binds an in-memory register under the configured name, unless a bean 
with that name
+     * already exists — {@code camel.beans.*} beans are bound before route 
builders run, so both
+     * they and CDI beans are visible here and win.
+     */
+    private void maybeAutoCreateRepository(String name, 
IngestRunTimeConfig.PipelineRunTimeConfig runtime) {
+        if (runtime == null || 
!runtime.source().idempotentRepositoryAutoCreate()) {
+            return;
+        }
+        String repositoryName = 
runtime.source().idempotentRepository().orElse(null);
+        if (repositoryName == null) {
+            throw new IllegalStateException("Ingestion pipeline '" + name
+                    + "' sets source.idempotent-repository-auto-create but no "
+                    + "source.idempotent-repository name to create the 
register under.");
+        }
+        if (getContext().getRegistry().lookupByNameAndType(repositoryName, 
IdempotentRepository.class) != null) {
+            LOG.infof("Ingestion pipeline '%s': idempotent repository '%s' 
already exists, auto-create skipped",
+                    name, repositoryName);
+            return;
+        }
+        getContext().getRegistry().bind(repositoryName,
+                
MemoryIdempotentRepository.memoryIdempotentRepository(100_000));
+    }
+
+    /**
+     * Resolves the configured register from the Camel registry: CDI 
producers, camel.beans
+     * definitions and auto-created registers alike. By name only — the 
application may hold
+     * unrelated idempotent repositories.
+     */
+    private IdempotentRepository resolveRepository(String name, String 
repositoryName) {
+        IdempotentRepository repository = 
getContext().getRegistry().lookupByNameAndType(repositoryName,
+                IdempotentRepository.class);
+        if (repository == null) {
+            throw new IllegalStateException("Ingestion pipeline '" + name + "' 
references idempotent repository '"
+                    + repositoryName + "' but no such bean exists");
+        }
+        // a CDI-produced repository does not pass through the registry's bind 
hook, so a
+        // CamelContextAware implementation would otherwise run contextless
+        CamelContextAware.trySetCamelContext(repository, getContext());
+        return repository;
+    }
+
     private <T> T resolve(String name, Instance<T> candidates, String 
configured, String what, String property) {
         if (configured != null) {
             Instance<T> named = candidates.select(NamedLiteral.of(configured));
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 17be745851..ce84df44c2 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
@@ -69,6 +69,22 @@ public interface IngestRunTimeConfig {
             @WithDefault("true")
             boolean recursive();
 
+            /**
+             * Name of the `IdempotentRepository` bean remembering already 
ingested documents,
+             * instead of the built-in in-memory one (100 000 keys, lost on 
restart). Looked up
+             * by name only. On a pipeline consuming from a component it 
deduplicates deliveries
+             * by document id, first write wins.
+             */
+            Optional<String> idempotentRepository();
+
+            /**
+             * When `true`, an in-memory register (100 000 keys) is created 
and bound under the
+             * `idempotent-repository` name, unless a bean with that name 
exists — the existing
+             * bean wins.
+             */
+            @WithDefault("false")
+            boolean idempotentRepositoryAutoCreate();
+
             /**
              * Where the document id lives in the exchange the consumer 
delivers: normally the
              * name of a header, such as `CamelAwsS3Key` for an S3 consumer or 
`CamelKafkaKey` for
diff --git 
a/extensions/langchain4j-ingest/runtime/src/main/java/org/apache/camel/quarkus/component/langchain4j/ingest/Source.java
 
b/extensions/langchain4j-ingest/runtime/src/main/java/org/apache/camel/quarkus/component/langchain4j/ingest/Source.java
index dfac49b82b..72d757758a 100644
--- 
a/extensions/langchain4j-ingest/runtime/src/main/java/org/apache/camel/quarkus/component/langchain4j/ingest/Source.java
+++ 
b/extensions/langchain4j-ingest/runtime/src/main/java/org/apache/camel/quarkus/component/langchain4j/ingest/Source.java
@@ -49,6 +49,8 @@ public final class Source {
     private String directory;
     private String uri;
     private String documentId;
+    private String idempotentRepository;
+    private boolean idempotentRepositoryAutoCreate;
     private boolean recursive = true;
 
     private Source(String type) {
@@ -109,6 +111,18 @@ public final class Source {
         return this;
     }
 
+    /** Name of the {@code IdempotentRepository} bean; twin of {@code 
source.idempotent-repository}. */
+    public Source idempotentRepository(String beanName) {
+        this.idempotentRepository = requireText(beanName, 
"idempotentRepository");
+        return this;
+    }
+
+    /** Twin of {@code source.idempotent-repository-auto-create}. */
+    public Source idempotentRepositoryAutoCreate(boolean autoCreate) {
+        this.idempotentRepositoryAutoCreate = autoCreate;
+        return this;
+    }
+
     /** A null or blank value here would surface much later as an obscure 
Camel error. */
     private static String requireText(String value, String what) {
         if (value == null || value.isBlank()) {
@@ -143,6 +157,16 @@ public final class Source {
             public Optional<String> documentId() {
                 return Optional.ofNullable(documentId);
             }
+
+            @Override
+            public Optional<String> idempotentRepository() {
+                return Optional.ofNullable(idempotentRepository);
+            }
+
+            @Override
+            public boolean idempotentRepositoryAutoCreate() {
+                return idempotentRepositoryAutoCreate;
+            }
         };
     }
 }
diff --git 
a/extensions/langchain4j-ingest/runtime/src/main/java/org/apache/camel/quarkus/component/langchain4j/ingest/core/IngestResult.java
 
b/extensions/langchain4j-ingest/runtime/src/main/java/org/apache/camel/quarkus/component/langchain4j/ingest/core/IngestResult.java
index 13ebbcfc07..45e4d86799 100644
--- 
a/extensions/langchain4j-ingest/runtime/src/main/java/org/apache/camel/quarkus/component/langchain4j/ingest/core/IngestResult.java
+++ 
b/extensions/langchain4j-ingest/runtime/src/main/java/org/apache/camel/quarkus/component/langchain4j/ingest/core/IngestResult.java
@@ -27,7 +27,9 @@ public record IngestResult(String pipeline, String 
documentId, int segmentsWritt
         /** Segments written. */
         INGESTED,
         /** Blank document, nothing written. */
-        EMPTY;
+        EMPTY,
+        /** Already ingested under the same key, nothing written. */
+        SKIPPED;
 
         /** The stable wire/log form. */
         public String label() {
diff --git a/integration-tests/langchain4j-ingest/pom.xml 
b/integration-tests/langchain4j-ingest/pom.xml
index 5cd7bb4ef5..125b77664e 100644
--- a/integration-tests/langchain4j-ingest/pom.xml
+++ b/integration-tests/langchain4j-ingest/pom.xml
@@ -59,6 +59,14 @@
             <groupId>org.apache.camel.quarkus</groupId>
             <artifactId>camel-quarkus-kafka</artifactId>
         </dependency>
+        <dependency>
+            <groupId>org.apache.camel.quarkus</groupId>
+            <artifactId>camel-quarkus-sql</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>io.quarkus</groupId>
+            <artifactId>quarkus-jdbc-h2</artifactId>
+        </dependency>
 
         <!-- test dependencies -->
         <dependency>
@@ -203,6 +211,19 @@
                         </exclusion>
                     </exclusions>
                 </dependency>
+                <dependency>
+                    <groupId>org.apache.camel.quarkus</groupId>
+                    <artifactId>camel-quarkus-sql-deployment</artifactId>
+                    <version>${project.version}</version>
+                    <type>pom</type>
+                    <scope>test</scope>
+                    <exclusions>
+                        <exclusion>
+                            <groupId>*</groupId>
+                            <artifactId>*</artifactId>
+                        </exclusion>
+                    </exclusions>
+                </dependency>
             </dependencies>
         </profile>
     </profiles>
diff --git 
a/integration-tests/langchain4j-ingest/src/main/java/org/apache/camel/quarkus/component/langchain4j/ingest/it/DeterministicEmbeddingModel.java
 
b/integration-tests/langchain4j-ingest/src/main/java/org/apache/camel/quarkus/component/langchain4j/ingest/it/DeterministicEmbeddingModel.java
index bee18f2e12..0625978775 100644
--- 
a/integration-tests/langchain4j-ingest/src/main/java/org/apache/camel/quarkus/component/langchain4j/ingest/it/DeterministicEmbeddingModel.java
+++ 
b/integration-tests/langchain4j-ingest/src/main/java/org/apache/camel/quarkus/component/langchain4j/ingest/it/DeterministicEmbeddingModel.java
@@ -33,6 +33,9 @@ import dev.langchain4j.model.output.Response;
  */
 public class DeterministicEmbeddingModel implements EmbeddingModel {
 
+    /** Test hook: a segment carrying this marker fails the embedding, so the 
exchange fails. */
+    public static final String POISON = "POISON-PILL";
+
     private final int dimension;
 
     public DeterministicEmbeddingModel(int dimension) {
@@ -43,6 +46,9 @@ public class DeterministicEmbeddingModel implements 
EmbeddingModel {
     public Response<List<Embedding>> embedAll(List<TextSegment> segments) {
         List<Embedding> embeddings = new ArrayList<>(segments.size());
         for (TextSegment segment : segments) {
+            if (segment.text().contains(POISON)) {
+                throw new IllegalStateException("test-induced embedding 
failure: " + POISON);
+            }
             embeddings.add(embeddingFor(segment.text()));
         }
         return Response.from(embeddings);
diff --git 
a/integration-tests/langchain4j-ingest/src/main/java/org/apache/camel/quarkus/component/langchain4j/ingest/it/IngestItProducers.java
 
b/integration-tests/langchain4j-ingest/src/main/java/org/apache/camel/quarkus/component/langchain4j/ingest/it/IngestItProducers.java
index 6ba00152ed..c61430afbc 100644
--- 
a/integration-tests/langchain4j-ingest/src/main/java/org/apache/camel/quarkus/component/langchain4j/ingest/it/IngestItProducers.java
+++ 
b/integration-tests/langchain4j-ingest/src/main/java/org/apache/camel/quarkus/component/langchain4j/ingest/it/IngestItProducers.java
@@ -16,6 +16,8 @@
  */
 package org.apache.camel.quarkus.component.langchain4j.ingest.it;
 
+import javax.sql.DataSource;
+
 import dev.langchain4j.data.segment.TextSegment;
 import dev.langchain4j.model.embedding.EmbeddingModel;
 import dev.langchain4j.store.embedding.EmbeddingStore;
@@ -24,6 +26,9 @@ import jakarta.enterprise.context.ApplicationScoped;
 import jakarta.enterprise.inject.Produces;
 import jakarta.inject.Named;
 import jakarta.inject.Singleton;
+import org.apache.camel.processor.idempotent.jdbc.JdbcMessageIdRepository;
+import org.apache.camel.spi.IdempotentRepository;
+import 
org.apache.camel.support.processor.idempotent.MemoryIdempotentRepository;
 
 @ApplicationScoped
 public class IngestItProducers {
@@ -45,8 +50,8 @@ public class IngestItProducers {
 
     @Produces
     @Singleton
-    @Named("built-store")
-    EmbeddingStore<TextSegment> builtStore() {
+    @Named("datasheets-store")
+    EmbeddingStore<TextSegment> datasheetsStore() {
         return new InMemoryEmbeddingStore<>();
     }
 
@@ -64,10 +69,33 @@ public class IngestItProducers {
         return new InMemoryEmbeddingStore<>();
     }
 
+    @Produces
+    @Singleton
+    @Named("jdbc-store")
+    EmbeddingStore<TextSegment> jdbcStore() {
+        return new InMemoryEmbeddingStore<>();
+    }
+
     @Produces
     @Singleton
     @Named("test-model")
     EmbeddingModel embeddingModel() {
         return new DeterministicEmbeddingModel(64);
     }
+
+    // the custom pipeline's register; auto-create is also set, so this 
existing bean must win
+    @Produces
+    @Singleton
+    @Named("test-register")
+    IdempotentRepository testRegister() {
+        return MemoryIdempotentRepository.memoryIdempotentRepository(1000);
+    }
+
+    // the JDBC register from the documentation's CDI example, over the Dev 
Services H2 datasource
+    @Produces
+    @Singleton
+    @Named("jdbcRegister")
+    IdempotentRepository jdbcRegister(DataSource dataSource) {
+        return new JdbcMessageIdRepository(dataSource, "ingest-jdbc");
+    }
 }
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 f6f7e4dee7..3d8c3b8fad 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
@@ -33,10 +33,12 @@ import jakarta.ws.rs.PathParam;
 import jakarta.ws.rs.Produces;
 import jakarta.ws.rs.QueryParam;
 import jakarta.ws.rs.core.MediaType;
+import org.apache.camel.CamelContext;
 import org.apache.camel.ProducerTemplate;
 import org.apache.camel.quarkus.component.langchain4j.ingest.IngestHeaders;
 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.eclipse.microprofile.config.inject.ConfigProperty;
 
 @jakarta.ws.rs.Path("/langchain4j-ingest")
@@ -55,8 +57,8 @@ public class IngestResource {
     EmbeddingStore<TextSegment> customStore;
 
     @Inject
-    @Named("built-store")
-    EmbeddingStore<TextSegment> builtStore;
+    @Named("datasheets-store")
+    EmbeddingStore<TextSegment> datasheetsStore;
 
     @Inject
     @Named("s3-store")
@@ -69,9 +71,22 @@ public class IngestResource {
     @Inject
     ProducerTemplate producerTemplate;
 
+    @Inject
+    CamelContext camelContext;
+
     @ConfigProperty(name = "ingest.test.directory")
     String directory;
 
+    /** Asserts a key was committed; registry lookup by name, the same way the 
pipelines resolve. */
+    @GET
+    @jakarta.ws.rs.Path("/register-contains")
+    @Produces(MediaType.TEXT_PLAIN)
+    public boolean registerContains(@QueryParam("repo") String repo, 
@QueryParam("key") String key) {
+        IdempotentRepository repository = 
camelContext.getRegistry().lookupByNameAndType(repo,
+                IdempotentRepository.class);
+        return repository != null && repository.contains(key);
+    }
+
     /** Writes a document into the watched directory — app-side, so native 
mode shares the path. */
     @POST
     @jakarta.ws.rs.Path("/file/{name}")
@@ -88,7 +103,7 @@ public class IngestResource {
     public List<SearchHit> search(@QueryParam("q") String query, 
@QueryParam("store") String storeName) {
         EmbeddingStore<TextSegment> store = switch (storeName == null ? 
"products" : storeName) {
         case "custom" -> customStore;
-        case "built" -> builtStore;
+        case "datasheets" -> datasheetsStore;
         case "s3" -> s3Store;
         case "events" -> eventsStore;
         default -> productsStore;
@@ -113,14 +128,15 @@ public class IngestResource {
     public record SearchHit(String text, String pipeline, String documentId) {
     }
 
-    /** Feeds a push pipeline through its Camel consumer URI. */
+    /** Feeds a pipeline synchronously; the reply carries the outcome, so 
tests can assert skipped and failures. */
     @POST
     @jakarta.ws.rs.Path("/feed/{pipeline}/{documentId:.+}")
     @Consumes(MediaType.TEXT_PLAIN)
     @Produces(MediaType.TEXT_PLAIN)
     public String feed(@PathParam("pipeline") String pipeline, 
@PathParam("documentId") String documentId,
             String content) {
-        String uri = "built".equals(pipeline) ? "direct:built-source" : 
"direct:custom-source";
+        // every consumer-fed test pipeline reads direct:<pipeline>-feed
+        String uri = "direct:" + pipeline + "-feed";
         IngestResult result = producerTemplate.requestBodyAndHeader(uri, 
content, IngestHeaders.DOCUMENT_ID,
                 documentId, IngestResult.class);
         return result.outcome().label();
diff --git 
a/integration-tests/langchain4j-ingest/src/main/java/org/apache/camel/quarkus/component/langchain4j/ingest/it/ItBuilderPipelines.java
 
b/integration-tests/langchain4j-ingest/src/main/java/org/apache/camel/quarkus/component/langchain4j/ingest/it/ItBuilderPipelines.java
index 0124e61b6c..aa29ee04d6 100644
--- 
a/integration-tests/langchain4j-ingest/src/main/java/org/apache/camel/quarkus/component/langchain4j/ingest/it/ItBuilderPipelines.java
+++ 
b/integration-tests/langchain4j-ingest/src/main/java/org/apache/camel/quarkus/component/langchain4j/ingest/it/ItBuilderPipelines.java
@@ -48,8 +48,11 @@ public class ItBuilderPipelines {
 
     @Ingest("datasheets")
     IngestPipeline datasheets() {
-        return IngestPipeline.from(Source.endpoint(direct("built-source")))
-                .embeddingStore("built-store")
+        // the auto-created register: named, but no bean is defined anywhere 
for it
+        return IngestPipeline.from(Source.endpoint(direct("datasheets-feed"))
+                .idempotentRepository("datasheetsRegister")
+                .idempotentRepositoryAutoCreate(true))
+                .embeddingStore("datasheets-store")
                 .embeddingModel("test-model")
                 .splitter(120, 20);
     }
diff --git 
a/integration-tests/langchain4j-ingest/src/main/resources/application.properties
 
b/integration-tests/langchain4j-ingest/src/main/resources/application.properties
index e01734c18b..f8caf3a942 100644
--- 
a/integration-tests/langchain4j-ingest/src/main/resources/application.properties
+++ 
b/integration-tests/langchain4j-ingest/src/main/resources/application.properties
@@ -26,18 +26,35 @@ 
quarkus.camel.langchain4j.ingest.products.embedding-store=products-store
 quarkus.camel.langchain4j.ingest.products.embedding-model=test-model
 quarkus.camel.langchain4j.ingest.products.max-segment-size=120
 quarkus.camel.langchain4j.ingest.products.max-overlap-size=20
+# register defined purely in properties via camel.beans; the name has no 
dashes because Camel
+# normalises dashed camel.beans keys to camelCase
+camel.beans.propertiesRegister=\#class\:org.apache.camel.support.processor.idempotent.MemoryIdempotentRepository
+camel.beans.propertiesRegister.cacheSize=5000
+quarkus.camel.langchain4j.ingest.products.source.idempotent-repository=propertiesRegister
 
-# endpoint source: the escape hatch — any Camel consumer URI feeds the 
pipeline. The document-id
-# exercises the $simple{...} form, the one MicroProfile Config's own ${...} 
expansion leaves
-# untouched; it reads the same header the pipeline would fall back to anyway
-quarkus.camel.langchain4j.ingest.custom.source.uri=direct:custom-source
+# 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.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
 quarkus.camel.langchain4j.ingest.custom.embedding-store=custom-store
 quarkus.camel.langchain4j.ingest.custom.embedding-model=test-model
 quarkus.camel.langchain4j.ingest.custom.max-segment-size=120
 quarkus.camel.langchain4j.ingest.custom.max-overlap-size=20
 
 
+# H2 through Dev Services (a separate process, no container) backs the JDBC 
register
+quarkus.datasource.db-kind=h2
+
+quarkus.camel.langchain4j.ingest.jdbcdocs.source.uri=direct:jdbcdocs-feed
+quarkus.camel.langchain4j.ingest.jdbcdocs.source.idempotent-repository=jdbcRegister
+quarkus.camel.langchain4j.ingest.jdbcdocs.embedding-store=jdbc-store
+quarkus.camel.langchain4j.ingest.jdbcdocs.embedding-model=test-model
+quarkus.camel.langchain4j.ingest.jdbcdocs.max-segment-size=120
+quarkus.camel.langchain4j.ingest.jdbcdocs.max-overlap-size=20
+
 # s3docs and events are declared in Java (ItBuilderPipelines) through the 
Endpoint DSL; they stay
 # off until MinioTestResource / IngestKafkaTestResource provide a store and a 
broker
 quarkus.camel.langchain4j.ingest.s3docs.enabled=false
diff --git 
a/extensions/langchain4j-ingest/runtime/src/main/java/org/apache/camel/quarkus/component/langchain4j/ingest/core/IngestResult.java
 
b/integration-tests/langchain4j-ingest/src/test/java/org/apache/camel/quarkus/component/langchain4j/ingest/it/Langchain4jIngestIdempotentIT.java
similarity index 60%
copy from 
extensions/langchain4j-ingest/runtime/src/main/java/org/apache/camel/quarkus/component/langchain4j/ingest/core/IngestResult.java
copy to 
integration-tests/langchain4j-ingest/src/test/java/org/apache/camel/quarkus/component/langchain4j/ingest/it/Langchain4jIngestIdempotentIT.java
index 13ebbcfc07..94c9a7041c 100644
--- 
a/extensions/langchain4j-ingest/runtime/src/main/java/org/apache/camel/quarkus/component/langchain4j/ingest/core/IngestResult.java
+++ 
b/integration-tests/langchain4j-ingest/src/test/java/org/apache/camel/quarkus/component/langchain4j/ingest/it/Langchain4jIngestIdempotentIT.java
@@ -14,24 +14,10 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.camel.quarkus.component.langchain4j.ingest.core;
+package org.apache.camel.quarkus.component.langchain4j.ingest.it;
 
-import java.util.Locale;
+import io.quarkus.test.junit.QuarkusIntegrationTest;
 
-/**
- * Outcome of one ingestion.
- */
-public record IngestResult(String pipeline, String documentId, int 
segmentsWritten, Outcome outcome) {
-
-    public enum Outcome {
-        /** Segments written. */
-        INGESTED,
-        /** Blank document, nothing written. */
-        EMPTY;
-
-        /** The stable wire/log form. */
-        public String label() {
-            return name().toLowerCase(Locale.ROOT);
-        }
-    }
+@QuarkusIntegrationTest
+class Langchain4jIngestIdempotentIT extends Langchain4jIngestIdempotentTest {
 }
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
new file mode 100644
index 0000000000..74a98d5c20
--- /dev/null
+++ 
b/integration-tests/langchain4j-ingest/src/test/java/org/apache/camel/quarkus/component/langchain4j/ingest/it/Langchain4jIngestIdempotentTest.java
@@ -0,0 +1,181 @@
+/*
+ * 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.quarkus.component.langchain4j.ingest.it;
+
+import java.util.concurrent.TimeUnit;
+
+import io.quarkus.test.junit.QuarkusTest;
+import io.restassured.RestAssured;
+import io.restassured.http.ContentType;
+import org.awaitility.Awaitility;
+import org.hamcrest.Matchers;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * The idempotent repository: an edited file re-ingests under a new key (path, 
mtime, size) with the
+ * previous segments kept, and a consumer-fed pipeline deduplicates by 
document id, first write
+ * wins.
+ */
+@QuarkusTest
+class Langchain4jIngestIdempotentTest {
+
+    @Test
+    void editedFileReIngestsAndAppends() {
+        Langchain4jIngestTest.write("versioned.txt", "The RHO-1 relay ships as 
rev ALPHA.");
+        Awaitility.await().atMost(30, TimeUnit.SECONDS).pollInterval(500, 
TimeUnit.MILLISECONDS)
+                .untilAsserted(() -> assertNotNull(
+                        Langchain4jIngestTest.hit("Which rev ships?", null, 
"rev ALPHA"),
+                        "the first version must be ingested"));
+
+        // different length guarantees a new key even within the mtime 
granularity
+        Langchain4jIngestTest.write("versioned.txt", "The RHO-1 relay now 
ships as rev BRAVO, improved.");
+        Awaitility.await().atMost(30, TimeUnit.SECONDS).pollInterval(500, 
TimeUnit.MILLISECONDS)
+                .untilAsserted(() -> assertNotNull(
+                        Langchain4jIngestTest.hit("Which rev ships?", null, 
"rev BRAVO"),
+                        "the edited version must be re-ingested under its new 
key"));
+
+        // append mode: the first version's segments were not replaced
+        assertNotNull(Langchain4jIngestTest.hit("Which rev ships?", null, "rev 
ALPHA"),
+                "append mode keeps the previous version's segments");
+    }
+
+    @Test
+    void duplicateDocumentIdIsSkipped() {
+        String first = RestAssured.given().contentType(ContentType.TEXT)
+                .body("The KAPPA-4 sensor reads humidity.")
+                .post("/langchain4j-ingest/feed/custom/dedup/kappa.txt")
+                .then().statusCode(200).extract().asString();
+        assertEquals("ingested", first);
+
+        // same document id again: the register skips it, first write wins
+        String second = RestAssured.given().contentType(ContentType.TEXT)
+                .body("The KAPPA-4 sensor allegedly reads pressure now.")
+                .post("/langchain4j-ingest/feed/custom/dedup/kappa.txt")
+                .then().statusCode(200).extract().asString();
+        assertEquals("skipped", second);
+
+        // the store holds the first write only, and the register committed 
the key
+        assertNotNull(Langchain4jIngestTest.hit("What does the sensor read?", 
"custom", "humidity"));
+        assertTrue(Langchain4jIngestTest.hits("What does the sensor read?", 
"custom").stream()
+                .noneMatch(hit -> hit.get("text").contains("pressure")),
+                "the duplicate delivery must not have been ingested");
+        RestAssured.given()
+                .queryParam("repo", "test-register")
+                .queryParam("key", "dedup/kappa.txt")
+                .get("/langchain4j-ingest/register-contains")
+                .then()
+                .statusCode(200)
+                .body(Matchers.is("true"));
+    }
+
+    /** A blank document releases its claim: only a delivery that wrote 
segments occupies the id. */
+    @Test
+    void emptyDeliveryDoesNotClaimTheId() {
+        String first = RestAssured.given().contentType(ContentType.TEXT)
+                .body("   ")
+                .post("/langchain4j-ingest/feed/custom/dedup/lambda.txt")
+                .then().statusCode(200).extract().asString();
+        assertEquals("empty", first);
+
+        // the claim was released, so the id is free again
+        RestAssured.given()
+                .queryParam("repo", "test-register")
+                .queryParam("key", "dedup/lambda.txt")
+                .get("/langchain4j-ingest/register-contains")
+                .then()
+                .statusCode(200)
+                .body(Matchers.is("false"));
+
+        RestAssured.given().contentType(ContentType.TEXT)
+                .body("The LAMBDA-2 valve regulates coolant flow.")
+                .post("/langchain4j-ingest/feed/custom/dedup/lambda.txt")
+                .then().statusCode(200)
+                .body(Matchers.is("ingested"));
+
+        assertNotNull(Langchain4jIngestTest.hit("What does the valve 
regulate?", "custom", "coolant"),
+                "the populated delivery must be ingested despite the earlier 
empty one");
+    }
+
+    /** A failed delivery releases the key ({@code removeOnFailure}), so the 
same id can retry. */
+    @Test
+    void failedDeliveryReleasesTheKey() {
+        RestAssured.given().contentType(ContentType.TEXT)
+                .body("The SIGMA-9 valve " + 
DeterministicEmbeddingModel.POISON + " fails to embed.")
+                .post("/langchain4j-ingest/feed/custom/release/sigma9.txt")
+                .then().statusCode(500);
+
+        String retry = RestAssured.given().contentType(ContentType.TEXT)
+                .body("The SIGMA-9 valve seals reliably.")
+                .post("/langchain4j-ingest/feed/custom/release/sigma9.txt")
+                .then().statusCode(200).extract().asString();
+        assertEquals("ingested", retry);
+    }
+
+    /** A JDBC register (H2-backed {@code JdbcMessageIdRepository}): dedup 
through real SQL. */
+    @Test
+    void jdbcRegisterDeduplicates() {
+        String first = RestAssured.given().contentType(ContentType.TEXT)
+                .body("The THETA-2 pump moves coolant.")
+                .post("/langchain4j-ingest/feed/jdbcdocs/dedup/theta.txt")
+                .then().statusCode(200).extract().asString();
+        assertEquals("ingested", first);
+
+        String second = RestAssured.given().contentType(ContentType.TEXT)
+                .body("The THETA-2 pump allegedly moves lava now.")
+                .post("/langchain4j-ingest/feed/jdbcdocs/dedup/theta.txt")
+                .then().statusCode(200).extract().asString();
+        assertEquals("skipped", second);
+
+        // contains() issues a SELECT against the H2 store, proving the key 
survived in SQL
+        String contains = RestAssured.given()
+                .queryParam("repo", "jdbcRegister")
+                .queryParam("key", "dedup/theta.txt")
+                .get("/langchain4j-ingest/register-contains")
+                .then().statusCode(200).extract().asString();
+        assertEquals("true", contains);
+    }
+
+    /**
+     * The {@code datasheets} builder pipeline names {@code 
datasheetsRegister} with auto-create and no
+     * bean defined anywhere: the auto-created register deduplicates too.
+     */
+    @Test
+    void autoCreatedRegisterDeduplicates() {
+        String first = RestAssured.given().contentType(ContentType.TEXT)
+                .body("The OMICRON-8 filter removes particles.")
+                .post("/langchain4j-ingest/feed/datasheets/dedup/omicron.txt")
+                .then().statusCode(200).extract().asString();
+        assertEquals("ingested", first);
+
+        String second = RestAssured.given().contentType(ContentType.TEXT)
+                .body("The OMICRON-8 filter allegedly removes odors now.")
+                .post("/langchain4j-ingest/feed/datasheets/dedup/omicron.txt")
+                .then().statusCode(200).extract().asString();
+        assertEquals("skipped", second);
+
+        String contains = RestAssured.given()
+                .queryParam("repo", "datasheetsRegister")
+                .queryParam("key", "dedup/omicron.txt")
+                .get("/langchain4j-ingest/register-contains")
+                .then().statusCode(200).extract().asString();
+        assertEquals("true", contains);
+    }
+}
diff --git 
a/integration-tests/langchain4j-ingest/src/test/java/org/apache/camel/quarkus/component/langchain4j/ingest/it/Langchain4jIngestTest.java
 
b/integration-tests/langchain4j-ingest/src/test/java/org/apache/camel/quarkus/component/langchain4j/ingest/it/Langchain4jIngestTest.java
index da1b0a6d86..05d1ecbe4f 100644
--- 
a/integration-tests/langchain4j-ingest/src/test/java/org/apache/camel/quarkus/component/langchain4j/ingest/it/Langchain4jIngestTest.java
+++ 
b/integration-tests/langchain4j-ingest/src/test/java/org/apache/camel/quarkus/component/langchain4j/ingest/it/Langchain4jIngestTest.java
@@ -86,10 +86,10 @@ class Langchain4jIngestTest {
     void builderDeclaredPipelineIngests() {
         RestAssured.given().contentType(ContentType.TEXT)
                 .body("The builder-declared pipeline handles the SIGMA-3 
datasheet.")
-                .post("/langchain4j-ingest/feed/built/datasheets/sigma.txt")
+                
.post("/langchain4j-ingest/feed/datasheets/datasheets/sigma.txt")
                 
.then().statusCode(200).body(org.hamcrest.Matchers.is("ingested"));
 
-        Map<String, String> hit = hit("Which datasheet is handled?", "built", 
"SIGMA-3");
+        Map<String, String> hit = hit("Which datasheet is handled?", 
"datasheets", "SIGMA-3");
         assertNotNull(hit, "the fed document must be ingested");
         // the metadata names the pipeline (@Ingest("datasheets")), not the 
store it writes to
         assertEquals("datasheets", hit.get("pipeline"));

Reply via email to