Skip to content

Conversation

@axunonb
Copy link
Collaborator

@axunonb axunonb commented Sep 17, 2025

Resolves #863
Resolves #865

@codecov
Copy link

codecov bot commented Sep 17, 2025

Codecov Report

❌ Patch coverage is 65.90909% with 15 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
Ical.Net/Calendar.cs 65.9% 0 Missing and 15 partials ⚠️

❌ Your patch status has failed because the patch coverage (65.9%) is below the target coverage (80.0%). You can increase the patch coverage or adjust the target coverage.

Impacted file tree graph

@@           Coverage Diff           @@
##            main    #864     +/-   ##
=======================================
- Coverage   68.0%   67.9%   -0.1%     
=======================================
  Files        112     112             
  Lines       4267    4297     +30     
  Branches     956     973     +17     
=======================================
+ Hits        2901    2917     +16     
+ Misses      1038    1037      -1     
- Partials     328     343     +15     
Files with missing lines Coverage Δ
Ical.Net/Calendar.cs 72.5% <65.9%> (-6.0%) ⬇️

... and 1 file with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

ramonsmits added a commit to ramonsmits/ical.net that referenced this pull request Sep 17, 2025
@ramonsmits
Copy link

@axunonb I applied this fix to my earlier branch and minimized the ics data but that still seems to not generate all occurrences:

main...ramonsmits:ical.net:occurence-issue

I also pushed the same test but for v4 that does generate the expected occurrences:

main...ramonsmits:occurrence-issue-v4

@axunonb
Copy link
Collaborator Author

axunonb commented Sep 17, 2025

Yes, I didn't use the same DTSTART in Master and Override event. That seems to be the reason, and I got excited too soon ;-)
The Override event is not part of occurrences, while it is expected to be. That's why the Master gets filtered out and so the expected date is missing. We're getting closer though.

@axunonb
Copy link
Collaborator Author

axunonb commented Sep 18, 2025

@ramonsmits Should work now also when the Override Event has the same DTSTART

Filter UID/RECURRENCE-ID combinations that replace other occurrences before using `.OrderedDistinct()`
@axunonb axunonb force-pushed the wip/axunonb/pr/event-recurrence-id branch 2 times, most recently from 830295e to 0c92c39 Compare September 18, 2025 12:58
.OrderedMergeMany()

// Remove duplicates and take advantage of being ordered to avoid full enumeration.
.OrderedDistinct()

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the expected order? Looking at the implementation of OrderedDistinct it it keeps the order of the enumerator and the first of its "sequence" is kept. However, in the code above I do not see any sorting happening. Is there some order implied from the RecurringItems items?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It ordered by Period.StartTime

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually the question is legit. The sequence is ordered by StartTime, true, but OrderedDistinct() uses Ocurrences.Equals() to compare for equality, which compares more than just the StartTime, i.e. also the Source. But the sequence is not ordered by Source, so this could become a problem.

Actually the real core of the problem we are dealing with here is a shortcoming (or misuse) of Occurrences.Equals, which compares the Source property via UniqueComponent.Equals under the hood, which in turn only looks at the objects' Uid. In case of VEVENTs with RECURRENCE-ID there are multiple objects having the same UID. In the case of this issue they also have the same DTSTART, so the resulting Occurrence instances are considered equal although they aren't. OrderedDistinct() will therefore, more or less randomly, return only one of both (except if there are even more entries with the same StartDate and the other issue kicks in that the sequence is not properly ordered due to the Source property). As a consequence the filter for the RECURRENCE-ID that has formerly been placed after the OrderedDistinct() couldn't see it.

While this change is a significant improvement, it is not a final fix for that problem. Actually it should be clearly defined how Occurrence instances are compared for equality and I'd say this should not happen based on just the UID. Actually I'd say that two Occurrences should be considered equal only if the Sources are equal by reference.

To keep the issues apart I created #865, #866.

@ramonsmits
Copy link

btw, tested last against my own ical and it seems to now correctly generate the occurrences 💪

* Move tests from GetOccurrencesTests to RecurrenceTests
* Delete GetOccurrencesTests
* Minor refactoring of some unit tests
@axunonb axunonb force-pushed the wip/axunonb/pr/event-recurrence-id branch from 0c92c39 to c79005e Compare September 18, 2025 13:28
@axunonb axunonb requested a review from minichma September 18, 2025 13:49
@axunonb axunonb marked this pull request as ready for review September 18, 2025 13:49
minichma
minichma previously approved these changes Sep 18, 2025
Copy link
Collaborator

@minichma minichma left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wow, this is a subtle one. Please see which of my comments make sense. I think the issue is just a symptom for problems that go deeper. Created follow-up tickets #865, #866 so we can keep things apart.

}

[Test]
public void GetOccurrencesWithRecurrenceId_DateOnly_ShouldEnumerate()
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new tests actually don't reproduce the original issue. Maybe add the test from the ticket?

#863 (comment)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will flag the tests with Category `RECURRENCE-ID" and add a comment referencing to the test covering the #863

.Where(r => r.Uid != null)
// Create a value tuple instead of an anonymous type, because
// anonymous types don't work well as dictionary keys due to equality semantics.
.Select(r => ((r as IUniqueComponent)?.Uid, r.RecurrenceId!.Value))
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I fully agree that it is better to use tuples here rather than anonymous types but I don't think this is the root cause of the problem (as suspected in the issue's comments).

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fully agree

// Exclude occurrences that are overridden by other components with the same UID and RECURRENCE-ID.
// This must happen before .OrderedDistinct() because that method would remove duplicates
// based on the occurrence time, and we need to remove them based on UID + RECURRENCE-ID.
.Where(r =>
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably just a matter of taste: For simplicity I'd rather place the filter after the ToList(). The idea is that before the ToList() we only deal with the individual sequences of occurrences, but not with the occurrences themselves, which is done only after the ToList(). If the filter is placed here, the effect is the same, but the code is less linear to read. I suggest to place it after the OrderedMergeMany() and before the OrderedDistinct().

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Btw, due to #455 it might be a good idea to keep this filter here, or at least before the OrderedMerge.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will leave the position unchanged

.OrderedMergeMany()

// Remove duplicates and take advantage of being ordered to avoid full enumeration.
.OrderedDistinct()
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually the question is legit. The sequence is ordered by StartTime, true, but OrderedDistinct() uses Ocurrences.Equals() to compare for equality, which compares more than just the StartTime, i.e. also the Source. But the sequence is not ordered by Source, so this could become a problem.

Actually the real core of the problem we are dealing with here is a shortcoming (or misuse) of Occurrences.Equals, which compares the Source property via UniqueComponent.Equals under the hood, which in turn only looks at the objects' Uid. In case of VEVENTs with RECURRENCE-ID there are multiple objects having the same UID. In the case of this issue they also have the same DTSTART, so the resulting Occurrence instances are considered equal although they aren't. OrderedDistinct() will therefore, more or less randomly, return only one of both (except if there are even more entries with the same StartDate and the other issue kicks in that the sequence is not properly ordered due to the Source property). As a consequence the filter for the RECURRENCE-ID that has formerly been placed after the OrderedDistinct() couldn't see it.

While this change is a significant improvement, it is not a final fix for that problem. Actually it should be clearly defined how Occurrence instances are compared for equality and I'd say this should not happen based on just the UID. Actually I'd say that two Occurrences should be considered equal only if the Sources are equal by reference.

To keep the issues apart I created #865, #866.

@axunonb axunonb requested a review from minichma September 19, 2025 10:07
@axunonb axunonb force-pushed the wip/axunonb/pr/event-recurrence-id branch from 8adedbb to bae25e0 Compare September 19, 2025 16:39
* Allow more than one `VEVENT` with same `UID` and `RECURRENCE-ID` (threw before)
* Use the latest `UID` / `RECURRENCE-ID`:
  * either last override in the calendar
  * or override with the highest `SEQUENCE`
@axunonb axunonb force-pushed the wip/axunonb/pr/event-recurrence-id branch from bae25e0 to 3cb8fdd Compare September 19, 2025 21:43
@sonarqubecloud
Copy link

Copy link
Collaborator

@minichma minichma left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very nice!

@axunonb axunonb merged commit 1fd6dde into main Sep 21, 2025
10 of 11 checks passed
@axunonb axunonb deleted the wip/axunonb/pr/event-recurrence-id branch September 21, 2025 19:27
@axunonb
Copy link
Collaborator Author

axunonb commented Sep 21, 2025

@minichma Thanks for your support and review

@minichma
Copy link
Collaborator

@axunonb urw

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Chained RECURRENCE-ID not working Event with RECURRENCE-ID removes occurrence completely

4 participants