Skip to content
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

Merged
merged 11 commits into from
Jun 28, 2023

Conversation

natikgadzhi
Copy link
Contributor

@natikgadzhi natikgadzhi commented Jun 23, 2023

This pull request adds a convenience API to Timer that uses Swift 5.7's Duration.

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.
  • A unit test
  • Added the test case to Linux tests
  • Soundness check passes.
  • Record duration in nanoseconds.

Where to start the review

  • My @available check looks weird, I thought Duration is available in Swift 5.7, but looks like it's platform-specific. Do they look okay?
  • Looks like DocC should pick up the function variant automatically, but I'm happy to add example / other documentation as needed.
  • Duration conversion to seconds / nanoseconds is lossy and looks a bit janky, but seems like that's how folks are doing it for now?

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
@ktoso
Copy link
Member

ktoso commented Jun 23, 2023

@swift-server-bot add to allowlist

@natikgadzhi
Copy link
Contributor Author

I see I broke the formatting, one sec, will fix.

@natikgadzhi
Copy link
Contributor Author

@ktoso, fixed the formatting issues in tests, but swiftformat with default config has a few more complaints. Won't add them to this PR to keep this isolated.

How does that look?

@natikgadzhi natikgadzhi requested a review from ktoso June 23, 2023 04:41
// `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)
Copy link
Member

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

@ktoso
Copy link
Member

ktoso commented Jun 23, 2023

Minor nitpick still, looking good though

@natikgadzhi natikgadzhi requested a review from ktoso June 24, 2023 03:31
@natikgadzhi
Copy link
Contributor Author

@ktoso thank you for the review! Cleaned up, added more comments, recording in nanoseconds now.

Copy link
Member

@tomerd tomerd left a comment

Choose a reason for hiding this comment

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

nice. thanks @natikgadzhi

// `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
Copy link
Member

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?

Copy link
Member

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?

Copy link
Contributor Author

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's Int64 divided by Int64 that is not zero, so it'll just be zero?
  • should we be using multipliedReportingOverflow and dividedReportingOverflow 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.

Copy link
Member

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

Copy link
Contributor Author

@natikgadzhi natikgadzhi Jun 27, 2023

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 and addingReportingOverflow will return a Bool flag on error. Not a very common way of error handling in Swift, but that's cheaper than making a new Error and throwing. So, I considered keeping a similar API, and perhaps providing a convenience func that would be throwing, but decided throws 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?

/// - 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 {
Copy link
Member

@tomerd tomerd Jun 27, 2023

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

Copy link
Contributor Author

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.

Copy link
Member

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)
        }
    }

Copy link
Contributor Author

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?

Copy link
Contributor Author

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.


#if swift(>=5.7)

public enum TimerError: Error {
Copy link
Member

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
Copy link
Member

@tomerd tomerd Jun 28, 2023

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?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

TIL.

@tomerd
Copy link
Member

tomerd commented Jun 28, 2023

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+")
Copy link
Member

@tomerd tomerd Jun 28, 2023

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.

Copy link
Contributor Author

@natikgadzhi natikgadzhi Jun 28, 2023

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.

Copy link
Member

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?

Copy link
Contributor Author

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?

Copy link
Member

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

Copy link
Member

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
    }

Copy link
Contributor Author

@natikgadzhi natikgadzhi Jun 28, 2023

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 🤦🏼

Copy link
Contributor Author

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?

@tomerd tomerd merged commit 13ea1fe into apple:main Jun 28, 2023
@tomerd
Copy link
Member

tomerd commented Jun 28, 2023

thanks @natikgadzhi

@natikgadzhi
Copy link
Contributor Author

@tomerd, thank you for your time and patience 🙃

@ktoso
Copy link
Member

ktoso commented Jun 29, 2023

Very nice work, late LGTM :)

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.

Make use of Swift 5.7 Duration and friends for Timer
3 participants