[ 
https://issues.apache.org/jira/browse/CAMEL-25608?page=com.atlassian.jira.plugin.system.issuetabpanels:all-tabpanel
 ]

Work on CAMEL-25608 started by shashank.
----------------------------------------
> 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
>            Priority: Major
>
> {{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)

Reply via email to