jamesnetherton commented on code in PR #9006:
URL: https://github.com/apache/camel-quarkus/pull/9006#discussion_r3782811670


##########
extensions-support/langchain4j/runtime/src/main/java/org/apache/camel/quarkus/component/support/langchain4j/tracker/jdbc/JdbcIngestionTracker.java:
##########
@@ -0,0 +1,288 @@
+/*
+ * 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.support.langchain4j.tracker.jdbc;
+
+import java.sql.Connection;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Timestamp;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+
+import javax.sql.DataSource;
+
+import 
org.apache.camel.quarkus.component.support.langchain4j.tracker.IngestionTracker;
+
+/**
+ * JDBC-backed {@link IngestionTracker}. Deliberately dialect-free SQL (works 
on PostgreSQL and H2):
+ * upserts are update-then-insert rather than vendor MERGE/ON CONFLICT.
+ *
+ * <p>
+ * <strong>Concurrency assumption: at most one writer per {@code (pipeline, 
documentId)} at a
+ * time.</strong> The update-then-insert is not atomic (each statement 
auto-commits on its own,
+ * there is no transaction), so two concurrent writers for the same row can 
both see the
+ * {@code UPDATE} affect zero rows and then both attempt the {@code INSERT}, 
and one fails on the
+ * {@code (pipeline, doc_id)} primary key. Wrapping the two statements in a 
transaction would not
+ * remove this race without either {@code SERIALIZABLE} isolation or a 
dialect-specific upsert,
+ * both at odds with staying dialect-free; callers (the sync pass runner 
processes one document
+ * at a time per pipeline) are relied upon to hold this invariant instead.
+ *
+ * <p>
+ * <strong>Status: Experimental.</strong> See {@link IngestionTracker}.
+ */
+public class JdbcIngestionTracker implements IngestionTracker {
+
+    static final String TABLE = "cq_ingestion_tracker";
+
+    private final DataSource dataSource;
+
+    public JdbcIngestionTracker(DataSource dataSource) {
+        this.dataSource = dataSource;
+    }
+
+    @Override
+    public void ensureSchema() {
+        String ddl = "CREATE TABLE IF NOT EXISTS " + TABLE + " ("
+                + "pipeline VARCHAR(128) NOT NULL, "
+                + "doc_id VARCHAR(512) NOT NULL, "
+                + "fingerprint VARCHAR(256), "
+                + "content_hash VARCHAR(64), "
+                + "segment_count INT DEFAULT 0 NOT NULL, "
+                + "intended_count INT DEFAULT 0 NOT NULL, "
+                + "status VARCHAR(16) NOT NULL, "
+                + "origin VARCHAR(16) DEFAULT 'source' NOT NULL, "
+                + "tombstone BOOLEAN DEFAULT FALSE NOT NULL, "
+                + "pinned BOOLEAN DEFAULT FALSE NOT NULL, "
+                + "updated_at TIMESTAMP, "
+                + "PRIMARY KEY (pipeline, doc_id))";
+        try (Connection connection = dataSource.getConnection()) {
+            connection.createStatement().execute(ddl);
+        } catch (SQLException e) {
+            throw new IllegalStateException(
+                    "Cannot create the ingestion tracker table '" + TABLE + 
"'. "
+                            + "mode=sync needs a working datasource — 
configure quarkus.datasource "
+                            + "(Dev Services provides one in dev mode) or use 
mode=append.",
+                    e);
+        }
+    }
+
+    private static final String ROW_COLUMNS = "fingerprint, content_hash, 
segment_count, intended_count, status, "
+            + "origin, tombstone, pinned";
+
+    @Override
+    public Optional<TrackerRow> read(String pipeline, String documentId) {
+        String sql = "SELECT " + ROW_COLUMNS + " FROM " + TABLE + " WHERE 
pipeline = ? AND doc_id = ?";
+        try (Connection connection = dataSource.getConnection();
+                PreparedStatement statement = 
connection.prepareStatement(sql)) {
+            statement.setString(1, pipeline);
+            statement.setString(2, documentId);
+            try (ResultSet resultSet = statement.executeQuery()) {
+                if (!resultSet.next()) {
+                    return Optional.empty();
+                }
+                return Optional.of(row(pipeline, documentId, resultSet));
+            }
+        } catch (SQLException e) {
+            throw new IllegalStateException("Tracker read failed for '" + 
documentId + "'", e);
+        }
+    }
+
+    @Override
+    public List<TrackerRow> listDocuments(String pipeline) {
+        String sql = "SELECT doc_id, " + ROW_COLUMNS + " FROM " + TABLE + " 
WHERE pipeline = ?";
+        try (Connection connection = dataSource.getConnection();
+                PreparedStatement statement = 
connection.prepareStatement(sql)) {
+            statement.setString(1, pipeline);
+            try (ResultSet resultSet = statement.executeQuery()) {
+                List<TrackerRow> rows = new ArrayList<>();
+                while (resultSet.next()) {
+                    String documentId = resultSet.getString(1);
+                    rows.add(new TrackerRow(pipeline, documentId,
+                            resultSet.getString(2), resultSet.getString(3),
+                            resultSet.getInt(4), resultSet.getInt(5), 
resultSet.getString(6),
+                            resultSet.getString(7), resultSet.getBoolean(8), 
resultSet.getBoolean(9)));
+                }
+                return rows;
+            }
+        } catch (SQLException e) {
+            throw new IllegalStateException("Tracker listing failed for 
pipeline '" + pipeline + "'", e);
+        }
+    }
+
+    private static TrackerRow row(String pipeline, String documentId, 
ResultSet resultSet) throws SQLException {
+        return new TrackerRow(pipeline, documentId,
+                resultSet.getString(1), resultSet.getString(2),
+                resultSet.getInt(3), resultSet.getInt(4), 
resultSet.getString(5),
+                resultSet.getString(6), resultSet.getBoolean(7), 
resultSet.getBoolean(8));
+    }
+
+    @Override
+    public void writeIntent(String pipeline, String documentId, String 
fingerprint, String contentHash,
+            int committedCount, int intendedCount, String origin) {
+        try (Connection connection = dataSource.getConnection()) {
+            String update = "UPDATE " + TABLE + " SET fingerprint = ?, 
content_hash = ?, "
+                    + "intended_count = ?, status = 'in_progress', origin = ?, 
updated_at = ? "

Review Comment:
   `intended_count = ?` overwrites unconditionally, so the shrink bound 
documented in the README ("the shrink bound `max(committed, intended)` rides 
along in the row") isn't actually held across an uncommitted intent — see the 
reproduction in the review body.
   
   Once an intent for N is durable, up to N segments may exist in the store, so 
the bound has to stay >= N until a `commit` proves the sweep happened. `commit` 
lowering it is correct; this path lowering it isn't. Still dialect-free:
   
   ```sql
   intended_count = CASE WHEN intended_count > ? THEN intended_count ELSE ? END
   ```



##########
extensions-support/langchain4j/runtime/src/main/java/org/apache/camel/quarkus/component/support/langchain4j/tracker/IngestionTracker.java:
##########
@@ -0,0 +1,128 @@
+/*
+ * 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.support.langchain4j.tracker;
+
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * The ingestion tracker: one row per document, the authority on what was 
ingested. The vector store is
+ * a projection that is never asked questions — losing the tracker costs 
re-ingestion (which
+ * converges thanks to deterministic segment ids), never correctness. 
Reconciliation is
+ * tracker-versus-source; the store is never enumerated.
+ *
+ * <p>
+ * <strong>Status: Experimental.</strong> Internal SPI — not a public API. No 
compatibility
+ * guarantees between releases; applications must not implement or call this 
interface, and it
+ * may change or be removed without a deprecation cycle. It exists so the 
implementation can be
+ * replaced wholesale: LangChain4j has no equivalent of LangChain-Python's 
{@code RecordManager} yet
+ * (<a 
href="https://github.com/langchain4j/langchain4j/issues/2931";>langchain4j#2931</a>),
 and
+ * once one lands upstream, an adapter implementing this interface replaces the
+ * {@code tracker.jdbc} package while every consumer stays untouched. New 
implementations must
+ * pass the behavioural tests of {@code JdbcIngestionTrackerTest}, whose test 
methods are written
+ * against this SPI only and are meant to be extracted into a shared contract 
base class the day
+ * a second implementation exists.
+ *
+ * <p>
+ * Two method groups, by replaceability:
+ * <ul>
+ * <li><em>Tracker subset</em> — {@link #ensureSchema}, {@link #read}, {@link 
#listDocuments},
+ * {@link #refreshFingerprint}, {@link #deleteRow}: mirrors what an upstream 
record manager
+ * provides and would delegate to it directly.</li>
+ * <li><em>Camel Quarkus extensions</em> — the two-phase {@link 
#writeIntent}/{@link #commit}
+ * protocol, {@link #tombstone}/{@link #unsuppress}, {@link #pin}/{@link 
#unpin},
+ * {@link #markFailed}: product semantics an upstream tracker will not carry; 
an adapter keeps
+ * these in side storage keyed the same way.</li>
+ * </ul>
+ */
+public interface IngestionTracker {
+
+    String ORIGIN_SOURCE = "source";
+    String ORIGIN_API = "api";
+
+    /** Creates or migrates the backing schema. Called once before first use. 
*/
+    void ensureSchema();
+
+    Optional<TrackerRow> read(String pipeline, String documentId);
+
+    /** All rows of a pipeline — the reconciliation input. */
+    List<TrackerRow> listDocuments(String pipeline);
+
+    /**
+     * Durably records the intent to (re)write a document <em>before</em> the 
store is touched.
+     * A row left {@code in_progress} by a crash is never skipped by change 
detection, so the
+     * next delivery of the document converges the store.
+     */
+    void writeIntent(String pipeline, String documentId, String fingerprint, 
String contentHash,
+            int committedCount, int intendedCount, String origin);

Review Comment:
   `committedCount` is only read on the INSERT branch of the JDBC impl — 
passing `row.maxKnownCount()` back in on a re-intent has no effect at all (I 
checked). As it stands it's a parameter that looks like it preserves the bound 
and doesn't. Either honour it on the update path or drop it from the signature.



##########
extensions-support/langchain4j/runtime/src/main/java/org/apache/camel/quarkus/component/support/langchain4j/tracker/jdbc/JdbcIngestionTracker.java:
##########
@@ -0,0 +1,288 @@
+/*
+ * 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.support.langchain4j.tracker.jdbc;
+
+import java.sql.Connection;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Timestamp;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+
+import javax.sql.DataSource;
+
+import 
org.apache.camel.quarkus.component.support.langchain4j.tracker.IngestionTracker;
+
+/**
+ * JDBC-backed {@link IngestionTracker}. Deliberately dialect-free SQL (works 
on PostgreSQL and H2):
+ * upserts are update-then-insert rather than vendor MERGE/ON CONFLICT.
+ *
+ * <p>
+ * <strong>Concurrency assumption: at most one writer per {@code (pipeline, 
documentId)} at a
+ * time.</strong> The update-then-insert is not atomic (each statement 
auto-commits on its own,
+ * there is no transaction), so two concurrent writers for the same row can 
both see the
+ * {@code UPDATE} affect zero rows and then both attempt the {@code INSERT}, 
and one fails on the
+ * {@code (pipeline, doc_id)} primary key. Wrapping the two statements in a 
transaction would not
+ * remove this race without either {@code SERIALIZABLE} isolation or a 
dialect-specific upsert,
+ * both at odds with staying dialect-free; callers (the sync pass runner 
processes one document
+ * at a time per pipeline) are relied upon to hold this invariant instead.
+ *
+ * <p>
+ * <strong>Status: Experimental.</strong> See {@link IngestionTracker}.
+ */
+public class JdbcIngestionTracker implements IngestionTracker {
+
+    static final String TABLE = "cq_ingestion_tracker";

Review Comment:
   Everything caller-supplied goes through `PreparedStatement` parameters and 
`TABLE` is a constant, so there's no injection today. But a `sync` mode will 
almost certainly grow a configurable table/schema name, and this concatenation 
becomes injectable the moment it does. Worth a comment here saying it must 
never become caller-supplied without identifier validation.



##########
extensions-support/langchain4j/runtime/src/main/java/org/apache/camel/quarkus/component/support/langchain4j/tracker/jdbc/JdbcIngestionTracker.java:
##########
@@ -0,0 +1,288 @@
+/*
+ * 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.support.langchain4j.tracker.jdbc;
+
+import java.sql.Connection;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Timestamp;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+
+import javax.sql.DataSource;
+
+import 
org.apache.camel.quarkus.component.support.langchain4j.tracker.IngestionTracker;
+
+/**
+ * JDBC-backed {@link IngestionTracker}. Deliberately dialect-free SQL (works 
on PostgreSQL and H2):
+ * upserts are update-then-insert rather than vendor MERGE/ON CONFLICT.
+ *
+ * <p>
+ * <strong>Concurrency assumption: at most one writer per {@code (pipeline, 
documentId)} at a
+ * time.</strong> The update-then-insert is not atomic (each statement 
auto-commits on its own,
+ * there is no transaction), so two concurrent writers for the same row can 
both see the
+ * {@code UPDATE} affect zero rows and then both attempt the {@code INSERT}, 
and one fails on the
+ * {@code (pipeline, doc_id)} primary key. Wrapping the two statements in a 
transaction would not
+ * remove this race without either {@code SERIALIZABLE} isolation or a 
dialect-specific upsert,
+ * both at odds with staying dialect-free; callers (the sync pass runner 
processes one document
+ * at a time per pipeline) are relied upon to hold this invariant instead.
+ *
+ * <p>
+ * <strong>Status: Experimental.</strong> See {@link IngestionTracker}.
+ */
+public class JdbcIngestionTracker implements IngestionTracker {
+
+    static final String TABLE = "cq_ingestion_tracker";
+
+    private final DataSource dataSource;
+
+    public JdbcIngestionTracker(DataSource dataSource) {
+        this.dataSource = dataSource;
+    }
+
+    @Override
+    public void ensureSchema() {
+        String ddl = "CREATE TABLE IF NOT EXISTS " + TABLE + " ("
+                + "pipeline VARCHAR(128) NOT NULL, "
+                + "doc_id VARCHAR(512) NOT NULL, "
+                + "fingerprint VARCHAR(256), "
+                + "content_hash VARCHAR(64), "
+                + "segment_count INT DEFAULT 0 NOT NULL, "
+                + "intended_count INT DEFAULT 0 NOT NULL, "
+                + "status VARCHAR(16) NOT NULL, "
+                + "origin VARCHAR(16) DEFAULT 'source' NOT NULL, "
+                + "tombstone BOOLEAN DEFAULT FALSE NOT NULL, "
+                + "pinned BOOLEAN DEFAULT FALSE NOT NULL, "
+                + "updated_at TIMESTAMP, "
+                + "PRIMARY KEY (pipeline, doc_id))";
+        try (Connection connection = dataSource.getConnection()) {
+            connection.createStatement().execute(ddl);
+        } catch (SQLException e) {
+            throw new IllegalStateException(
+                    "Cannot create the ingestion tracker table '" + TABLE + 
"'. "
+                            + "mode=sync needs a working datasource — 
configure quarkus.datasource "
+                            + "(Dev Services provides one in dev mode) or use 
mode=append.",
+                    e);
+        }
+    }
+
+    private static final String ROW_COLUMNS = "fingerprint, content_hash, 
segment_count, intended_count, status, "
+            + "origin, tombstone, pinned";
+
+    @Override
+    public Optional<TrackerRow> read(String pipeline, String documentId) {
+        String sql = "SELECT " + ROW_COLUMNS + " FROM " + TABLE + " WHERE 
pipeline = ? AND doc_id = ?";
+        try (Connection connection = dataSource.getConnection();
+                PreparedStatement statement = 
connection.prepareStatement(sql)) {
+            statement.setString(1, pipeline);
+            statement.setString(2, documentId);
+            try (ResultSet resultSet = statement.executeQuery()) {
+                if (!resultSet.next()) {
+                    return Optional.empty();
+                }
+                return Optional.of(row(pipeline, documentId, resultSet));
+            }
+        } catch (SQLException e) {
+            throw new IllegalStateException("Tracker read failed for '" + 
documentId + "'", e);
+        }
+    }
+
+    @Override
+    public List<TrackerRow> listDocuments(String pipeline) {
+        String sql = "SELECT doc_id, " + ROW_COLUMNS + " FROM " + TABLE + " 
WHERE pipeline = ?";
+        try (Connection connection = dataSource.getConnection();
+                PreparedStatement statement = 
connection.prepareStatement(sql)) {
+            statement.setString(1, pipeline);
+            try (ResultSet resultSet = statement.executeQuery()) {
+                List<TrackerRow> rows = new ArrayList<>();
+                while (resultSet.next()) {
+                    String documentId = resultSet.getString(1);
+                    rows.add(new TrackerRow(pipeline, documentId,
+                            resultSet.getString(2), resultSet.getString(3),
+                            resultSet.getInt(4), resultSet.getInt(5), 
resultSet.getString(6),
+                            resultSet.getString(7), resultSet.getBoolean(8), 
resultSet.getBoolean(9)));
+                }
+                return rows;
+            }
+        } catch (SQLException e) {
+            throw new IllegalStateException("Tracker listing failed for 
pipeline '" + pipeline + "'", e);
+        }
+    }
+
+    private static TrackerRow row(String pipeline, String documentId, 
ResultSet resultSet) throws SQLException {
+        return new TrackerRow(pipeline, documentId,
+                resultSet.getString(1), resultSet.getString(2),
+                resultSet.getInt(3), resultSet.getInt(4), 
resultSet.getString(5),
+                resultSet.getString(6), resultSet.getBoolean(7), 
resultSet.getBoolean(8));
+    }
+
+    @Override
+    public void writeIntent(String pipeline, String documentId, String 
fingerprint, String contentHash,
+            int committedCount, int intendedCount, String origin) {
+        try (Connection connection = dataSource.getConnection()) {
+            String update = "UPDATE " + TABLE + " SET fingerprint = ?, 
content_hash = ?, "
+                    + "intended_count = ?, status = 'in_progress', origin = ?, 
updated_at = ? "
+                    + "WHERE pipeline = ? AND doc_id = ?";
+            int updated;
+            try (PreparedStatement statement = 
connection.prepareStatement(update)) {
+                statement.setString(1, fingerprint);
+                statement.setString(2, contentHash);
+                statement.setInt(3, intendedCount);
+                statement.setString(4, origin);
+                statement.setTimestamp(5, Timestamp.from(Instant.now()));
+                statement.setString(6, pipeline);
+                statement.setString(7, documentId);
+                updated = statement.executeUpdate();
+            }
+            if (updated == 0) {
+                String insert = "INSERT INTO " + TABLE
+                        + " (pipeline, doc_id, fingerprint, content_hash, 
segment_count, intended_count, "
+                        + "status, origin, updated_at) VALUES (?, ?, ?, ?, ?, 
?, 'in_progress', ?, ?)";
+                try (PreparedStatement statement = 
connection.prepareStatement(insert)) {
+                    statement.setString(1, pipeline);
+                    statement.setString(2, documentId);
+                    statement.setString(3, fingerprint);
+                    statement.setString(4, contentHash);
+                    statement.setInt(5, committedCount);
+                    statement.setInt(6, intendedCount);
+                    statement.setString(7, origin);
+                    statement.setTimestamp(8, Timestamp.from(Instant.now()));
+                    statement.executeUpdate();
+                }
+            }
+        } catch (SQLException e) {
+            throw new IllegalStateException("Tracker intent write failed for 
'" + documentId + "'", e);
+        }
+    }
+
+    @Override
+    public void tombstone(String pipeline, String documentId) {
+        setFlag(pipeline, documentId, "tombstone", true);
+    }
+
+    @Override
+    public void unsuppress(String pipeline, String documentId) {
+        setFlag(pipeline, documentId, "tombstone", false);
+    }
+
+    @Override
+    public void pin(String pipeline, String documentId) {
+        setFlag(pipeline, documentId, "pinned", true);
+    }
+
+    @Override
+    public void unpin(String pipeline, String documentId) {
+        setFlag(pipeline, documentId, "pinned", false);
+    }
+
+    private void setFlag(String pipeline, String documentId, String column, 
boolean value) {

Review Comment:
   `setFlag` is UPDATE-only, so `tombstone`/`pin`/`unsuppress`/`unpin` silently 
no-op when no row exists — `tombstone("p", "ghost")` leaves no row and throws 
nothing. The README (line 80) explicitly promises the opposite: "a tombstone 
can even be recorded for a never-ingested document, as a pure suppression 
record".
   
   So "deleted stays deleted" doesn't hold for a document deleted before its 
first successful ingest — the next pass re-ingests it. Either upsert a 
suppression row here or correct the README.



##########
extensions-support/langchain4j/runtime/src/test/java/org/apache/camel/quarkus/component/support/langchain4j/tracker/jdbc/JdbcIngestionTrackerTest.java:
##########
@@ -0,0 +1,169 @@
+/*
+ * 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.support.langchain4j.tracker.jdbc;
+
+import java.util.List;
+import java.util.UUID;
+
+import 
org.apache.camel.quarkus.component.support.langchain4j.tracker.IngestionTracker;
+import org.h2.jdbcx.JdbcDataSource;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * The behavioural contract of the {@link IngestionTracker} SPI, exercised 
against the JDBC
+ * implementation on H2. The test methods are deliberately written against the 
SPI only — nothing
+ * below {@link #setUpTracker()} references {@link JdbcIngestionTracker}.
+ *
+ * <p>
+ * The contract lives folded into this class only because a single 
implementation exists today.
+ * When a second one arrives — e.g. an adapter over a future upstream 
LangChain4j record manager
+ * (langchain4j#2931) — extract the test methods into an abstract {@code 
IngestionTrackerContract}
+ * base class with a {@code createTracker()} factory, and keep one {@code 
*Test} subclass per
+ * implementation: a replacement is a drop-in exactly when its subclass passes.
+ */
+class JdbcIngestionTrackerTest {
+
+    IngestionTracker tracker;
+
+    /** A fresh, empty tracker per test. */
+    @BeforeEach
+    void setUpTracker() {
+        JdbcDataSource dataSource = new JdbcDataSource();
+        dataSource.setURL("jdbc:h2:mem:" + UUID.randomUUID() + 
";DB_CLOSE_DELAY=-1");
+        tracker = new JdbcIngestionTracker(dataSource);
+        tracker.ensureSchema();
+    }
+
+    @Test
+    void unknownDocumentReadsEmpty() {
+        assertTrue(tracker.read("p", "missing").isEmpty());
+    }
+
+    @Test
+    void intentIsDurableAndNeverSkippable() {
+        tracker.writeIntent("p", "doc", "fp1", "hash1", 0, 5, 
IngestionTracker.ORIGIN_SOURCE);
+
+        IngestionTracker.TrackerRow row = tracker.read("p", 
"doc").orElseThrow();
+        assertFalse(row.done(), "an intent row must not count as done");
+        assertEquals(5, row.maxKnownCount(), "the shrink bound must cover the 
intended count");
+    }
+
+    @Test
+    void commitCompletesTheIntent() {
+        tracker.writeIntent("p", "doc", "fp1", "hash1", 0, 5, 
IngestionTracker.ORIGIN_SOURCE);
+        tracker.commit("p", "doc", "fp1", "hash1", 3);
+
+        IngestionTracker.TrackerRow row = tracker.read("p", 
"doc").orElseThrow();
+        assertTrue(row.done());
+        assertEquals("fp1", row.fingerprint());
+        assertEquals("hash1", row.contentHash());
+        assertEquals(3, row.segmentCount());
+    }
+
+    @Test
+    void reintentKeepsTheLargestKnownCount() {

Review Comment:
   This only covers re-intent *after* a commit — the path where `segment_count` 
carries the bound, which works. The intent -> intent path is the broken one and 
isn't covered.
   
   Also missing: `markFailed` straight from `in_progress`, `commit` without a 
preceding intent, and `refreshFingerprint` on a non-`done` row.



##########
extensions-support/langchain4j/runtime/src/main/java/org/apache/camel/quarkus/component/support/langchain4j/tracker/jdbc/JdbcIngestionTracker.java:
##########
@@ -0,0 +1,288 @@
+/*
+ * 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.support.langchain4j.tracker.jdbc;
+
+import java.sql.Connection;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Timestamp;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+
+import javax.sql.DataSource;
+
+import 
org.apache.camel.quarkus.component.support.langchain4j.tracker.IngestionTracker;
+
+/**
+ * JDBC-backed {@link IngestionTracker}. Deliberately dialect-free SQL (works 
on PostgreSQL and H2):
+ * upserts are update-then-insert rather than vendor MERGE/ON CONFLICT.
+ *
+ * <p>
+ * <strong>Concurrency assumption: at most one writer per {@code (pipeline, 
documentId)} at a
+ * time.</strong> The update-then-insert is not atomic (each statement 
auto-commits on its own,
+ * there is no transaction), so two concurrent writers for the same row can 
both see the
+ * {@code UPDATE} affect zero rows and then both attempt the {@code INSERT}, 
and one fails on the
+ * {@code (pipeline, doc_id)} primary key. Wrapping the two statements in a 
transaction would not
+ * remove this race without either {@code SERIALIZABLE} isolation or a 
dialect-specific upsert,
+ * both at odds with staying dialect-free; callers (the sync pass runner 
processes one document
+ * at a time per pipeline) are relied upon to hold this invariant instead.
+ *
+ * <p>
+ * <strong>Status: Experimental.</strong> See {@link IngestionTracker}.
+ */
+public class JdbcIngestionTracker implements IngestionTracker {
+
+    static final String TABLE = "cq_ingestion_tracker";
+
+    private final DataSource dataSource;
+
+    public JdbcIngestionTracker(DataSource dataSource) {
+        this.dataSource = dataSource;
+    }
+
+    @Override
+    public void ensureSchema() {
+        String ddl = "CREATE TABLE IF NOT EXISTS " + TABLE + " ("
+                + "pipeline VARCHAR(128) NOT NULL, "
+                + "doc_id VARCHAR(512) NOT NULL, "

Review Comment:
   `VARCHAR(512)` hard-fails rather than truncating on longer keys (confirmed: 
`JdbcSQLDataException`). S3 keys go to 1024 bytes and file URIs can exceed 512 
too.



##########
extensions-support/langchain4j/README.adoc:
##########
@@ -0,0 +1,107 @@
+= LangChain4j Support
+
+This module provides common support code shared by Camel Quarkus LangChain4j 
extensions.
+
+== Ingestion tracker
+
+Status: *Experimental*.
+
+`org.apache.camel.quarkus.component.support.langchain4j.tracker.IngestionTracker`
 is the bookkeeping
+SPI behind keeping a vector store in sync with a changing document source 
(files, buckets, feeds,
+...). Vector stores cannot be enumerated for "what did I already ingest", and 
LangChain4j has no
+equivalent of LangChain-Python's `RecordManager`
+(https://github.com/langchain4j/langchain4j/issues/2931[langchain4j#2931]), so 
without external
+bookkeeping any ingestion pipeline is either append-only (restarts re-embed 
the whole corpus,
+edited documents join their previous vectors instead of replacing them) or 
wipe-and-reload
+(loses everything between passes). `IngestionTracker` closes that gap: it 
tracks one row per document
+— fingerprint, content hash, segment count, a two-phase 
`in_progress`/`done`/`failed` status,
+and `tombstone`/`pinned` flags — as the single authority on what was ingested. 
The vector store
+itself stays a disposable projection that is never asked questions; losing the 
tracker only costs
+re-ingestion, never correctness, because segment ids are deterministic.
+
+The two-phase `writeIntent`/`commit` protocol is what makes the tracker 
crash-safe: a row left
+`in_progress` by a crash (killed process, OOM, ...) is never mistaken for 
"already ingested", so
+the next delivery of the same document converges the store instead of skipping 
it.
+
+=== Document states
+
+Each document is one row that moves through the states below. Transitions are 
the SPI methods;
+the annotations in parentheses describe when the consumer (the ingestion 
pipeline) invokes them.
+
+----
+ (no row)
+    │  writeIntent                 durable BEFORE the store is touched; a 
crash strands
+    ▼                              the row here, and an in_progress row is 
never trusted
+ in_progress                       as "already ingested" — the next delivery 
of the
+    │  commit                      document converges the store
+    ▼
+  done ◄──────────────────────┐
+    │                         │
+    ├─ (unchanged) ──► done   │  no transition: the skip that makes restarts 
free
+    │                         │
+    ├─ refreshFingerprint ────┘  fingerprint renewed after a content-hash match
+    │                            (tier-2), so the cheap tier-1 check works 
next pass
+    │
+    ├─ writeIntent ──► in_progress    (edited content: replace; the shrink 
bound
+    │                                  max(committed, intended) rides along in 
the row)
+    │
+    ├─ markFailed ──► failed          (processing failure — also fired 
straight from
+    │      │                           in_progress; previously committed 
segments, if
+    │      │                           any — a stale but valid version — keep 
serving)
+    │      │
+    │      ├─ (fingerprint unchanged) ──► failed   dead-lettered: skipped on 
every
+    │      │                                       pass until the content 
changes
+    │      │
+    │      └─ writeIntent ──► in_progress          (changed content: retry)
+    │
+    ├─ tombstone() ──► done(0) + tombstone    (explicit delete: the consumer 
removes
+    │      │                                   the vectors and records 
commit(0) with
+    │      │                                   the flag) — "deleted stays 
deleted":
+    │      │                                   every future ingest of the 
document is
+    │      │                                   suppressed, even though the 
source
+    │      │                                   still has it
+    │      │
+    │      └─ unsuppress() ──► done(0)         suppression lifted: the next 
pass
+    │                                          re-ingests the document
+    │
+    └─ pin() ──► done + pinned        (an API write corrected a source-owned 
document)
+           │                           — "the correction wins": source-origin 
updates
+           │                           are suppressed; API-origin writes still 
pass
+           │
+           └─ unpin() ──► done         the source owns the document again
+
+ any state ── deleteRow ──► (no row)   reconciliation: the source no longer 
lists the
+                                       document (guarded by the consumer's pass
+                                       interlock, never fired by the tracker 
itself)
+----
+
+`tombstone` and `pinned` are columns orthogonal to `status`: drawn above off 
`done` because
+that is how the consumer composes them, but they can accompany any state (a 
tombstone can even
+be recorded for a never-ingested document, as a pure suppression record) and 
they survive every

Review Comment:
   This claim isn't implemented — see the comment on `setFlag`.
   
   While you're here: worth an operator note that this table is a durable 
inventory of the corpus. Content correctly never lands in it, but `doc_id` is 
typically a path, URL or S3 key, and those routinely encode sensitive structure 
(`/customers/12345/contract.pdf`). Doc ids also go into every exception 
message, so they reach logs and stack traces.



##########
extensions-support/langchain4j/runtime/src/main/java/org/apache/camel/quarkus/component/support/langchain4j/tracker/jdbc/JdbcIngestionTracker.java:
##########
@@ -0,0 +1,288 @@
+/*
+ * 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.support.langchain4j.tracker.jdbc;
+
+import java.sql.Connection;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Timestamp;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+
+import javax.sql.DataSource;
+
+import 
org.apache.camel.quarkus.component.support.langchain4j.tracker.IngestionTracker;
+
+/**
+ * JDBC-backed {@link IngestionTracker}. Deliberately dialect-free SQL (works 
on PostgreSQL and H2):
+ * upserts are update-then-insert rather than vendor MERGE/ON CONFLICT.
+ *
+ * <p>
+ * <strong>Concurrency assumption: at most one writer per {@code (pipeline, 
documentId)} at a
+ * time.</strong> The update-then-insert is not atomic (each statement 
auto-commits on its own,
+ * there is no transaction), so two concurrent writers for the same row can 
both see the
+ * {@code UPDATE} affect zero rows and then both attempt the {@code INSERT}, 
and one fails on the
+ * {@code (pipeline, doc_id)} primary key. Wrapping the two statements in a 
transaction would not
+ * remove this race without either {@code SERIALIZABLE} isolation or a 
dialect-specific upsert,
+ * both at odds with staying dialect-free; callers (the sync pass runner 
processes one document
+ * at a time per pipeline) are relied upon to hold this invariant instead.
+ *
+ * <p>
+ * <strong>Status: Experimental.</strong> See {@link IngestionTracker}.
+ */
+public class JdbcIngestionTracker implements IngestionTracker {
+
+    static final String TABLE = "cq_ingestion_tracker";
+
+    private final DataSource dataSource;
+
+    public JdbcIngestionTracker(DataSource dataSource) {
+        this.dataSource = dataSource;
+    }
+
+    @Override
+    public void ensureSchema() {
+        String ddl = "CREATE TABLE IF NOT EXISTS " + TABLE + " ("
+                + "pipeline VARCHAR(128) NOT NULL, "
+                + "doc_id VARCHAR(512) NOT NULL, "
+                + "fingerprint VARCHAR(256), "
+                + "content_hash VARCHAR(64), "
+                + "segment_count INT DEFAULT 0 NOT NULL, "
+                + "intended_count INT DEFAULT 0 NOT NULL, "
+                + "status VARCHAR(16) NOT NULL, "
+                + "origin VARCHAR(16) DEFAULT 'source' NOT NULL, "
+                + "tombstone BOOLEAN DEFAULT FALSE NOT NULL, "
+                + "pinned BOOLEAN DEFAULT FALSE NOT NULL, "
+                + "updated_at TIMESTAMP, "
+                + "PRIMARY KEY (pipeline, doc_id))";
+        try (Connection connection = dataSource.getConnection()) {
+            connection.createStatement().execute(ddl);
+        } catch (SQLException e) {
+            throw new IllegalStateException(
+                    "Cannot create the ingestion tracker table '" + TABLE + 
"'. "
+                            + "mode=sync needs a working datasource — 
configure quarkus.datasource "
+                            + "(Dev Services provides one in dev mode) or use 
mode=append.",
+                    e);
+        }
+    }
+
+    private static final String ROW_COLUMNS = "fingerprint, content_hash, 
segment_count, intended_count, status, "
+            + "origin, tombstone, pinned";
+
+    @Override
+    public Optional<TrackerRow> read(String pipeline, String documentId) {
+        String sql = "SELECT " + ROW_COLUMNS + " FROM " + TABLE + " WHERE 
pipeline = ? AND doc_id = ?";
+        try (Connection connection = dataSource.getConnection();
+                PreparedStatement statement = 
connection.prepareStatement(sql)) {
+            statement.setString(1, pipeline);
+            statement.setString(2, documentId);
+            try (ResultSet resultSet = statement.executeQuery()) {
+                if (!resultSet.next()) {
+                    return Optional.empty();
+                }
+                return Optional.of(row(pipeline, documentId, resultSet));
+            }
+        } catch (SQLException e) {
+            throw new IllegalStateException("Tracker read failed for '" + 
documentId + "'", e);
+        }
+    }
+
+    @Override
+    public List<TrackerRow> listDocuments(String pipeline) {
+        String sql = "SELECT doc_id, " + ROW_COLUMNS + " FROM " + TABLE + " 
WHERE pipeline = ?";
+        try (Connection connection = dataSource.getConnection();
+                PreparedStatement statement = 
connection.prepareStatement(sql)) {
+            statement.setString(1, pipeline);
+            try (ResultSet resultSet = statement.executeQuery()) {
+                List<TrackerRow> rows = new ArrayList<>();
+                while (resultSet.next()) {
+                    String documentId = resultSet.getString(1);
+                    rows.add(new TrackerRow(pipeline, documentId,
+                            resultSet.getString(2), resultSet.getString(3),
+                            resultSet.getInt(4), resultSet.getInt(5), 
resultSet.getString(6),
+                            resultSet.getString(7), resultSet.getBoolean(8), 
resultSet.getBoolean(9)));
+                }
+                return rows;
+            }
+        } catch (SQLException e) {
+            throw new IllegalStateException("Tracker listing failed for 
pipeline '" + pipeline + "'", e);
+        }
+    }
+
+    private static TrackerRow row(String pipeline, String documentId, 
ResultSet resultSet) throws SQLException {
+        return new TrackerRow(pipeline, documentId,
+                resultSet.getString(1), resultSet.getString(2),
+                resultSet.getInt(3), resultSet.getInt(4), 
resultSet.getString(5),
+                resultSet.getString(6), resultSet.getBoolean(7), 
resultSet.getBoolean(8));
+    }
+
+    @Override
+    public void writeIntent(String pipeline, String documentId, String 
fingerprint, String contentHash,
+            int committedCount, int intendedCount, String origin) {
+        try (Connection connection = dataSource.getConnection()) {
+            String update = "UPDATE " + TABLE + " SET fingerprint = ?, 
content_hash = ?, "
+                    + "intended_count = ?, status = 'in_progress', origin = ?, 
updated_at = ? "
+                    + "WHERE pipeline = ? AND doc_id = ?";
+            int updated;
+            try (PreparedStatement statement = 
connection.prepareStatement(update)) {
+                statement.setString(1, fingerprint);
+                statement.setString(2, contentHash);
+                statement.setInt(3, intendedCount);
+                statement.setString(4, origin);
+                statement.setTimestamp(5, Timestamp.from(Instant.now()));
+                statement.setString(6, pipeline);
+                statement.setString(7, documentId);
+                updated = statement.executeUpdate();
+            }
+            if (updated == 0) {
+                String insert = "INSERT INTO " + TABLE
+                        + " (pipeline, doc_id, fingerprint, content_hash, 
segment_count, intended_count, "
+                        + "status, origin, updated_at) VALUES (?, ?, ?, ?, ?, 
?, 'in_progress', ?, ?)";
+                try (PreparedStatement statement = 
connection.prepareStatement(insert)) {
+                    statement.setString(1, pipeline);
+                    statement.setString(2, documentId);
+                    statement.setString(3, fingerprint);
+                    statement.setString(4, contentHash);
+                    statement.setInt(5, committedCount);
+                    statement.setInt(6, intendedCount);
+                    statement.setString(7, origin);
+                    statement.setTimestamp(8, Timestamp.from(Instant.now()));
+                    statement.executeUpdate();
+                }
+            }
+        } catch (SQLException e) {
+            throw new IllegalStateException("Tracker intent write failed for 
'" + documentId + "'", e);
+        }
+    }
+
+    @Override
+    public void tombstone(String pipeline, String documentId) {
+        setFlag(pipeline, documentId, "tombstone", true);
+    }
+
+    @Override
+    public void unsuppress(String pipeline, String documentId) {
+        setFlag(pipeline, documentId, "tombstone", false);
+    }
+
+    @Override
+    public void pin(String pipeline, String documentId) {
+        setFlag(pipeline, documentId, "pinned", true);
+    }
+
+    @Override
+    public void unpin(String pipeline, String documentId) {
+        setFlag(pipeline, documentId, "pinned", false);
+    }
+
+    private void setFlag(String pipeline, String documentId, String column, 
boolean value) {
+        String sql = "UPDATE " + TABLE + " SET " + column + " = ?, updated_at 
= ? "
+                + "WHERE pipeline = ? AND doc_id = ?";
+        try (Connection connection = dataSource.getConnection();
+                PreparedStatement statement = 
connection.prepareStatement(sql)) {
+            statement.setBoolean(1, value);
+            statement.setTimestamp(2, Timestamp.from(Instant.now()));
+            statement.setString(3, pipeline);
+            statement.setString(4, documentId);
+            statement.executeUpdate();
+        } catch (SQLException e) {
+            throw new IllegalStateException("Tracker " + column + " update 
failed for '" + documentId + "'", e);
+        }
+    }
+
+    @Override
+    public void markFailed(String pipeline, String documentId, String 
fingerprint) {
+        try (Connection connection = dataSource.getConnection()) {
+            String update = "UPDATE " + TABLE + " SET fingerprint = ?, status 
= 'failed', updated_at = ? "
+                    + "WHERE pipeline = ? AND doc_id = ?";
+            int updated;
+            try (PreparedStatement statement = 
connection.prepareStatement(update)) {
+                statement.setString(1, fingerprint);
+                statement.setTimestamp(2, Timestamp.from(Instant.now()));
+                statement.setString(3, pipeline);
+                statement.setString(4, documentId);
+                updated = statement.executeUpdate();
+            }
+            if (updated == 0) {
+                String insert = "INSERT INTO " + TABLE
+                        + " (pipeline, doc_id, fingerprint, status, origin, 
updated_at) "
+                        + "VALUES (?, ?, ?, 'failed', 'source', ?)";

Review Comment:
   The INSERT branch hardcodes `origin = 'source'`, so a document that only 
ever fails records the wrong origin.



##########
integration-tests/langchain4j-ingestion-tracker/src/test/java/org/apache/camel/quarkus/component/langchain4j/ingestiontracker/it/Langchain4jIngestionTrackerTest.java:
##########
@@ -0,0 +1,163 @@
+/*
+ * 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.ingestiontracker.it;
+
+import io.quarkus.test.junit.QuarkusTest;
+import io.restassured.RestAssured;
+import org.junit.jupiter.api.Test;
+
+import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.hasSize;
+
+/**
+ * Runs the {@code IngestionTracker} behavioural guarantees (mirrored from 
{@code JdbcIngestionTrackerTest})
+ * against a real PostgreSQL server, provisioned by Quarkus Dev Services, 
through
+ * {@link IngestionTrackerResource}.
+ */
+@QuarkusTest
+class Langchain4jIngestionTrackerTest {

Review Comment:
   Every method here shares `pipeline="p"` / `docId="doc"` with no cleanup and 
no DB reset. It passes today only because each one happens to rewrite the row 
before asserting — `deleteRowForgetsTheDocument` asserting `hasSize(0)` on 
pipeline `p` is one added test away from being order-dependent. Unique ids per 
test, or a truncate in `@BeforeEach`, would remove the trap.



##########
extensions-support/langchain4j/runtime/src/main/java/org/apache/camel/quarkus/component/support/langchain4j/tracker/jdbc/JdbcIngestionTracker.java:
##########
@@ -0,0 +1,288 @@
+/*
+ * 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.support.langchain4j.tracker.jdbc;
+
+import java.sql.Connection;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Timestamp;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+
+import javax.sql.DataSource;
+
+import 
org.apache.camel.quarkus.component.support.langchain4j.tracker.IngestionTracker;
+
+/**
+ * JDBC-backed {@link IngestionTracker}. Deliberately dialect-free SQL (works 
on PostgreSQL and H2):
+ * upserts are update-then-insert rather than vendor MERGE/ON CONFLICT.
+ *
+ * <p>
+ * <strong>Concurrency assumption: at most one writer per {@code (pipeline, 
documentId)} at a
+ * time.</strong> The update-then-insert is not atomic (each statement 
auto-commits on its own,
+ * there is no transaction), so two concurrent writers for the same row can 
both see the
+ * {@code UPDATE} affect zero rows and then both attempt the {@code INSERT}, 
and one fails on the
+ * {@code (pipeline, doc_id)} primary key. Wrapping the two statements in a 
transaction would not
+ * remove this race without either {@code SERIALIZABLE} isolation or a 
dialect-specific upsert,
+ * both at odds with staying dialect-free; callers (the sync pass runner 
processes one document
+ * at a time per pipeline) are relied upon to hold this invariant instead.
+ *
+ * <p>
+ * <strong>Status: Experimental.</strong> See {@link IngestionTracker}.
+ */
+public class JdbcIngestionTracker implements IngestionTracker {
+
+    static final String TABLE = "cq_ingestion_tracker";
+
+    private final DataSource dataSource;
+
+    public JdbcIngestionTracker(DataSource dataSource) {
+        this.dataSource = dataSource;
+    }
+
+    @Override
+    public void ensureSchema() {
+        String ddl = "CREATE TABLE IF NOT EXISTS " + TABLE + " ("
+                + "pipeline VARCHAR(128) NOT NULL, "
+                + "doc_id VARCHAR(512) NOT NULL, "
+                + "fingerprint VARCHAR(256), "
+                + "content_hash VARCHAR(64), "
+                + "segment_count INT DEFAULT 0 NOT NULL, "
+                + "intended_count INT DEFAULT 0 NOT NULL, "
+                + "status VARCHAR(16) NOT NULL, "
+                + "origin VARCHAR(16) DEFAULT 'source' NOT NULL, "
+                + "tombstone BOOLEAN DEFAULT FALSE NOT NULL, "
+                + "pinned BOOLEAN DEFAULT FALSE NOT NULL, "
+                + "updated_at TIMESTAMP, "
+                + "PRIMARY KEY (pipeline, doc_id))";
+        try (Connection connection = dataSource.getConnection()) {
+            connection.createStatement().execute(ddl);

Review Comment:
   `CREATE TABLE IF NOT EXISTS` against the application's datasource forces the 
production DB user to hold DDL rights, with no opt-out. Worth mirroring 
`JdbcMessageIdRepository` in camel-sql: a `createTableIfNotExists` flag 
(default true) plus a `tableExists` probe, so operators can pre-provision and 
run least-privilege. When that becomes a config option it should be 
`ConfigPhase.RUN_TIME` per our security model doc.
   
   Also minor: `connection.createStatement()` isn't in the try-with-resources.



##########
extensions-support/langchain4j/runtime/src/main/java/org/apache/camel/quarkus/component/support/langchain4j/tracker/jdbc/JdbcIngestionTracker.java:
##########
@@ -0,0 +1,288 @@
+/*
+ * 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.support.langchain4j.tracker.jdbc;
+
+import java.sql.Connection;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Timestamp;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+
+import javax.sql.DataSource;
+
+import 
org.apache.camel.quarkus.component.support.langchain4j.tracker.IngestionTracker;
+
+/**
+ * JDBC-backed {@link IngestionTracker}. Deliberately dialect-free SQL (works 
on PostgreSQL and H2):
+ * upserts are update-then-insert rather than vendor MERGE/ON CONFLICT.
+ *
+ * <p>
+ * <strong>Concurrency assumption: at most one writer per {@code (pipeline, 
documentId)} at a
+ * time.</strong> The update-then-insert is not atomic (each statement 
auto-commits on its own,
+ * there is no transaction), so two concurrent writers for the same row can 
both see the
+ * {@code UPDATE} affect zero rows and then both attempt the {@code INSERT}, 
and one fails on the
+ * {@code (pipeline, doc_id)} primary key. Wrapping the two statements in a 
transaction would not
+ * remove this race without either {@code SERIALIZABLE} isolation or a 
dialect-specific upsert,
+ * both at odds with staying dialect-free; callers (the sync pass runner 
processes one document
+ * at a time per pipeline) are relied upon to hold this invariant instead.
+ *
+ * <p>
+ * <strong>Status: Experimental.</strong> See {@link IngestionTracker}.
+ */
+public class JdbcIngestionTracker implements IngestionTracker {
+
+    static final String TABLE = "cq_ingestion_tracker";
+
+    private final DataSource dataSource;
+
+    public JdbcIngestionTracker(DataSource dataSource) {
+        this.dataSource = dataSource;
+    }
+
+    @Override
+    public void ensureSchema() {
+        String ddl = "CREATE TABLE IF NOT EXISTS " + TABLE + " ("
+                + "pipeline VARCHAR(128) NOT NULL, "
+                + "doc_id VARCHAR(512) NOT NULL, "
+                + "fingerprint VARCHAR(256), "
+                + "content_hash VARCHAR(64), "
+                + "segment_count INT DEFAULT 0 NOT NULL, "
+                + "intended_count INT DEFAULT 0 NOT NULL, "
+                + "status VARCHAR(16) NOT NULL, "
+                + "origin VARCHAR(16) DEFAULT 'source' NOT NULL, "
+                + "tombstone BOOLEAN DEFAULT FALSE NOT NULL, "
+                + "pinned BOOLEAN DEFAULT FALSE NOT NULL, "
+                + "updated_at TIMESTAMP, "
+                + "PRIMARY KEY (pipeline, doc_id))";
+        try (Connection connection = dataSource.getConnection()) {
+            connection.createStatement().execute(ddl);
+        } catch (SQLException e) {
+            throw new IllegalStateException(
+                    "Cannot create the ingestion tracker table '" + TABLE + 
"'. "
+                            + "mode=sync needs a working datasource — 
configure quarkus.datasource "
+                            + "(Dev Services provides one in dev mode) or use 
mode=append.",
+                    e);
+        }
+    }
+
+    private static final String ROW_COLUMNS = "fingerprint, content_hash, 
segment_count, intended_count, status, "
+            + "origin, tombstone, pinned";
+
+    @Override
+    public Optional<TrackerRow> read(String pipeline, String documentId) {
+        String sql = "SELECT " + ROW_COLUMNS + " FROM " + TABLE + " WHERE 
pipeline = ? AND doc_id = ?";
+        try (Connection connection = dataSource.getConnection();
+                PreparedStatement statement = 
connection.prepareStatement(sql)) {
+            statement.setString(1, pipeline);
+            statement.setString(2, documentId);
+            try (ResultSet resultSet = statement.executeQuery()) {
+                if (!resultSet.next()) {
+                    return Optional.empty();
+                }
+                return Optional.of(row(pipeline, documentId, resultSet));
+            }
+        } catch (SQLException e) {
+            throw new IllegalStateException("Tracker read failed for '" + 
documentId + "'", e);
+        }
+    }
+
+    @Override
+    public List<TrackerRow> listDocuments(String pipeline) {
+        String sql = "SELECT doc_id, " + ROW_COLUMNS + " FROM " + TABLE + " 
WHERE pipeline = ?";
+        try (Connection connection = dataSource.getConnection();
+                PreparedStatement statement = 
connection.prepareStatement(sql)) {
+            statement.setString(1, pipeline);
+            try (ResultSet resultSet = statement.executeQuery()) {
+                List<TrackerRow> rows = new ArrayList<>();
+                while (resultSet.next()) {
+                    String documentId = resultSet.getString(1);
+                    rows.add(new TrackerRow(pipeline, documentId,
+                            resultSet.getString(2), resultSet.getString(3),
+                            resultSet.getInt(4), resultSet.getInt(5), 
resultSet.getString(6),
+                            resultSet.getString(7), resultSet.getBoolean(8), 
resultSet.getBoolean(9)));
+                }
+                return rows;
+            }
+        } catch (SQLException e) {
+            throw new IllegalStateException("Tracker listing failed for 
pipeline '" + pipeline + "'", e);
+        }
+    }
+
+    private static TrackerRow row(String pipeline, String documentId, 
ResultSet resultSet) throws SQLException {
+        return new TrackerRow(pipeline, documentId,
+                resultSet.getString(1), resultSet.getString(2),
+                resultSet.getInt(3), resultSet.getInt(4), 
resultSet.getString(5),
+                resultSet.getString(6), resultSet.getBoolean(7), 
resultSet.getBoolean(8));
+    }
+
+    @Override
+    public void writeIntent(String pipeline, String documentId, String 
fingerprint, String contentHash,
+            int committedCount, int intendedCount, String origin) {
+        try (Connection connection = dataSource.getConnection()) {
+            String update = "UPDATE " + TABLE + " SET fingerprint = ?, 
content_hash = ?, "
+                    + "intended_count = ?, status = 'in_progress', origin = ?, 
updated_at = ? "
+                    + "WHERE pipeline = ? AND doc_id = ?";
+            int updated;
+            try (PreparedStatement statement = 
connection.prepareStatement(update)) {
+                statement.setString(1, fingerprint);
+                statement.setString(2, contentHash);
+                statement.setInt(3, intendedCount);
+                statement.setString(4, origin);
+                statement.setTimestamp(5, Timestamp.from(Instant.now()));
+                statement.setString(6, pipeline);
+                statement.setString(7, documentId);
+                updated = statement.executeUpdate();
+            }
+            if (updated == 0) {
+                String insert = "INSERT INTO " + TABLE
+                        + " (pipeline, doc_id, fingerprint, content_hash, 
segment_count, intended_count, "
+                        + "status, origin, updated_at) VALUES (?, ?, ?, ?, ?, 
?, 'in_progress', ?, ?)";
+                try (PreparedStatement statement = 
connection.prepareStatement(insert)) {
+                    statement.setString(1, pipeline);
+                    statement.setString(2, documentId);
+                    statement.setString(3, fingerprint);
+                    statement.setString(4, contentHash);
+                    statement.setInt(5, committedCount);
+                    statement.setInt(6, intendedCount);
+                    statement.setString(7, origin);
+                    statement.setTimestamp(8, Timestamp.from(Instant.now()));
+                    statement.executeUpdate();
+                }
+            }
+        } catch (SQLException e) {
+            throw new IllegalStateException("Tracker intent write failed for 
'" + documentId + "'", e);
+        }
+    }
+
+    @Override
+    public void tombstone(String pipeline, String documentId) {
+        setFlag(pipeline, documentId, "tombstone", true);
+    }
+
+    @Override
+    public void unsuppress(String pipeline, String documentId) {
+        setFlag(pipeline, documentId, "tombstone", false);
+    }
+
+    @Override
+    public void pin(String pipeline, String documentId) {
+        setFlag(pipeline, documentId, "pinned", true);
+    }
+
+    @Override
+    public void unpin(String pipeline, String documentId) {
+        setFlag(pipeline, documentId, "pinned", false);
+    }
+
+    private void setFlag(String pipeline, String documentId, String column, 
boolean value) {
+        String sql = "UPDATE " + TABLE + " SET " + column + " = ?, updated_at 
= ? "
+                + "WHERE pipeline = ? AND doc_id = ?";
+        try (Connection connection = dataSource.getConnection();
+                PreparedStatement statement = 
connection.prepareStatement(sql)) {
+            statement.setBoolean(1, value);
+            statement.setTimestamp(2, Timestamp.from(Instant.now()));
+            statement.setString(3, pipeline);
+            statement.setString(4, documentId);
+            statement.executeUpdate();
+        } catch (SQLException e) {
+            throw new IllegalStateException("Tracker " + column + " update 
failed for '" + documentId + "'", e);
+        }
+    }
+
+    @Override
+    public void markFailed(String pipeline, String documentId, String 
fingerprint) {
+        try (Connection connection = dataSource.getConnection()) {
+            String update = "UPDATE " + TABLE + " SET fingerprint = ?, status 
= 'failed', updated_at = ? "
+                    + "WHERE pipeline = ? AND doc_id = ?";
+            int updated;
+            try (PreparedStatement statement = 
connection.prepareStatement(update)) {
+                statement.setString(1, fingerprint);
+                statement.setTimestamp(2, Timestamp.from(Instant.now()));
+                statement.setString(3, pipeline);
+                statement.setString(4, documentId);
+                updated = statement.executeUpdate();
+            }
+            if (updated == 0) {
+                String insert = "INSERT INTO " + TABLE
+                        + " (pipeline, doc_id, fingerprint, status, origin, 
updated_at) "
+                        + "VALUES (?, ?, ?, 'failed', 'source', ?)";
+                try (PreparedStatement statement = 
connection.prepareStatement(insert)) {
+                    statement.setString(1, pipeline);
+                    statement.setString(2, documentId);
+                    statement.setString(3, fingerprint);
+                    statement.setTimestamp(4, Timestamp.from(Instant.now()));
+                    statement.executeUpdate();
+                }
+            }
+        } catch (SQLException e) {
+            throw new IllegalStateException("Tracker dead-letter update failed 
for '" + documentId + "'", e);
+        }
+    }
+
+    @Override
+    public void deleteRow(String pipeline, String documentId) {
+        String sql = "DELETE FROM " + TABLE + " WHERE pipeline = ? AND doc_id 
= ?";
+        try (Connection connection = dataSource.getConnection();
+                PreparedStatement statement = 
connection.prepareStatement(sql)) {
+            statement.setString(1, pipeline);
+            statement.setString(2, documentId);
+            statement.executeUpdate();
+        } catch (SQLException e) {
+            throw new IllegalStateException("Tracker row deletion failed for 
'" + documentId + "'", e);
+        }
+    }
+
+    @Override
+    public void commit(String pipeline, String documentId, String fingerprint, 
String contentHash,
+            int segmentCount) {
+        String sql = "UPDATE " + TABLE + " SET fingerprint = ?, content_hash = 
?, segment_count = ?, "
+                + "intended_count = ?, status = 'done', updated_at = ? WHERE 
pipeline = ? AND doc_id = ?";

Review Comment:
   `executeUpdate()`'s row count is never checked, so `commit` on a key with no 
preceding intent writes nothing and reports success. Given the two-phase 
protocol is the whole point of the design, a commit that quietly evaporates is 
a rough thing to debug — worth failing loudly.
   
   Same shape at line 274 — `refreshFingerprint` silently no-ops when the row 
isn't `done`. That one may well be intentional, but it's undocumented.



##########
integration-tests/langchain4j-ingestion-tracker/pom.xml:
##########
@@ -0,0 +1,128 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+    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.
+
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0"; 
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"; 
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 
http://maven.apache.org/xsd/maven-4.0.0.xsd";>
+    <modelVersion>4.0.0</modelVersion>
+    <parent>
+        <groupId>org.apache.camel.quarkus</groupId>
+        <artifactId>camel-quarkus-build-parent-it</artifactId>
+        <version>3.39.0-SNAPSHOT</version>
+        <relativePath>../../poms/build-parent-it/pom.xml</relativePath>
+    </parent>
+
+    
<artifactId>camel-quarkus-integration-test-langchain4j-ingestion-tracker</artifactId>
+    <name>Camel Quarkus :: Integration Tests :: LangChain4j Sync Tracker</name>

Review Comment:
   Leftover from the `sync-ledger` naming — should be "Ingestion Tracker".



##########
integration-tests/langchain4j-ingestion-tracker/src/main/java/org/apache/camel/quarkus/component/langchain4j/ingestiontracker/it/IngestionTrackerResource.java:
##########
@@ -0,0 +1,135 @@
+/*
+ * 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.ingestiontracker.it;
+
+import java.util.List;
+
+import javax.sql.DataSource;
+
+import jakarta.annotation.PostConstruct;
+import jakarta.inject.Inject;
+import jakarta.ws.rs.DELETE;
+import jakarta.ws.rs.GET;
+import jakarta.ws.rs.NotFoundException;
+import jakarta.ws.rs.POST;
+import jakarta.ws.rs.Path;
+import jakarta.ws.rs.PathParam;
+import jakarta.ws.rs.QueryParam;
+import 
org.apache.camel.quarkus.component.support.langchain4j.tracker.IngestionTracker;
+import 
org.apache.camel.quarkus.component.support.langchain4j.tracker.IngestionTracker.TrackerRow;
+import 
org.apache.camel.quarkus.component.support.langchain4j.tracker.jdbc.JdbcIngestionTracker;
+
+/**
+ * Exercises {@link JdbcIngestionTracker} against a real datasource. A REST 
resource rather than a
+ * directly injected test field because {@code @QuarkusIntegrationTest} runs 
the application as a
+ * separate process and cannot use {@code @Inject}.
+ */
+@Path("/ingestion-tracker")
+public class IngestionTrackerResource {
+
+    @Inject
+    DataSource dataSource;
+
+    private IngestionTracker tracker;
+
+    @PostConstruct

Review Comment:
   DDL in `@PostConstruct` on an unscoped JAX-RS resource. `@ApplicationScoped` 
with a `StartupEvent` observer would be more predictable.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to