This is an automated email from the ASF dual-hosted git repository.
apupier 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 bccff2a2fb15 CAMEL-25608: camel-ical - parse each unmarshal with its
own CalendarBuilder
bccff2a2fb15 is described below
commit bccff2a2fb1556c3e1bc721bf89d7980a2a49f61
Author: smjain <[email protected]>
AuthorDate: Tue Oct 13 07:31:59 2026 +0530
CAMEL-25608: camel-ical - parse each unmarshal with its own CalendarBuilder
ICalDataFormat created one ical4j CalendarBuilder in doStart and used it
for every exchange without a lock. A CalendarBuilder keeps the calendar
it is parsing in its fields (the content handler collects the properties
and components, build() returns the calendar field), so two exchanges
that were unmarshalled at the same time got the events of each other, or
one of them got the calendar of the other, without any error.
The builder also has a time zone registry, and the shared one kept the
VTIMEZONE definitions of all messages, also without any concurrency
(seen with ical4j 4.4.0): a calendar that used a TZID it did not define
was read with the definition of an earlier message, a calendar that
defined a TZID again with another offset was read with the first
definition, and every parsed VTIMEZONE took one of the 1500 zone ids of
ical4j, which were never given back, so the 1501st calendar with a
VTIMEZONE and all later ones failed with "Ran out of available zone
IDs".
unmarshal now creates a CalendarBuilder per call and the field is gone.
Each calendar is read with its own time zone definitions; a TZID that is
neither standard nor defined in the calendar gives a local date-time, as
the 4.24 upgrade guide says. The zone ids of a calendar are free again
after the calendar has been garbage collected, so the limit of 1500 is
now on the calendars with a VTIMEZONE that are alive at one time.
Two new tests. One holds the stream of a calendar between its two events
until a second calendar has been unmarshalled by the same route; without
the fix the first exchange gets [b1, b2, a2] instead of [a1, a2]. The
other unmarshals a calendar that defines Custom/A and then one that only
uses it; without the fix the second is read with the offset of the
first.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
.../camel/component/ical/ICalDataFormat.java | 5 +-
.../ICalDataFormatConcurrentUnmarshalTest.java | 164 +++++++++++++++++++++
.../component/ical/ICalDataFormatTimeZoneTest.java | 77 ++++++++++
.../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc | 12 ++
4 files changed, 255 insertions(+), 3 deletions(-)
diff --git
a/components/camel-ical/src/main/java/org/apache/camel/component/ical/ICalDataFormat.java
b/components/camel-ical/src/main/java/org/apache/camel/component/ical/ICalDataFormat.java
index 79012c783b59..61ee10bf2985 100644
---
a/components/camel-ical/src/main/java/org/apache/camel/component/ical/ICalDataFormat.java
+++
b/components/camel-ical/src/main/java/org/apache/camel/component/ical/ICalDataFormat.java
@@ -37,7 +37,6 @@ public class ICalDataFormat extends ServiceSupport implements
DataFormat, DataFo
private boolean validating;
private CalendarOutputter outputer;
- private CalendarBuilder builder;
@Override
public String getDataFormatName() {
@@ -52,13 +51,13 @@ public class ICalDataFormat extends ServiceSupport
implements DataFormat, DataFo
@Override
public Object unmarshal(Exchange exchange, InputStream stream) throws
Exception {
- return builder.build(stream);
+ // a CalendarBuilder keeps the calendar it is building in its fields,
so each call needs its own
+ return new CalendarBuilder().build(stream);
}
@Override
protected void doStart() throws Exception {
outputer = new CalendarOutputter(validating);
- builder = new CalendarBuilder();
}
@Override
diff --git
a/components/camel-ical/src/test/java/org/apache/camel/component/ical/ICalDataFormatConcurrentUnmarshalTest.java
b/components/camel-ical/src/test/java/org/apache/camel/component/ical/ICalDataFormatConcurrentUnmarshalTest.java
new file mode 100644
index 000000000000..1633bc2a4688
--- /dev/null
+++
b/components/camel-ical/src/test/java/org/apache/camel/component/ical/ICalDataFormatConcurrentUnmarshalTest.java
@@ -0,0 +1,164 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.component.ical;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+
+import net.fortuna.ical4j.model.Calendar;
+import net.fortuna.ical4j.model.Component;
+import net.fortuna.ical4j.model.Property;
+import net.fortuna.ical4j.model.component.CalendarComponent;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Two exchanges are unmarshalled by the same data format at the same time.
The stream of the first one stops between
+ * its two events until the second exchange has been unmarshalled, so the two
calls always overlap.
+ */
+class ICalDataFormatConcurrentUnmarshalTest extends CamelTestSupport {
+
+ private static final String HEAD =
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Events Calendar//iCal4j
1.0//EN\r\n";
+
+ @Test
+ void overlappingUnmarshalsGetTheirOwnCalendar() throws Exception {
+ CountDownLatch firstIsBetweenItsEvents = new CountDownLatch(1);
+ CountDownLatch secondIsDone = new CountDownLatch(1);
+
+ // the stream ends in the middle of a line, so the parser has handled
the first event completely and has not
+ // started the second one when it asks for more
+ byte[] firstPart = (HEAD + event("a1") +
"BEGIN:VEV").getBytes(StandardCharsets.UTF_8);
+ byte[] secondPart = ("ENT\r\nUID:a2\r\nSUMMARY:a2\r\nEND:VEVENT\r\n" +
"END:VCALENDAR\r\n")
+ .getBytes(StandardCharsets.UTF_8);
+ InputStream first = new TwoPartStream(firstPart, secondPart,
firstIsBetweenItsEvents, secondIsDone);
+ byte[] second = (HEAD + event("b1") + event("b2") +
"END:VCALENDAR\r\n").getBytes(StandardCharsets.UTF_8);
+
+ ExecutorService executor = Executors.newSingleThreadExecutor();
+ try {
+ Future<Calendar> firstResult
+ = executor.submit(() ->
template.requestBody("direct:unmarshal", first, Calendar.class));
+
+ assertTrue(firstIsBetweenItsEvents.await(20, TimeUnit.SECONDS),
"the first unmarshal did not read its stream");
+ Calendar secondCalendar;
+ try {
+ secondCalendar = template.requestBody("direct:unmarshal",
second, Calendar.class);
+ } finally {
+ secondIsDone.countDown();
+ }
+ Calendar firstCalendar = firstResult.get(20, TimeUnit.SECONDS);
+
+ assertEquals(List.of("a1", "a2"), summaries(firstCalendar),
"events of the first calendar");
+ assertEquals(List.of("b1", "b2"), summaries(secondCalendar),
"events of the second calendar");
+ } finally {
+ secondIsDone.countDown();
+ executor.shutdownNow();
+ }
+ }
+
+ private static String event(String name) {
+ return "BEGIN:VEVENT\r\nUID:" + name + "\r\nSUMMARY:" + name +
"\r\nEND:VEVENT\r\n";
+ }
+
+ private static List<String> summaries(Calendar calendar) {
+ return calendar.<CalendarComponent>
getComponents(Component.VEVENT).stream()
+ .map(event ->
event.getRequiredProperty(Property.SUMMARY).getValue())
+ .toList();
+ }
+
+ @Override
+ protected RouteBuilder createRouteBuilder() {
+ return new RouteBuilder() {
+ @Override
+ public void configure() {
+ // without stream caching the data format reads the stream of
the test itself, so the test can hold
+ // the first call inside unmarshal; the two calls would
overlap with stream caching too, but not at
+ // a point the test controls
+
from("direct:unmarshal").noStreamCaching().unmarshal().ical(false);
+ }
+ };
+ }
+
+ /**
+ * Returns the first part, then reports that it was read and waits
(bounded) for the gate before it returns the
+ * second part.
+ */
+ private static final class TwoPartStream extends InputStream {
+ private final byte[] firstPart;
+ private final byte[] secondPart;
+ private final CountDownLatch firstPartRead;
+ private final CountDownLatch gate;
+ private int position;
+ private boolean gatePassed;
+
+ private TwoPartStream(byte[] firstPart, byte[] secondPart,
CountDownLatch firstPartRead, CountDownLatch gate) {
+ this.firstPart = firstPart;
+ this.secondPart = secondPart;
+ this.firstPartRead = firstPartRead;
+ this.gate = gate;
+ }
+
+ @Override
+ public int read() throws IOException {
+ byte[] one = new byte[1];
+ return read(one, 0, 1) < 0 ? -1 : one[0] & 0xFF;
+ }
+
+ @Override
+ public int read(byte[] target, int offset, int length) throws
IOException {
+ if (length == 0) {
+ return 0;
+ }
+ if (position < firstPart.length) {
+ int count = Math.min(length, firstPart.length - position);
+ System.arraycopy(firstPart, position, target, offset, count);
+ position += count;
+ return count;
+ }
+ if (!gatePassed) {
+ firstPartRead.countDown();
+ try {
+ if (!gate.await(20, TimeUnit.SECONDS)) {
+ throw new IOException("the second unmarshal did not
finish in time");
+ }
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new IOException(e);
+ }
+ gatePassed = true;
+ }
+ int done = position - firstPart.length;
+ if (done >= secondPart.length) {
+ return -1;
+ }
+ int count = Math.min(length, secondPart.length - done);
+ System.arraycopy(secondPart, done, target, offset, count);
+ position += count;
+ return count;
+ }
+ }
+}
diff --git
a/components/camel-ical/src/test/java/org/apache/camel/component/ical/ICalDataFormatTimeZoneTest.java
b/components/camel-ical/src/test/java/org/apache/camel/component/ical/ICalDataFormatTimeZoneTest.java
new file mode 100644
index 000000000000..faad3dc03e79
--- /dev/null
+++
b/components/camel-ical/src/test/java/org/apache/camel/component/ical/ICalDataFormatTimeZoneTest.java
@@ -0,0 +1,77 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.component.ical;
+
+import java.nio.charset.StandardCharsets;
+import java.time.LocalDateTime;
+import java.time.ZoneOffset;
+import java.time.ZonedDateTime;
+import java.time.temporal.Temporal;
+
+import net.fortuna.ical4j.model.Calendar;
+import net.fortuna.ical4j.model.Component;
+import net.fortuna.ical4j.model.component.VEvent;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+
+/**
+ * Two calendars are unmarshalled one after the other by the same data format.
The time zone that the first calendar
+ * defines must not be used for the second calendar, which names it without
defining it.
+ */
+class ICalDataFormatTimeZoneTest extends CamelTestSupport {
+
+ private static final String HEAD =
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Events Calendar//iCal4j
1.0//EN\r\n";
+ private static final String CUSTOM_ZONE =
"BEGIN:VTIMEZONE\r\nTZID:Custom/A\r\nBEGIN:STANDARD\r\n"
+ +
"DTSTART:19700101T000000\r\nTZOFFSETFROM:+0300\r\nTZOFFSETTO:+0300\r\n"
+ +
"TZNAME:A\r\nEND:STANDARD\r\nEND:VTIMEZONE\r\n";
+ private static final String EVENT =
"BEGIN:VEVENT\r\nUID:e1\r\nDTSTAMP:20260101T000000Z\r\nSUMMARY:e1\r\n"
+ +
"DTSTART;TZID=Custom/A:20260101T120000\r\nEND:VEVENT\r\n";
+ private static final String END = "END:VCALENDAR\r\n";
+
+ @Test
+ void timeZoneOfAnEarlierCalendarIsNotUsed() {
+ Temporal defined = startOfTheEvent(HEAD + CUSTOM_ZONE + EVENT + END);
+ Temporal notDefined = startOfTheEvent(HEAD + EVENT + END);
+
+ assertEquals(ZoneOffset.ofHours(3),
assertInstanceOf(ZonedDateTime.class, defined).getOffset(),
+ "the calendar that defines the time zone");
+ // ical4j reads a date with a time zone it does not know as a local
date-time without a zone
+ assertEquals(LocalDateTime.of(2026, 1, 1, 12, 0), notDefined,
+ "the calendar that does not define the time zone");
+ }
+
+ private Temporal startOfTheEvent(String calendarText) {
+ Calendar calendar = template.requestBody("direct:unmarshal",
calendarText.getBytes(StandardCharsets.UTF_8),
+ Calendar.class);
+ VEvent event = (VEvent)
calendar.getComponents(Component.VEVENT).get(0);
+ return event.getDateTimeStart().getDate();
+ }
+
+ @Override
+ protected RouteBuilder createRouteBuilder() {
+ return new RouteBuilder() {
+ @Override
+ public void configure() {
+ from("direct:unmarshal").unmarshal().ical(false);
+ }
+ };
+ }
+}
diff --git
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
index 8ebdb395da0c..47d66491ea51 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
@@ -85,6 +85,18 @@ now resolves to no value, as it already did without a field,
so its default valu
(`{{?hashicorp:secret:missing#field}}`) applies. Before, without a default
value, it failed with a
`NullPointerException`.
+=== camel-ical - each unmarshal parses with its own calendar builder
+
+The `ical` data format used one ical4j `CalendarBuilder` for all exchanges.
Two exchanges that were unmarshalled at
+the same time could get the events of each other, or the calendar of the other
exchange. Each unmarshal now uses its
+own builder.
+
+A builder also has its own time zone registry, so a `VTIMEZONE` of one
calendar is no longer used for the calendars
+unmarshalled after it. A date with a `TZID` that is not a standard time zone
and that its calendar does not define is
+now read as a local date-time without a zone (`getDate()` returns a
`LocalDateTime`); before, it was read with the
+definition of an earlier message, if there was one. A calendar that defines a
`TZID` an earlier message has defined
+differently is now read with its own definition.
+
=== camel-lra - only an LRA hosted by the configured coordinator is joined
The LRA saga service now joins, completes or compensates an LRA only when its
URL is under the configured LRA