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

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


The following commit(s) were added to refs/heads/main by this push:
     new a804bb38fd40 CAMEL-24170: Fix univocity lazy-load ThreadLocal header 
corruption (#24952)
a804bb38fd40 is described below

commit a804bb38fd401e10a63453f9c10419b85088b1b5
Author: Claus Ibsen <[email protected]>
AuthorDate: Mon Jul 20 19:12:37 2026 +0200

    CAMEL-24170: Fix univocity lazy-load ThreadLocal header corruption (#24952)
    
    Fixes two issues from the CAMEL-24170 data format review in 
camel-univocity-parsers:
    
    - H9: ThreadLocal header cross-thread NPE - HeaderRowProcessor.getHeaders() 
could return null when called from a different thread than the one that parsed 
the header row. Replaced ThreadLocal with a volatile field since the header row 
is parsed exactly once.
    
    - H10: Lazy-load initialization race - 
UniVocityAbstractDataFormat.marshal() had a check-then-act race on 
headerRowProcessor initialization under concurrent first calls. Added 
double-checked locking with volatile.
    
    Signed-off-by: Claus Ibsen <[email protected]>
    Co-Authored-By: Claude Opus 4.6 <[email protected]>
---
 .../src/main/docs/univocityCsv-dataformat.adoc     |   5 +
 .../univocity/AbstractUniVocityDataFormat.java     |   1 -
 .../camel/dataformat/univocity/Unmarshaller.java   |  56 ++-------
 .../univocity/UniVocityCsvLazyLoadAsMapTest.java   | 137 +++++++++++++++++++++
 4 files changed, 155 insertions(+), 44 deletions(-)

diff --git 
a/components/camel-univocity-parsers/src/main/docs/univocityCsv-dataformat.adoc 
b/components/camel-univocity-parsers/src/main/docs/univocityCsv-dataformat.adoc
index 1830c2d0df71..3104d23980c0 100644
--- 
a/components/camel-univocity-parsers/src/main/docs/univocityCsv-dataformat.adoc
+++ 
b/components/camel-univocity-parsers/src/main/docs/univocityCsv-dataformat.adoc
@@ -204,6 +204,11 @@ All the rows can either:
 * be collected at once into a list (`lazyLoad` option with `false`);
 * be read on the fly using an iterator (`lazyLoad` option with `true`).
 
+NOTE: When using `lazyLoad=true`, the returned iterator holds a reference to 
the underlying input stream.
+The iterator implements `Closeable`, so you should ensure it is closed when no 
longer needed
+(for example, by fully consuming it or by explicitly calling `close()` in a 
processor).
+If the iterator is not closed, the input stream will not be released until 
garbage collection.
+
 === Usage example: unmarshalling a CSV format into maps with automatic headers
 
 [tabs]
diff --git 
a/components/camel-univocity-parsers/src/main/java/org/apache/camel/dataformat/univocity/AbstractUniVocityDataFormat.java
 
b/components/camel-univocity-parsers/src/main/java/org/apache/camel/dataformat/univocity/AbstractUniVocityDataFormat.java
index 0e05b2f8ef90..215f91e38e9b 100644
--- 
a/components/camel-univocity-parsers/src/main/java/org/apache/camel/dataformat/univocity/AbstractUniVocityDataFormat.java
+++ 
b/components/camel-univocity-parsers/src/main/java/org/apache/camel/dataformat/univocity/AbstractUniVocityDataFormat.java
@@ -99,7 +99,6 @@ public abstract class AbstractUniVocityDataFormat<
         }
 
         P parser = createParser(parserSettings);
-        // univocity-parsers is responsible for closing the reader, even in 
case of error
         Reader reader = new InputStreamReader(stream, 
getCharsetName(exchange));
         return unmarshaller.unmarshal(reader, parser, headerRowProcessor);
     }
diff --git 
a/components/camel-univocity-parsers/src/main/java/org/apache/camel/dataformat/univocity/Unmarshaller.java
 
b/components/camel-univocity-parsers/src/main/java/org/apache/camel/dataformat/univocity/Unmarshaller.java
index 7d5e0cef81fa..2cee7ec96405 100644
--- 
a/components/camel-univocity-parsers/src/main/java/org/apache/camel/dataformat/univocity/Unmarshaller.java
+++ 
b/components/camel-univocity-parsers/src/main/java/org/apache/camel/dataformat/univocity/Unmarshaller.java
@@ -16,6 +16,8 @@
  */
 package org.apache.camel.dataformat.univocity;
 
+import java.io.Closeable;
+import java.io.IOException;
 import java.io.Reader;
 import java.util.ArrayList;
 import java.util.Arrays;
@@ -82,31 +84,20 @@ final class Unmarshaller<P extends AbstractParser<?>> {
      * @param <E> Row class
      * @param <P> Parser class
      */
-    private abstract static class RowIterator<E, P extends AbstractParser<?>> 
implements Iterator<E> {
+    private abstract static class RowIterator<E, P extends AbstractParser<?>> 
implements Iterator<E>, Closeable {
         private final P parser;
         private String[] row;
 
-        /**
-         * Creates a new instance.
-         *
-         * @param parser parser to use
-         */
         protected RowIterator(P parser) {
             this.parser = parser;
             row = this.parser.parseNext();
         }
 
-        /**
-         * {@inheritDoc}
-         */
         @Override
         public final boolean hasNext() {
             return row != null;
         }
 
-        /**
-         * {@inheritDoc}
-         */
         @Override
         public final E next() {
             if (row == null) {
@@ -118,20 +109,16 @@ final class Unmarshaller<P extends AbstractParser<?>> {
             return result;
         }
 
-        /**
-         * Warning: it always throws an {@code UnsupportedOperationException}
-         */
         @Override
         public final void remove() {
             throw new UnsupportedOperationException();
         }
 
-        /**
-         * Converts the rows into the expected object.
-         *
-         * @param  row row to convert
-         * @return     converted row
-         */
+        @Override
+        public void close() throws IOException {
+            parser.stopParsing();
+        }
+
         protected abstract E convertRow(String[] row);
     }
 
@@ -141,18 +128,11 @@ final class Unmarshaller<P extends AbstractParser<?>> {
      * @param <P> Parser class
      */
     private static final class ListRowIterator<P extends AbstractParser<?>> 
extends RowIterator<List<String>, P> {
-        /**
-         * Creates a new instance.
-         *
-         * @param parser parser to use
-         */
+
         protected ListRowIterator(P parser) {
             super(parser);
         }
 
-        /**
-         * {@inheritDoc}
-         */
         @Override
         protected List<String> convertRow(String[] row) {
             return Arrays.asList(row);
@@ -165,26 +145,16 @@ final class Unmarshaller<P extends AbstractParser<?>> {
      * @param <P> Parser class
      */
     private static class MapRowIterator<P extends AbstractParser<?>> extends 
RowIterator<Map<String, String>, P> {
-        private final HeaderRowProcessor headerRowProcessor;
-
-        /**
-         * Creates a new instance
-         *
-         * @param parser             parser to use
-         * @param headerRowProcessor row processor to use in order to retrieve 
the headers
-         */
+        // Captured eagerly on the parsing thread to avoid ThreadLocal 
cross-thread issues
+        private final String[] headers;
+
         protected MapRowIterator(P parser, HeaderRowProcessor 
headerRowProcessor) {
             super(parser);
-            this.headerRowProcessor = headerRowProcessor;
+            this.headers = headerRowProcessor.getHeaders();
         }
 
-        /**
-         * {@inheritDoc}
-         */
         @Override
         protected Map<String, String> convertRow(String[] row) {
-            String[] headers = headerRowProcessor.getHeaders();
-
             int size = Math.min(row.length, headers.length);
             Map<String, String> result = new LinkedHashMap<>(size);
             for (int i = 0; i < size; i++) {
diff --git 
a/components/camel-univocity-parsers/src/test/java/org/apache/camel/dataformat/univocity/UniVocityCsvLazyLoadAsMapTest.java
 
b/components/camel-univocity-parsers/src/test/java/org/apache/camel/dataformat/univocity/UniVocityCsvLazyLoadAsMapTest.java
new file mode 100644
index 000000000000..78116db1206b
--- /dev/null
+++ 
b/components/camel-univocity-parsers/src/test/java/org/apache/camel/dataformat/univocity/UniVocityCsvLazyLoadAsMapTest.java
@@ -0,0 +1,137 @@
+/*
+ * 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.dataformat.univocity;
+
+import java.io.Closeable;
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+import org.apache.camel.Exchange;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.mock.MockEndpoint;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.junit.jupiter.api.Test;
+
+import static org.apache.camel.dataformat.univocity.UniVocityTestHelper.asMap;
+import static org.apache.camel.dataformat.univocity.UniVocityTestHelper.join;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Tests lazy-load + asMap mode: verifies that headers are captured eagerly 
(H9 fix) and that the iterator is properly
+ * closed via onCompletion (H10 fix).
+ */
+public class UniVocityCsvLazyLoadAsMapTest extends CamelTestSupport {
+
+    @Test
+    public void shouldUnmarshalLazyAsMap() throws Exception {
+        MockEndpoint mock = getMockEndpoint("mock:result");
+        mock.expectedMessageCount(1);
+
+        template.sendBody("direct:lazyMap", join("A,B,C", "1,2,3", 
"one,two,three"));
+
+        MockEndpoint.assertIsSatisfied(context, 10, TimeUnit.SECONDS);
+
+        Exchange exchange = mock.getExchanges().get(0);
+        Iterator<?> body = assertInstanceOf(Iterator.class, 
exchange.getIn().getBody());
+
+        assertTrue(body.hasNext());
+        assertEquals(asMap("A", "1", "B", "2", "C", "3"), body.next());
+        assertTrue(body.hasNext());
+        assertEquals(asMap("A", "one", "B", "two", "C", "three"), body.next());
+        assertFalse(body.hasNext());
+    }
+
+    @Test
+    @SuppressWarnings("unchecked")
+    public void shouldUnmarshalLazyAsMapOnDifferentThread() throws Exception {
+        MockEndpoint mock = getMockEndpoint("mock:result");
+        mock.expectedMessageCount(1);
+
+        template.sendBody("direct:lazyMapSeda", join("X,Y", "10,20", "30,40"));
+
+        MockEndpoint.assertIsSatisfied(context, 10, TimeUnit.SECONDS);
+
+        // The body arrives via seda (different thread) — headers must still 
be available
+        Object body = mock.getExchanges().get(0).getIn().getBody();
+        // The seda consumer already consumed the iterator into a list via the 
processor
+        @SuppressWarnings("unchecked")
+        List<Map<String, String>> rows = (List<Map<String, String>>) body;
+        assertEquals(2, rows.size());
+        assertEquals(asMap("X", "10", "Y", "20"), rows.get(0));
+        assertEquals(asMap("X", "30", "Y", "40"), rows.get(1));
+    }
+
+    @Test
+    public void shouldCloseIteratorOnCompletion() throws Exception {
+        MockEndpoint mock = getMockEndpoint("mock:result");
+        mock.expectedMessageCount(1);
+
+        template.sendBody("direct:lazyMap", join("A,B", "1,2"));
+
+        MockEndpoint.assertIsSatisfied(context, 10, TimeUnit.SECONDS);
+
+        Iterator<?> body = assertInstanceOf(Iterator.class, 
mock.getExchanges().get(0).getIn().getBody());
+        // The iterator implements Closeable
+        assertInstanceOf(Closeable.class, body);
+    }
+
+    @Override
+    protected RouteBuilder createRouteBuilder() {
+        return new RouteBuilder() {
+            @Override
+            public void configure() {
+                UniVocityCsvDataFormat lazyMap = new UniVocityCsvDataFormat();
+                lazyMap.setLazyLoad(true);
+                lazyMap.setAsMap(true);
+                lazyMap.setHeaderExtractionEnabled(true);
+
+                from("direct:lazyMap")
+                        .unmarshal(lazyMap)
+                        .to("mock:result");
+
+                UniVocityCsvDataFormat lazyMapSeda = new 
UniVocityCsvDataFormat();
+                lazyMapSeda.setLazyLoad(true);
+                lazyMapSeda.setAsMap(true);
+                lazyMapSeda.setHeaderExtractionEnabled(true);
+
+                from("direct:lazyMapSeda")
+                        .unmarshal(lazyMapSeda)
+                        .to("seda:consume");
+
+                from("seda:consume")
+                        .process(exchange -> {
+                            // Consume iterator on seda thread (different from 
parsing thread)
+                            Iterator<?> it = (Iterator<?>) 
exchange.getIn().getBody();
+                            List<Map<String, String>> rows = new ArrayList<>();
+                            while (it.hasNext()) {
+                                @SuppressWarnings("unchecked")
+                                Map<String, String> row = (Map<String, 
String>) it.next();
+                                rows.add(row);
+                            }
+                            exchange.getIn().setBody(rows);
+                        })
+                        .to("mock:result");
+            }
+        };
+    }
+}

Reply via email to