-
Notifications
You must be signed in to change notification settings - Fork 197
Implement Calendar.RecurrenceRule.recurrences(of: ) #464
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Implement Calendar.RecurrenceRule.recurrences(of: ) #464
Conversation
082f5d8
to
fdf028c
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM 👍
4e091f3
to
0091100
Compare
final class CalendarRecurrenceRuleTests: XCTestCase { | ||
|
||
func testRoundtripEncoding() throws { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Does this need availability even if it's only returned via some Protocol
API?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Possibly not, is there any downside to annotating availability?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@hristost Were you able to remove this? If not, what error did the compiler give?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@hristost What do you think?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't quite understand what this logic is about. Can you explain a bit more?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Tracking that in #555 (rdar://126761035)
for _ in rule.recurrences(of: thanksgivingStart) { | ||
count += 1 | ||
} | ||
assert(count == 1000) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since you're already normalizing negative months, should we also handle month
s that are greater or equal than monthRange.upperBound
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Super cool to see this in action. I had some comments about mini-optimization but overall looks good
// 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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@hristost What do you think?
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just one more pending question about availability annotation
@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.RecurrenceRule
type 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