Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
18 changes: 10 additions & 8 deletions Sources/GRPCCore/Internal/Metadata+GRPC.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
}

Expand Down
220 changes: 220 additions & 0 deletions Sources/GRPCCore/Timeout.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Loading