Implement Calendar.RecurrenceRule.recurrences(of: )#464
Implement Calendar.RecurrenceRule.recurrences(of: )#464hristost merged 1 commit intoswiftlang:mainfrom
Conversation
082f5d8 to
fdf028c
Compare
4e091f3 to
0091100
Compare
| @available(FoundationPreview 0.4, *) | ||
| final class CalendarRecurrenceRuleTests: XCTestCase { | ||
|
|
||
| func testRoundtripEncoding() throws { |
There was a problem hiding this comment.
This test was moved to FoundationEssentialsTests
| case .weekly: 40 * 53 | ||
| case .daily: 40 * 366 | ||
| case .hourly: 40 * 366 * 24 | ||
| case .minutely: 40 * 366 * 24 * 60 |
There was a problem hiding this comment.
These limits are somewhat arbitrary, but ultimately we wouldn't want RecurrenceRule to be used as a predicate for finding rare dates
|
|
||
| extension Calendar { | ||
| /// A `Sequence` of `Date`s produced by a given recurrence rule | ||
| @available(FoundationPreview 0.4, *) |
There was a problem hiding this comment.
Does this need availability even if it's only returned via some Protocol API?
There was a problem hiding this comment.
Possibly not, is there any downside to annotating availability?
There was a problem hiding this comment.
At the very least, it's confusing. I would like to know what the correct answer is, though.
There was a problem hiding this comment.
@hristost Were you able to remove this? If not, what error did the compiler give?
There was a problem hiding this comment.
My bad, I missed this message. I removed the annotation -- we don't need it.
Sources/FoundationEssentials/Calendar/Calendar_Recurrence.swift
Outdated
Show resolved
Hide resolved
|
|
||
| /// An iterator for a sequence of dates spaced evenly from the start | ||
| /// date, by the interval specified by the recurrence rule frequency | ||
| /// This does not include the start date itself. |
There was a problem hiding this comment.
Style nit, but elsewhere in the project we do not manually wrap these comments.
| // rdar://124640028 | ||
| // https://github.com/apple/swift-foundation/issues/483 | ||
| // Calendar.dates(byMatching: ...) does not respect repeatedTimePolicy when .nanosecond is set #483 | ||
| let components: Calendar.ComponentSet = switch recurrence.frequency { |
There was a problem hiding this comment.
Does it even make sense to ever include nanoseconds in these results? It's so sparsely supported in the first place. Maybe it should really just always be set to 0.
There was a problem hiding this comment.
I can't actually imagine a use case of a recurrence rule where we'd care about the nanoseconds, so I can just remove this to keep things simple.
| let componentsForEnumerating = recurrence.calendar._dateComponents(components, from: start) | ||
|
|
||
| let rangeForBaseRecurrence: Range<Date>? = nil | ||
| baseRecurrence = Calendar.DatesByMatching(calendar: recurrence.calendar, |
There was a problem hiding this comment.
Why create this directly instead of calling the dates(byMatching:...) API on Calendar? There doesn't seem to be much reason to break the abstraction.
There was a problem hiding this comment.
We need to store the iterator, since it is used throughout the enumeration and we don't want to reset. We can't use dates(byMatching: ...).makeIterator() because we need to save this in a var the structure, and thus it'd need to be Sendable. any (IteratorProtocol<Date> & Sendable) does not currently compile (rdar://104888113), so we just represent it using the internal type which is known to be Sendable
| } | ||
| while let nextDate = next() { | ||
| // Skip every few iterations when an interval has been given | ||
| if (iterations - 1) % recurrence.interval != 0 { |
There was a problem hiding this comment.
I don't quite understand what this logic is about. Can you explain a bit more?
There was a problem hiding this comment.
Per the RFC: The interval part of a recurrence rule specifies at which
intervals the event repeats. A value of 1 indicates every
interval, and any n > 1 means every n-th interval. E.g, a
recurrence with frequency=.yearly and interval=5 will
repeat every 5 years.
Here, baseRecurrence repeats the date over the specified frequency with interval=1, and we need to skip some matches to compensate
Sources/FoundationEssentials/Calendar/Calendar_Recurrence.swift
Outdated
Show resolved
Hide resolved
|
|
||
| if case let .afterDate(limit) = recurrence.end._guts { | ||
| let hadDates = !dates.isEmpty | ||
| dates = dates.filter { $0 <= limit } |
There was a problem hiding this comment.
I'm not sure if this is actionable, but it seems like a reason to specify a limit is to avoid the cost of finding those dates in the first place - not just to filter them out at the end. Is it possible to do that?
There was a problem hiding this comment.
I can't think of a way to do that. Even if we have dates that are past the limit, further expansions may generate dates that are before the limit.
Tests/FoundationEssentialsTests/GregorianCalendarRecurrenceRuleTests.swift
Outdated
Show resolved
Hide resolved
Tests/FoundationEssentialsTests/GregorianCalendarRecurrenceRuleTests.swift
Outdated
Show resolved
Hide resolved
| for _ in rule.recurrences(of: thanksgivingStart) { | ||
| count += 1 | ||
| } | ||
| assert(count == 1000) |
There was a problem hiding this comment.
The assertion is there to make sure the compiler wouldn't optimize anything out.
The benchmarks are a bit flaky right now and may cause the compiler to crash, I am working to figure out which change introduced that. They do run when we pick RecurrenceRule to an older commit of the same branch, and results are as follows:
nextThousandThanksgivings
╒═══════════════════════════╤═════════╤═════════╤═════════╤═════════╤═════════╤═════════╤═════════╤═════════╕
│ Metric │ p0 │ p25 │ p50 │ p75 │ p90 │ p99 │ p100 │ Samples │
╞═══════════════════════════╪═════════╪═════════╪═════════╪═════════╪═════════╪═════════╪═════════╪═════════╡
│ Malloc (total) * │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 2 │
├───────────────────────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┤
│ Throughput (# / s) (K) │ 1 │ 1 │ 1 │ 1 │ 1 │ 1 │ 1 │ 2 │
├───────────────────────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┤
│ Time (total CPU) (μs) * │ 1564 │ 1564 │ 1564 │ 1567 │ 1567 │ 1567 │ 1567 │ 2 │
├───────────────────────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┤
│ Time (wall clock) (μs) * │ 1577 │ 1578 │ 1578 │ 1581 │ 1581 │ 1581 │ 1581 │ 2 │
╘═══════════════════════════╧═════════╧═════════╧═════════╧═════════╧═════════╧═════════╧═════════╧═════════╛
nextThousandThanksgivingsSequence
╒═══════════════════════════╤═════════╤═════════╤═════════╤═════════╤═════════╤═════════╤═════════╤═════════╕
│ Metric │ p0 │ p25 │ p50 │ p75 │ p90 │ p99 │ p100 │ Samples │
╞═══════════════════════════╪═════════╪═════════╪═════════╪═════════╪═════════╪═════════╪═════════╪═════════╡
│ Malloc (total) * │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 2 │
├───────────────────────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┤
│ Throughput (# / s) (K) │ 1 │ 1 │ 1 │ 1 │ 1 │ 1 │ 1 │ 2 │
├───────────────────────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┤
│ Time (total CPU) (μs) * │ 1590 │ 1591 │ 1591 │ 1591 │ 1591 │ 1591 │ 1591 │ 2 │
├───────────────────────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┤
│ Time (wall clock) (μs) * │ 1604 │ 1604 │ 1604 │ 1604 │ 1604 │ 1604 │ 1604 │ 2 │
╘═══════════════════════════╧═════════╧═════════╧═════════╧═════════╧═════════╧═════════╧═════════╧═════════╛
nextThousandThanksgivingsUsingRecurrenceRule
╒═══════════════════════════╤═════════╤═════════╤═════════╤═════════╤═════════╤═════════╤═════════╤═════════╕
│ Metric │ p0 │ p25 │ p50 │ p75 │ p90 │ p99 │ p100 │ Samples │
╞═══════════════════════════╪═════════╪═════════╪═════════╪═════════╪═════════╪═════════╪═════════╪═════════╡
│ Malloc (total) * │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 1 │
├───────────────────────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┤
│ Time (total CPU) (ms) * │ 301 │ 301 │ 301 │ 301 │ 301 │ 301 │ 301 │ 1 │
├───────────────────────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┤
│ Time (wall clock) (ms) * │ 304 │ 304 │ 304 │ 304 │ 304 │ 304 │ 304 │ 1 │
╘═══════════════════════════╧═════════╧═════════╧═════════╧═════════╧═════════╧═════════╧═════════╧═════════╛
Somehow we achieve a significant speedup even though . RecurrenceRule does component matching under the hood. Perhaps the weekOfMonth component is a bit slow, we don't really use it.RecurrenceRule is currently waay slower than enumerating with just the date components -- 300 vs 1.5ms.
There was a problem hiding this comment.
This is amazing but also curious.
rule.weekdays = [.nth(4, .thursday)] still essentially uses .weekOfMonth in _weekdayComponents, doesn't it? (Though I didn't really run the code, so I could read it wrong)
There was a problem hiding this comment.
I fixed an obvious performance pitfall (we were enumerating over a wider range than needed), and now we are within <4x runtime of the benchmark that uses enumeration by date components. Since we also own the implementation for matching date components, the next step would be to bypass the public API and use some of the internals to get the runtime even closer.
There was a problem hiding this comment.
The original benchmark (nextThousandThanksgivings) does not actually compute Thanksgivings as the name implies. It finds the Thursday in the fourth week of November, which is not necessarily the fourth Thursday in November. Is it permissible to update the benchmark given that the runtime will be slightly different?
There was a problem hiding this comment.
Since we also own the implementation for matching date components, the next step would be to bypass the public API and use some of the internals to get the runtime even closer.
I got started on this, but the potential performance improvement does not seem to justify the amount of work it takes to get there. I'd like to merge the implementation as it is now, and revisit performance with a later PR. The benchmarks taken on my machine are as follows:
nextThousandThanksgivings
╒═══════════════════════════╤═════════╤═════════╤═════════╤═════════╤═════════╤═════════╤═════════╤═════════╕
│ Metric │ p0 │ p25 │ p50 │ p75 │ p90 │ p99 │ p100 │ Samples │
╞═══════════════════════════╪═════════╪═════════╪═════════╪═════════╪═════════╪═════════╪═════════╪═════════╡
│ Malloc (total) * │ 426 │ 426 │ 426 │ 426 │ 426 │ 426 │ 426 │ 6 │
├───────────────────────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┤
│ Throughput (# / s) (K) │ 2 │ 2 │ 2 │ 2 │ 2 │ 2 │ 2 │ 6 │
├───────────────────────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┤
│ Time (total CPU) (μs) * │ 580 │ 581 │ 581 │ 583 │ 583 │ 583 │ 583 │ 6 │
├───────────────────────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┤
│ Time (wall clock) (μs) * │ 582 │ 584 │ 584 │ 586 │ 586 │ 586 │ 586 │ 6 │
╘═══════════════════════════╧═════════╧═════════╧═════════╧═════════╧═════════╧═════════╧═════════╧═════════╛
nextThousandThanksgivingsSequence
╒═══════════════════════════╤═════════╤═════════╤═════════╤═════════╤═════════╤═════════╤═════════╤═════════╕
│ Metric │ p0 │ p25 │ p50 │ p75 │ p90 │ p99 │ p100 │ Samples │
╞═══════════════════════════╪═════════╪═════════╪═════════╪═════════╪═════════╪═════════╪═════════╪═════════╡
│ Malloc (total) * │ 426 │ 426 │ 426 │ 426 │ 426 │ 426 │ 426 │ 6 │
├───────────────────────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┤
│ Throughput (# / s) (K) │ 2 │ 2 │ 2 │ 2 │ 2 │ 2 │ 2 │ 6 │
├───────────────────────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┤
│ Time (total CPU) (μs) * │ 580 │ 581 │ 581 │ 581 │ 581 │ 581 │ 581 │ 6 │
├───────────────────────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┤
│ Time (wall clock) (μs) * │ 582 │ 584 │ 584 │ 585 │ 586 │ 586 │ 586 │ 6 │
╘═══════════════════════════╧═════════╧═════════╧═════════╧═════════╧═════════╧═════════╧═════════╧═════════╛
nextThousandThanksgivingsUsingRecurrenceRule
╒═══════════════════════════╤═════════╤═════════╤═════════╤═════════╤═════════╤═════════╤═════════╤═════════╕
│ Metric │ p0 │ p25 │ p50 │ p75 │ p90 │ p99 │ p100 │ Samples │
╞═══════════════════════════╪═════════╪═════════╪═════════╪═════════╪═════════╪═════════╪═════════╪═════════╡
│ Malloc (total) * │ 861 │ 861 │ 861 │ 861 │ 861 │ 861 │ 861 │ 2 │
├───────────────────────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┤
│ Time (total CPU) (μs) * │ 2121 │ 2121 │ 2121 │ 2129 │ 2129 │ 2129 │ 2129 │ 2 │
├───────────────────────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┤
│ Time (wall clock) (μs) * │ 2135 │ 2136 │ 2136 │ 2140 │ 2140 │ 2140 │ 2140 │ 2 │
╘═══════════════════════════╧═════════╧═════════╧═════════╧═════════╧═════════╧═════════╧═════════╧═════════╛
There was a problem hiding this comment.
Ok, that seems reasonable for now. Let's file a follow-up bug just so we don't lose track of the task.
There was a problem hiding this comment.
Tracking that in #555 (rdar://126761035)
| for _ in rule.recurrences(of: thanksgivingStart) { | ||
| count += 1 | ||
| } | ||
| assert(count == 1000) |
There was a problem hiding this comment.
This is amazing but also curious.
rule.weekdays = [.nth(4, .thursday)] still essentially uses .weekOfMonth in _weekdayComponents, doesn't it? (Though I didn't really run the code, so I could read it wrong)
Sources/FoundationEssentials/Calendar/Calendar_Recurrence.swift
Outdated
Show resolved
Hide resolved
Sources/FoundationEssentials/Calendar/Calendar_Recurrence.swift
Outdated
Show resolved
Hide resolved
| if month.index > 0 { | ||
| return month | ||
| } else { | ||
| let newIndex = monthRange!.upperBound + month.index |
There was a problem hiding this comment.
Since you're already normalizing negative months, should we also handle months that are greater or equal than monthRange.upperBound?
There was a problem hiding this comment.
I wouldn't call that normalization, it's simply so we can address months which are usually counted backwards. We can normalize by filtering the resulting array later, but also we achieve the same results now by leaving invalid months in, and having Calendar APIs return nil
Sources/FoundationEssentials/Calendar/Calendar_Recurrence.swift
Outdated
Show resolved
Hide resolved
| // rdar://124640028 | ||
| // https://github.com/apple/swift-foundation/issues/483 | ||
| // Calendar.dates(byMatching: ...) does not respect repeatedTimePolicy when .nanosecond is set #483 | ||
| let components: Calendar.ComponentSet = switch recurrence.frequency { |
Sources/FoundationEssentials/Calendar/Calendar_Recurrence.swift
Outdated
Show resolved
Hide resolved
Sources/FoundationEssentials/Calendar/Calendar_Recurrence.swift
Outdated
Show resolved
Hide resolved
Sources/FoundationEssentials/Calendar/Calendar_Recurrence.swift
Outdated
Show resolved
Hide resolved
Sources/FoundationEssentials/Calendar/Calendar_Recurrence.swift
Outdated
Show resolved
Hide resolved
Sources/FoundationEssentials/Calendar/Calendar_Recurrence.swift
Outdated
Show resolved
Hide resolved
Sources/FoundationEssentials/Calendar/Calendar_Recurrence.swift
Outdated
Show resolved
Hide resolved
| if dayOfMonth > 0 { | ||
| if dayOfMonth == day { return true } | ||
| } else { | ||
| if dayRange == nil { |
There was a problem hiding this comment.
We can't have lazy var inside a closure for some reason, so we store the day range in an optional and calculate it the first time it is assigned.
|
@swift-ci please test |
Sources/FoundationEssentials/Calendar/Calendar_Recurrence.swift
Outdated
Show resolved
Hide resolved
beeed5b to
1d4ce5e
Compare
|
@swift-ci please test |
1 similar comment
|
@swift-ci please test |
This commit implements `Calendar.RecurrenceRule.recurrences(of:in:)` as pitched in swiftlang#422. This type models a subset of RRULE as specified in RFC-5545, section 3.3.10. One notable difference is that it doesn't support a frequency of "secondly", as that was not part of the original proposal. It also implements RFC-7529 Non-Gregorian Recurrence Rules and works with any instance of `Calendar`. Recurrences are calculated according to our interpretation of the RFCs. As such, the resulting dates may differ in certain edge cases when compared to other open source implementations.
1d4ce5e to
e353553
Compare
This commit implements the
Calendar.RecurrenceRuletype first pitched in #422.This type models a subset of RRULE as specified in RFC-5545, section 3.3.10. One
notable difference is that it doesn't support a frequency of "secondly", as that
was not part of the original proposal. It also implements RFC-7529 Non-Gregorian
Recurrence Rules and works with any instance of
Calendar.Recurrences are calculated according to our interpretation of the RFCs. As such,
the resulting dates may differ in certain edge cases when compared to other open
source implementations.
Resolves rdar://120559017&123337748