Browse Source

Add new Timeout type (#1718)

Gustavo Cairo 2 years ago
parent
commit
2971c0ad34

+ 1 - 1
Sources/GRPCCore/Call/Server/Internal/ServerRPCExecutor.swift

@@ -76,7 +76,7 @@ struct ServerRPCExecutor {
       if let timeout = metadata.timeout {
         group.addTask {
           let result = await Result {
-            try await Task.sleep(until: .now.advanced(by: timeout), clock: .continuous)
+            try await Task.sleep(for: timeout, clock: .continuous)
           }
           return .timedOut(result)
         }

+ 10 - 8
Sources/GRPCCore/Internal/Metadata+GRPC.swift

@@ -39,14 +39,16 @@ extension Metadata {
 
   @inlinable
   var timeout: Duration? {
-    // Temporary hack to support tests; only supports nanoseconds.
-    guard let value = self.firstString(forKey: .timeout) else { return nil }
-    guard value.utf8.last == UTF8.CodeUnit(ascii: "n") else { return nil }
-    var index = value.utf8.endIndex
-    value.utf8.formIndex(before: &index)
-    guard let digits = String(value.utf8[..<index]) else { return nil }
-    guard let nanoseconds = Int64(digits) else { return nil }
-    return .nanoseconds(nanoseconds)
+    get {
+      self.firstString(forKey: .timeout).flatMap { Timeout(decoding: $0)?.duration }
+    }
+    set {
+      if let newValue {
+        self.replaceOrAddString(String(describing: Timeout(duration: newValue)), forKey: .timeout)
+      } else {
+        self.removeAllValues(forKey: .timeout)
+      }
+    }
   }
 }
 

+ 220 - 0
Sources/GRPCCore/Timeout.swift

@@ -0,0 +1,220 @@
+/*
+ * Copyright 2023, gRPC Authors All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import Dispatch
+
+/// A timeout for a gRPC call.
+///
+/// It's a combination of an amount (expressed as an integer of at maximum 8 digits), and a unit, which is
+/// one of ``Timeout/Unit`` (hours, minutes, seconds, milliseconds, microseconds or nanoseconds).
+///
+/// Timeouts must be positive and at most 8-digits long.
+@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
+@usableFromInline
+struct Timeout: CustomStringConvertible, Hashable, Sendable {
+  /// Possible units for a ``Timeout``.
+  internal enum Unit: Character {
+    case hours = "H"
+    case minutes = "M"
+    case seconds = "S"
+    case milliseconds = "m"
+    case microseconds = "u"
+    case nanoseconds = "n"
+  }
+
+  /// The largest amount of any unit of time which may be represented by a gRPC timeout.
+  static let maxAmount: Int64 = 99_999_999
+
+  private let amount: Int64
+  private let unit: Unit
+
+  @usableFromInline
+  var duration: Duration {
+    Duration(amount: amount, unit: unit)
+  }
+
+  /// The wire encoding of this timeout as described in the gRPC protocol.
+  /// See "Timeout" in https://github.com/grpc/grpc/blob/master/doc/PROTOCOL-HTTP2.md#requests
+  var wireEncoding: String {
+    "\(amount)\(unit.rawValue)"
+  }
+
+  @usableFromInline
+  var description: String {
+    return self.wireEncoding
+  }
+
+  @usableFromInline
+  init?(decoding value: String) {
+    guard (2 ... 8).contains(value.count) else {
+      return nil
+    }
+
+    if let amount = Int64(value.dropLast()),
+      let unit = Unit(rawValue: value.last!)
+    {
+      self = Self.init(amount: amount, unit: unit)
+    } else {
+      return nil
+    }
+  }
+
+  /// Create a ``Timeout`` from a ``Duration``.
+  ///
+  /// - Important: It's not possible to know with what precision the duration was created: that is,
+  /// it's not possible to know whether `Duration.seconds(value)` or `Duration.milliseconds(value)`
+  /// was used. For this reason, the unit chosen for the ``Timeout`` (and thus the wire encoding) may be
+  /// different from the one originally used to create the ``Duration``. Despite this, we guarantee that
+  /// both durations will be equivalent if there was no loss in precision during the transformation.
+  /// For example, `Duration.hours(123)` will yield a ``Timeout`` with `wireEncoding` equal to
+  /// `"442800S"`, which is in seconds. However, 442800 seconds and 123 hours are equivalent.
+  /// However, you must note that there may be some loss of precision when dealing with transforming
+  /// between units. For example, for very low precisions, such as a duration of only a few attoseconds,
+  /// given the smallest unit we have is whole nanoseconds, we cannot represent it. Same when converting
+  /// for instance, milliseconds to seconds. In these scenarios, we'll round to the closest whole number in
+  /// the target unit.
+  @usableFromInline
+  init(duration: Duration) {
+    let (seconds, attoseconds) = duration.components
+
+    if seconds == 0 {
+      // There is no seconds component, so only pay attention to the attoseconds.
+      // Try converting to nanoseconds first, and continue rounding up if the
+      // max amount of digits is exceeded.
+      let nanoseconds = Int64(Double(attoseconds) / 1e+9)
+      self.init(rounding: nanoseconds, unit: .nanoseconds)
+    } else if Self.exceedsDigitLimit(seconds) {
+      // We don't have enough digits to represent this amount in seconds, so
+      // we will have to use minutes or hours.
+      // We can also ignore attoseconds, since we won't have enough precision
+      // anyways to represent the (at most) one second that the attoseconds
+      // component can express.
+      self.init(rounding: seconds, unit: .seconds)
+    } else {
+      // We can't convert seconds to nanoseconds because that would take us
+      // over the 8 digit limit (1 second = 1e+9 nanoseconds).
+      // We can however, try converting to microseconds or milliseconds.
+      let nanoseconds = Int64(Double(attoseconds) / 1e+9)
+      let microseconds = nanoseconds / 1000
+      if microseconds == 0 {
+        self.init(amount: seconds, unit: .seconds)
+      } else {
+        let secondsInMicroseconds = seconds * 1000 * 1000
+        let totalMicroseconds = microseconds + secondsInMicroseconds
+        self.init(rounding: totalMicroseconds, unit: .microseconds)
+      }
+    }
+  }
+
+  /// Create a timeout by rounding up the timeout so that it may be represented in the gRPC
+  /// wire format.
+  private init(rounding amount: Int64, unit: Unit) {
+    var roundedAmount = amount
+    var roundedUnit = unit
+
+    if roundedAmount <= 0 {
+      roundedAmount = 0
+    } else {
+      while roundedAmount > Timeout.maxAmount {
+        switch roundedUnit {
+        case .nanoseconds:
+          roundedAmount = roundedAmount.quotientRoundedUp(dividingBy: 1000)
+          roundedUnit = .microseconds
+        case .microseconds:
+          roundedAmount = roundedAmount.quotientRoundedUp(dividingBy: 1000)
+          roundedUnit = .milliseconds
+        case .milliseconds:
+          roundedAmount = roundedAmount.quotientRoundedUp(dividingBy: 1000)
+          roundedUnit = .seconds
+        case .seconds:
+          roundedAmount = roundedAmount.quotientRoundedUp(dividingBy: 60)
+          roundedUnit = .minutes
+        case .minutes:
+          roundedAmount = roundedAmount.quotientRoundedUp(dividingBy: 60)
+          roundedUnit = .hours
+        case .hours:
+          roundedAmount = Timeout.maxAmount
+          roundedUnit = .hours
+        }
+      }
+    }
+
+    self.init(amount: roundedAmount, unit: roundedUnit)
+  }
+
+  private static func exceedsDigitLimit(_ value: Int64) -> Bool {
+    value > Timeout.maxAmount
+  }
+
+  /// Creates a `GRPCTimeout`.
+  ///
+  /// - Precondition: The amount should be greater than or equal to zero and less than or equal
+  ///   to `GRPCTimeout.maxAmount`.
+  internal init(amount: Int64, unit: Unit) {
+    precondition((0 ... Timeout.maxAmount).contains(amount))
+
+    self.amount = amount
+    self.unit = unit
+  }
+}
+
+extension Int64 {
+  /// Returns the quotient of this value when divided by `divisor` rounded up to the nearest
+  /// multiple of `divisor` if the remainder is non-zero.
+  ///
+  /// - Parameter divisor: The value to divide this value by.
+  fileprivate func quotientRoundedUp(dividingBy divisor: Int64) -> Int64 {
+    let (quotient, remainder) = self.quotientAndRemainder(dividingBy: divisor)
+    return quotient + (remainder != 0 ? 1 : 0)
+  }
+}
+
+@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
+extension Duration {
+  /// Construct a `Duration` given a number of minutes represented as an `Int64`.
+  ///
+  ///       let d: Duration = .minutes(5)
+  ///
+  /// - Returns: A `Duration` representing a given number of minutes.
+  internal static func minutes(_ minutes: Int64) -> Duration {
+    return Self.init(secondsComponent: 60 * minutes, attosecondsComponent: 0)
+  }
+
+  /// Construct a `Duration` given a number of hours represented as an `Int64`.
+  ///
+  ///       let d: Duration = .hours(3)
+  ///
+  /// - Returns: A `Duration` representing a given number of hours.
+  internal static func hours(_ hours: Int64) -> Duration {
+    return Self.init(secondsComponent: 60 * 60 * hours, attosecondsComponent: 0)
+  }
+
+  internal init(amount: Int64, unit: Timeout.Unit) {
+    switch unit {
+    case .hours:
+      self = Self.hours(amount)
+    case .minutes:
+      self = Self.minutes(amount)
+    case .seconds:
+      self = Self.seconds(amount)
+    case .milliseconds:
+      self = Self.milliseconds(amount)
+    case .microseconds:
+      self = Self.microseconds(amount)
+    case .nanoseconds:
+      self = Self.nanoseconds(amount)
+    }
+  }
+}

+ 204 - 0
Tests/GRPCCoreTests/TimeoutTests.swift

@@ -0,0 +1,204 @@
+/*
+ * Copyright 2023, gRPC Authors All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import XCTest
+
+@testable import GRPCCore
+
+final class TimeoutTests: XCTestCase {
+  func testDecodeInvalidTimeout_Empty() {
+    let timeoutHeader = ""
+    XCTAssertNil(Timeout(decoding: timeoutHeader))
+  }
+
+  func testDecodeInvalidTimeout_NoAmount() {
+    let timeoutHeader = "H"
+    XCTAssertNil(Timeout(decoding: timeoutHeader))
+  }
+
+  func testDecodeInvalidTimeout_NoUnit() {
+    let timeoutHeader = "123"
+    XCTAssertNil(Timeout(decoding: timeoutHeader))
+  }
+
+  func testDecodeInvalidTimeout_TooLongAmount() {
+    let timeoutHeader = "100000000S"
+    XCTAssertNil(Timeout(decoding: timeoutHeader))
+  }
+
+  func testDecodeInvalidTimeout_InvalidUnit() {
+    let timeoutHeader = "123j"
+    XCTAssertNil(Timeout(decoding: timeoutHeader))
+  }
+
+  func testDecodeValidTimeout_Hours() throws {
+    let timeoutHeader = "123H"
+    let timeout = Timeout(decoding: timeoutHeader)
+    let unwrappedTimeout = try XCTUnwrap(timeout)
+    XCTAssertEqual(unwrappedTimeout.duration, Duration.hours(123))
+    XCTAssertEqual(unwrappedTimeout.wireEncoding, timeoutHeader)
+  }
+
+  func testDecodeValidTimeout_Minutes() throws {
+    let timeoutHeader = "123M"
+    let timeout = Timeout(decoding: timeoutHeader)
+    let unwrappedTimeout = try XCTUnwrap(timeout)
+    XCTAssertEqual(unwrappedTimeout.duration, Duration.minutes(123))
+    XCTAssertEqual(unwrappedTimeout.wireEncoding, timeoutHeader)
+  }
+
+  func testDecodeValidTimeout_Seconds() throws {
+    let timeoutHeader = "123S"
+    let timeout = Timeout(decoding: timeoutHeader)
+    let unwrappedTimeout = try XCTUnwrap(timeout)
+    XCTAssertEqual(unwrappedTimeout.duration, Duration.seconds(123))
+    XCTAssertEqual(unwrappedTimeout.wireEncoding, timeoutHeader)
+  }
+
+  func testDecodeValidTimeout_Milliseconds() throws {
+    let timeoutHeader = "123m"
+    let timeout = Timeout(decoding: timeoutHeader)
+    let unwrappedTimeout = try XCTUnwrap(timeout)
+    XCTAssertEqual(unwrappedTimeout.duration, Duration.milliseconds(123))
+    XCTAssertEqual(unwrappedTimeout.wireEncoding, timeoutHeader)
+  }
+
+  func testDecodeValidTimeout_Microseconds() throws {
+    let timeoutHeader = "123u"
+    let timeout = Timeout(decoding: timeoutHeader)
+    let unwrappedTimeout = try XCTUnwrap(timeout)
+    XCTAssertEqual(unwrappedTimeout.duration, Duration.microseconds(123))
+    XCTAssertEqual(unwrappedTimeout.wireEncoding, timeoutHeader)
+  }
+
+  func testDecodeValidTimeout_Nanoseconds() throws {
+    let timeoutHeader = "123n"
+    let timeout = Timeout(decoding: timeoutHeader)
+    let unwrappedTimeout = try XCTUnwrap(timeout)
+    XCTAssertEqual(unwrappedTimeout.duration, Duration.nanoseconds(123))
+    XCTAssertEqual(unwrappedTimeout.wireEncoding, timeoutHeader)
+  }
+
+  func testEncodeValidTimeout_Hours() {
+    let duration = Duration.hours(123)
+    let timeout = Timeout(duration: duration)
+    XCTAssertEqual(timeout.duration.components.seconds, duration.components.seconds)
+    XCTAssertEqual(timeout.duration.components.attoseconds, duration.components.attoseconds)
+  }
+
+  func testEncodeValidTimeout_Minutes() {
+    let duration = Duration.minutes(43)
+    let timeout = Timeout(duration: duration)
+    XCTAssertEqual(timeout.duration.components.seconds, duration.components.seconds)
+    XCTAssertEqual(timeout.duration.components.attoseconds, duration.components.attoseconds)
+  }
+
+  func testEncodeValidTimeout_Seconds() {
+    let duration = Duration.seconds(12345)
+    let timeout = Timeout(duration: duration)
+    XCTAssertEqual(timeout.duration.components.seconds, duration.components.seconds)
+    XCTAssertEqual(timeout.duration.components.attoseconds, duration.components.attoseconds)
+  }
+
+  func testEncodeValidTimeout_Seconds_TooLong_Minutes() {
+    let duration = Duration.seconds(111_111_111)
+    let timeout = Timeout(duration: duration)
+    // The conversion from seconds to minutes results in a loss of precision.
+    // 111,111,111 seconds / 60 = 1,851,851.85 minutes -rounding up-> 1,851,852 minutes * 60 = 111,111,120 seconds
+    let expectedRoundedDuration = Duration.minutes(1_851_852)
+    XCTAssertEqual(timeout.duration.components.seconds, expectedRoundedDuration.components.seconds)
+    XCTAssertEqual(
+      timeout.duration.components.attoseconds,
+      expectedRoundedDuration.components.attoseconds
+    )
+  }
+
+  func testEncodeValidTimeout_Seconds_TooLong_Hours() {
+    let duration = Duration.seconds(9_999_999_999)
+    let timeout = Timeout(duration: duration)
+    // The conversion from seconds to hours results in a loss of precision.
+    // 9,999,999,999 seconds / 60 = 166,666,666.65 minutes -rounding up->
+    // 166,666,667 minutes / 60 = 2,777,777.78 hours -rounding up->
+    // 2,777,778 hours * 60 -> 166,666,680 minutes * 60 = 10,000,000,800 seconds
+    let expectedRoundedDuration = Duration.hours(2_777_778)
+    XCTAssertEqual(timeout.duration.components.seconds, expectedRoundedDuration.components.seconds)
+    XCTAssertEqual(
+      timeout.duration.components.attoseconds,
+      expectedRoundedDuration.components.attoseconds
+    )
+  }
+
+  func testEncodeValidTimeout_Seconds_TooLong_MaxAmount() {
+    let duration = Duration.seconds(999_999_999_999)
+    let timeout = Timeout(duration: duration)
+    // The conversion from seconds to hours results in a number that still has
+    // more than the maximum allowed 8 digits, so we must clamp it.
+    // Make sure that `Timeout.maxAmount` is the amount used for the resulting timeout.
+    let expectedRoundedDuration = Duration.hours(Timeout.maxAmount)
+    XCTAssertEqual(timeout.duration.components.seconds, expectedRoundedDuration.components.seconds)
+    XCTAssertEqual(
+      timeout.duration.components.attoseconds,
+      expectedRoundedDuration.components.attoseconds
+    )
+  }
+
+  func testEncodeValidTimeout_SecondsAndMilliseconds() {
+    let duration = Duration(secondsComponent: 100, attosecondsComponent: Int64(1e+17))
+    let timeout = Timeout(duration: duration)
+    XCTAssertEqual(timeout.duration.components.seconds, duration.components.seconds)
+    XCTAssertEqual(timeout.duration.components.attoseconds, duration.components.attoseconds)
+  }
+
+  func testEncodeValidTimeout_SecondsAndMicroseconds() {
+    let duration = Duration(secondsComponent: 1, attosecondsComponent: Int64(1e+14))
+    let timeout = Timeout(duration: duration)
+    XCTAssertEqual(timeout.duration.components.seconds, duration.components.seconds)
+    XCTAssertEqual(timeout.duration.components.attoseconds, duration.components.attoseconds)
+  }
+
+  func testEncodeValidTimeout_SecondsAndNanoseconds() {
+    let duration = Duration(secondsComponent: 1, attosecondsComponent: Int64(1e+11))
+    let timeout = Timeout(duration: duration)
+    // We can't convert seconds to nanoseconds because that would require at least
+    // 9 digits, and the maximum allowed is 8: we expect to simply drop the nanoseconds.
+    let expectedRoundedDuration = Duration.seconds(1)
+    XCTAssertEqual(timeout.duration.components.seconds, expectedRoundedDuration.components.seconds)
+    XCTAssertEqual(
+      timeout.duration.components.attoseconds,
+      expectedRoundedDuration.components.attoseconds
+    )
+  }
+
+  func testEncodeValidTimeout_Milliseconds() {
+    let duration = Duration.milliseconds(100)
+    let timeout = Timeout(duration: duration)
+    XCTAssertEqual(timeout.duration.components.seconds, duration.components.seconds)
+    XCTAssertEqual(timeout.duration.components.attoseconds, duration.components.attoseconds)
+  }
+
+  func testEncodeValidTimeout_Microseconds() {
+    let duration = Duration.microseconds(100)
+    let timeout = Timeout(duration: duration)
+    XCTAssertEqual(timeout.duration.components.seconds, duration.components.seconds)
+    XCTAssertEqual(timeout.duration.components.attoseconds, duration.components.attoseconds)
+  }
+
+  func testEncodeValidTimeout_Nanoseconds() {
+    let duration = Duration.nanoseconds(100)
+    let timeout = Timeout(duration: duration)
+    XCTAssertEqual(timeout.duration.components.seconds, duration.components.seconds)
+    XCTAssertEqual(timeout.duration.components.attoseconds, duration.components.attoseconds)
+  }
+}