shashank created CAMEL-25608:
--------------------------------
Summary: camel-ical - the ical data format parses all exchanges
with one CalendarBuilder, so two calendars unmarshalled at the same time get
the events of each other
Key: CAMEL-25608
URL: https://issues.apache.org/jira/browse/CAMEL-25608
Project: Camel
Issue Type: Bug
Reporter: shashank
Assignee: shashank
{{ICalDataFormat}} creates one ical4j {{CalendarBuilder}} in {{doStart}} (line
61 at main a7169d7c6ab6) and {{unmarshal}} calls {{builder.build(stream)}} on
it for every exchange (line 55), without a lock. A data format instance belongs
to an {{unmarshal}} node of a route (or is a shared bean), so all threads that
run the route use the same builder.
A {{CalendarBuilder}} is not made for that (ical4j 4.4.0, read from the class
files):
* {{build(UnfoldingReader)}} is {{parser.parse(reader, contentHandler); return
calendar;}}. {{contentHandler}} and {{calendar}} are fields of the builder.
* the {{DefaultContentHandler}} keeps the calendar it is building in its fields
({{calendarProperties}}, {{calendarComponents}}, the stack of open components,
the current property). {{startCalendar}} replaces the two lists,
{{endComponent}} adds to them, {{endCalendar}} creates the {{Calendar}} from
them and hands it to the builder, which stores it in the {{calendar}} field.
So when two exchanges are in {{unmarshal}} at the same time:
* the second {{startCalendar}} throws away the events the first exchange has
parsed so far, and the remaining events of the first exchange are added to the
calendar of the second one;
* an exchange can return the {{calendar}} field after the other exchange has
written it, that is the whole calendar of the other exchange;
* when the two parsers are inside a component or a property at the same time,
the open component of one exchange gets the properties or sub-components of the
other, or the parse fails.
No exception is needed for the wrong result: the route continues with a
{{Calendar}} that has the events of another message. Any route that unmarshals
ical with more than one thread is affected, with default options, for example
{{from("jms:queue:invites?concurrentConsumers=5").unmarshal().ical(false)}}, a
{{seda}} endpoint with {{concurrentConsumers}}, a file consumer followed by
{{threads()}}, or an HTTP consumer ({{platform-http}}, {{jetty}},
{{undertow}}), which calls the route on the threads of the server. With stream
caching (the default) the body is in memory when {{unmarshal}} runs, so the
window is the parse itself; it gets longer with the size of the calendar.
h3. Time zones of earlier messages (no concurrency needed)
The shared builder also has one {{TimeZoneRegistryImpl}}, in which the content
handler registers every {{VTIMEZONE}} it parses. Seen on main through the data
format with ical4j 4.4.0, one route, one thread:
* a calendar that uses {{TZID=Custom/A}} without a {{VTIMEZONE}} is read with
the {{+03:00}} of an earlier calendar that defined {{Custom/A}}. As the first
message the same calendar gets a local date-time without a zone.
* a calendar that defines a {{TZID}} again with another offset ({{+0700}}) is
read with the offset of the first definition ({{+03:00}}), also when it is sent
a second time. {{TimeZoneRegistryImpl.getZoneId}} takes the first entry of a
{{HashMap}} that has this {{TZID}} as value, so which definition wins is not
specified; it was the old one in each of the four reads I made.
* every parsed {{VTIMEZONE}} takes one zone id from a pool of ical4j with 1500
ids ({{ZoneRulesProviderImpl$ZoneIdPool}}). With the shared builder they do not
come back: a loop that unmarshalled the same small calendar with one
{{VTIMEZONE}} failed at the 1501st message with {{ParserException: Error at
line 12:Ran out of available zone IDs}}, and so did the next message, also
after a {{System.gc()}}. So a route that receives calendars with a
{{VTIMEZONE}} fails for them after 1500 such messages.
{{marshal}} is not affected: {{CalendarOutputter}} keeps only the
{{validating}} flag and the fold length.
h3. Reproduction
New
{{ICalDataFormatConcurrentUnmarshalTest.overlappingUnmarshalsGetTheirOwnCalendar}}:
a route {{direct:unmarshal -> unmarshal().ical(false)}}, two calendars with
the events {{a1, a2}} and {{b1, b2}}. The stream of the first calendar gives
the text up to the start of its second event and then waits (bounded) until the
second calendar has been unmarshalled by another thread, so the two calls
overlap in every run. The route has {{noStreamCaching()}} only so that the data
format reads the stream of the test and the test can hold the first call inside
{{unmarshal}}.
On main: {{events of the first calendar ==> expected: <[a1, a2]> but was: <[b1,
b2, a2]>}} in every run (three runs by the rerun setting of the build): the
first exchange lost its first event and got the two events of the second
exchange.
With the fix both exchanges get their own two events.
New {{ICalDataFormatTimeZoneTest.timeZoneOfAnEarlierCalendarIsNotUsed}}, one
thread, default stream caching: a calendar that defines {{Custom/A}}
({{+0300}}) is unmarshalled, then a calendar that uses {{TZID=Custom/A}}
without defining it. On main: {{the calendar that does not define the time zone
==> expected: <2026-01-01T12:00> but was:
<2026-01-01T12:00+03:00[ical4j-local-...]>}} in every run. With the fix the
second date is the local date-time.
The redefined {{TZID}} and the zone id pool were checked with a throwaway test
through the same route (not part of the patch).
h3. Proposed fix
{{unmarshal}} creates a {{CalendarBuilder}} per call ({{new
CalendarBuilder().build(stream)}}), as the {{Calendars.load}} helpers of ical4j
do; the field is removed. The constructor looks up the parser factory and
creates a {{TimeZoneRegistryImpl}} (three empty maps; the standard time zones
are in a static cache of ical4j), so the cost per message is small next to the
parse. I did not measure it.
Behaviour change, with a 4.24 upgrade guide entry: each calendar is read with
its own time zone definitions only. With the fix, the date of a calendar that
uses {{TZID=Custom/A}} without defining it is a {{LocalDateTime}}
({{2026-01-01T12:00}}) whatever was unmarshalled before; {{TZID=Europe/Berlin}}
without a {{VTIMEZONE}} still resolves ({{+01:00}}); a redefined {{TZID}} is
read with its own offset. {{getDateTimeStart().getDate()}} did not throw for
the unknown {{TZID}}, with relaxed parsing on or off.
Remaining limit of the zone id pool: with the fix the ids of a calendar are
free again after the calendar has been garbage collected, not before. 1500
unmarshalled calendars with a {{VTIMEZONE}} that are all still referenced
exhaust the pool (the 1501st unmarshal failed with the message above). A loop
that did not keep the calendars and left the collector no time failed once
after 2592 calendars and worked again after a {{System.gc()}}; that number
depends on the collector and the heap. The pool size is the ical4j property
{{net.fortuna.ical4j.timezone.id.pool.size}} (read from the class file, not
tried). There is no test for the pool in the patch, because with the fix its
result depends on GC timing.
I did not keep one registry for all builders: {{TimeZoneRegistryImpl}} is not
documented as thread-safe and one of its three maps ({{zoneIds}}) is a plain
{{HashMap}}, so a shared registry could be the same kind of defect.
camel-ical tests with the fix: 4 run, 0 failures (the two new tests and the two
existing tests of {{ICalDataFormatTest}}).
How it was found: a sweep for per-exchange state kept in fields of objects that
are shared by concurrent exchanges, after CAMEL-25552. A small TLA+ model of
two unmarshals on one builder (actions: startCalendar, endComponent per event,
endCalendar, return of the calendar field) violates "each exchange gets the
events of its own stream" in a trace of 9 states (start b, start a, a adds its
two events, b adds one, a ends and returns a1, a2, b1); with a builder per
exchange the property holds for two and three exchanges. The model covers only
the list of components and the result field, not the state inside a component
or property. I then reproduced it with the real data format.
Affected: main (a7169d7c6ab6; tested), camel-4.23.x, camel-4.22.x, camel-4.18.x
and camel-4.14.x (the same three lines, 40, 55 and 61 of {{ICalDataFormat}};
read, not run). The time zone results are from main with ical4j 4.4.0 (also the
version of camel-4.23.x); camel-4.22.x has ical4j 4.3.0, camel-4.18.x 4.2.3 and
camel-4.14.x 4.1.1, where they were not run. The code is the one of CAMEL-6320
(2.12).
Duplicate check (2026-10-13): JIRA text "CalendarBuilder" (none),
"ICalDataFormat" (2: CAMEL-20375 a configurer NPE, CAMEL-13548 a Windows build
failure), "ical thread" and "ical concurrent" (3 each, none about camel-ical),
"camel-ical" (13, none about concurrency); GitHub pull requests "ical" in
title: none about the builder.
_Filed with Claude Code on behalf of allthingssecurity._
--
This message was sent by Atlassian Jira
(v8.20.10#820010)