-
Notifications
You must be signed in to change notification settings - Fork 66
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
Introducing Timer.record(_ duration:), closes #114 #133
Conversation
Motivation: `Duration` is available starting Swift 5.7, and we should probably support it in our convinience API. Modifications: This commit adds: - Timer.record(_ duration: Duration) implementation - A unit test case - Generated Linux tests
@swift-server-bot add to allowlist |
I see I broke the formatting, one sec, will fix. |
@ktoso, fixed the formatting issues in tests, but How does that look? |
Sources/Metrics/Metrics.swift
Outdated
// `Duration` doesn't have a nice way to convert it back to `Double` of seconds, | ||
// so instead we can multiply attoseconds by 1e18 and add the number of seconds to it. | ||
let durationSeconds = Double(duration.components.seconds) + Double(duration.components.attoseconds) / 1e18 | ||
self.recordSeconds(durationSeconds) |
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.
Actually, this is too lossy, we have recordNanoseconds
that this should call into, as it's the most precision we have. With just seconds this would not be very useful for fast operations
Minor nitpick still, looking good though |
@ktoso thank you for the review! Cleaned up, added more comments, recording in nanoseconds now. |
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.
nice. thanks @natikgadzhi
Sources/Metrics/Metrics.swift
Outdated
// `Duration` doesn't have a nice way to convert it nanoseconds or seconds, | ||
// so we'll do the multiplication manually. | ||
// nanoseconds are the smallest unit Timer can track, so we'll record in that. | ||
let durationNanoseconds = duration.components.seconds * 1_000_000_000 + duration.components.attoseconds / 1_000_000_000 |
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.
can duration.components.attoseconds
ever be zero?
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.
should we be using multipliedReportingOverflow
and dividedReportingOverflow
to address overflow risk?
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.
- can duration.components.attoseconds ever be zero?
Unlikely, but they can. That shouldn't be a problem, though? I think it'sInt64
divided byInt64
that is not zero, so it'll just be zero? - should we be using
multipliedReportingOverflow
anddividedReportingOverflow
to address overflow risk?
TIL! I was suspicious about overflows, thank you for pointing out the direction. I'll take a look and use those, happy to address in this or a new PR.
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'll take a look and use those, happy to address in this or a new PR.
lets add it here to make sure the code is safe
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.
@tomerd, here's an attempt: https://github.com/apple/swift-metrics/pull/133/files#diff-19a0ef33862d9079451ca7ba346b734175c62c5ad95fb30d565a41f9ea32a770R95
dividedReportingOverflow
should not be applicable in here, since we know we're not dividing by zero.multipliedReportingOverflow
andaddingReportingOverflow
will return aBool
flag on error. Not a very common way of error handling in Swift, but that's cheaper than making a newError
and throwing. So, I considered keeping a similar API, and perhaps providing a convenience func that would be throwing, but decidedthrows
provides a more familiar and simple API.- Added a test to keep all code paths tested and verify it won't
fatal
out.
What I don't know is whether there's a performance hit we take for marking the function as throwing, in situations where we are not actually throwing errors. I think it's negligible?
Sources/Metrics/Metrics.swift
Outdated
/// - Throws: `TimerError.durationToIntOverflow` if conversion from `Duration` to `Int64` of Nanoseconds overflowed. | ||
@available(macOS 13, iOS 16, tvOS 15, watchOS 8, *) | ||
@inlinable | ||
public func record(_ duration: Duration) 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.
I am not convinced we want these to throw as it will make the call sites awkward to use. I think we should record Int64.max
on overflow as we do in recordNanoseconds
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 do that! The way I understand the trade-off:
- Having an error out on overflow is useful in a very rare cases that I can't quite imagine.
- Typing
try!
all the time and explaining to your team why it's okay is not ideomatic. - When an overflow does happen, it would mean that the duration is VERY big, which should never happen in a Timer metric. When it does, we can choose to error out (not quite useful), crash (bad), or just record a really, really long duration, even if it does not match an actual reading. Because that actual reading is very likely wrong anyway.
Do I understand the problem correctly?
I'll clean up a code in a bit when I have a minute.
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.
exactly. we already take that approach in recordXXX
, eg
@inlinable
public func recordNanoseconds<DataType: BinaryInteger>(_ duration: DataType) {
self.recordNanoseconds(duration >= Int64.max ? Int64.max : Int64(duration))
}
@inlinable
public func recordSeconds<DataType: BinaryInteger>(_ duration: DataType) {
guard duration <= Int64.max else { return self.recordNanoseconds(Int64.max) }
let result = Int64(duration).multipliedReportingOverflow(by: 1_000_000_000)
if result.overflow {
self.recordNanoseconds(Int64.max)
} else {
self.recordNanoseconds(result.partialValue)
}
}
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.
@tomerd, pushed up a change.
Performing overflow checks one at a time to not perform the second operation if the first already overflowed.
I don't yet know the separation into CoreMetrics
and plain Metrics
directories. How do you split them? My gut feeling was that it's about cross-platform support, band CoreMetrics
is designed to work everywhere where, only relies on standard library, but Metrics
uses Foundation
as well?
Is it important where that Timer extension lives?
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.
Aside: I know an engineer at Apple could put that PR and merge it in under an hour, and I appreciate your patience and guidance, @tomerd.
Sources/Metrics/Metrics.swift
Outdated
|
||
#if swift(>=5.7) | ||
|
||
public enum TimerError: Error { |
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.
can be removed now?
// tests on Linux in MetricsTests+XCTest don't complain that the func does not exist. | ||
#if swift(>=5.7) | ||
guard #available(iOS 16, macOS 13, tvOS 15, watchOS 8, *) else { | ||
return |
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.
use XCTSkip or XCTSkipIf where we are skipping the test?
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.
TIL.
looks good, a couple of nits and we should merge this! |
@@ -101,7 +101,7 @@ class MetricsExtensionsTests: XCTestCase { | |||
// tests on Linux in MetricsTests+XCTest don't complain that the func does not exist. | |||
#if swift(>=5.7) | |||
guard #available(iOS 16, macOS 13, tvOS 15, watchOS 8, *) else { | |||
return | |||
throw XCTSkip("Timer.record(_ duration: Duration) is available on Swift 5.7+") |
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.
do the same for earlier versions of Swift? this one is for the 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.
Tried that — #available
doesn't like to be treated as an expression and only works with guard
and if
, seemingly. Didn't try for the language check, though. Let me see if that works.
UPD: I think wrapping the check in a closure before feeding it to XCSkipIf
works. Might look ugly, trying it out now.
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.
adding an #else
does not work?
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.
try! XCTSkipIf({
#if swift(<5.7)
return true
#else
if #available(iOS 16, macOS 13, tvOS 15, watchOS 8, *) {
return false
} else {
return true
}
#endif
}(), "Timer.record(_ duration: Duration) is available on Swift 5.7+")
That works! But mixing #if
and regular if
looks weird to me. Perhaps two separate for #available
and #if swift
are okay?
#if swift(<5.7)
throw XCTSkip("Timer.record(_ duration: Duration) is not available on this version of Swift")
#endif
guard #available(iOS 16, macOS 13, tvOS 15, watchOS 8, *) else {
throw XCTSkip("Timer.record(_ duration: Duration) is not available on this version of Swift")
}
Looks more readable? The closure with nested ifs and explicit returns seems too verbose.
Should I stick with two separate checks?
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 think we will have to #if swift(>=5.7)
around the entire body of the test as done now since it used types that are not available in previous versions (build will fail) but we can add an #else
to throw XCTSkip there
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.
note tested, but...
func testTimerDuration() throws {
// Wrapping only the insides of the test case so that the generated
// tests on Linux in MetricsTests+XCTest don't complain that the func does not exist.
#if swift(>=5.7)
guard #available(iOS 16, macOS 13, tvOS 15, watchOS 8, *) else {
throw XCTSkip("Timer.record(_ duration: Duration) is available on Swift 5.7+")
}
let metrics = TestMetrics()
MetricsSystem.bootstrapInternal(metrics)
let name = "timer-\(UUID().uuidString)"
let timer = Timer(label: name)
let duration = Duration(secondsComponent: 3, attosecondsComponent: 123_000_000_000_000_000)
let nanoseconds = duration.components.seconds * 1_000_000_000 + duration.components.attoseconds / 1_000_000_000
timer.record(duration)
// Record a Duration that would overflow,
// expect Int64.max to be recorded.
timer.record(Duration(secondsComponent: 10_000_000_000, attosecondsComponent: 123))
let testTimer = try metrics.expectTimer(timer)
XCTAssertEqual(testTimer.values.count, 2, "expected number of entries to match")
XCTAssertEqual(testTimer.values.first, nanoseconds, "expected value to match")
XCTAssertEqual(testTimer.values[1], Int64.max, "expected to record Int64.max if Durataion overflows")
XCTAssertEqual(metrics.timers.count, 1, "timer should have been stored")
#else
throw XCTSkip("Timer.record(_ duration: Duration) is available on Swift 5.7+")
#endif
}
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.
Alright! Pushed up.
UPD wait a sec, broke CI. XCTSkip
is not available on Swift 5.0 and 5.1 🤦🏼
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.
Okay,
#if swift(>=5.7)
// ...
#elseif swift(>=5.2)
throw XCTSkip("Timer.record(_ duration: Duration) is only available on Swift >=5.7")
#endif
I think that'd be good enough, Swift 5.1 is not that common anymore anyway?
thanks @natikgadzhi |
@tomerd, thank you for your time and patience 🙃 |
Very nice work, late LGTM :) |
This pull request adds a convenience API to
Timer
that uses Swift 5.7'sDuration
.Motivation:
I'm looking for ways to learn Swift, and it's open source ecosystem by doing, and this looked like a beginner-friendly little addition that @ktoso requested (well, a year ago) in #114.
Closes #114.
Modifications:
Timer.record(_ duration: Duration)
that's available on iOS16 and friends.Where to start the review
@available
check looks weird, I thoughtDuration
is available in Swift 5.7, but looks like it's platform-specific. Do they look okay?Duration
conversion to seconds / nanoseconds is lossy and looks a bit janky, but seems like that's how folks are doing it for now?