Skip to content

Commit 2971c0a

Browse files
authored
Add new Timeout type (#1718)
1 parent 3835e14 commit 2971c0a

File tree

4 files changed

+435
-9
lines changed

4 files changed

+435
-9
lines changed

Sources/GRPCCore/Call/Server/Internal/ServerRPCExecutor.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ struct ServerRPCExecutor {
7676
if let timeout = metadata.timeout {
7777
group.addTask {
7878
let result = await Result {
79-
try await Task.sleep(until: .now.advanced(by: timeout), clock: .continuous)
79+
try await Task.sleep(for: timeout, clock: .continuous)
8080
}
8181
return .timedOut(result)
8282
}

Sources/GRPCCore/Internal/Metadata+GRPC.swift

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -39,14 +39,16 @@ extension Metadata {
3939

4040
@inlinable
4141
var timeout: Duration? {
42-
// Temporary hack to support tests; only supports nanoseconds.
43-
guard let value = self.firstString(forKey: .timeout) else { return nil }
44-
guard value.utf8.last == UTF8.CodeUnit(ascii: "n") else { return nil }
45-
var index = value.utf8.endIndex
46-
value.utf8.formIndex(before: &index)
47-
guard let digits = String(value.utf8[..<index]) else { return nil }
48-
guard let nanoseconds = Int64(digits) else { return nil }
49-
return .nanoseconds(nanoseconds)
42+
get {
43+
self.firstString(forKey: .timeout).flatMap { Timeout(decoding: $0)?.duration }
44+
}
45+
set {
46+
if let newValue {
47+
self.replaceOrAddString(String(describing: Timeout(duration: newValue)), forKey: .timeout)
48+
} else {
49+
self.removeAllValues(forKey: .timeout)
50+
}
51+
}
5052
}
5153
}
5254

Sources/GRPCCore/Timeout.swift

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
/*
2+
* Copyright 2023, gRPC Authors All rights reserved.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
import Dispatch
17+
18+
/// A timeout for a gRPC call.
19+
///
20+
/// It's a combination of an amount (expressed as an integer of at maximum 8 digits), and a unit, which is
21+
/// one of ``Timeout/Unit`` (hours, minutes, seconds, milliseconds, microseconds or nanoseconds).
22+
///
23+
/// Timeouts must be positive and at most 8-digits long.
24+
@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
25+
@usableFromInline
26+
struct Timeout: CustomStringConvertible, Hashable, Sendable {
27+
/// Possible units for a ``Timeout``.
28+
internal enum Unit: Character {
29+
case hours = "H"
30+
case minutes = "M"
31+
case seconds = "S"
32+
case milliseconds = "m"
33+
case microseconds = "u"
34+
case nanoseconds = "n"
35+
}
36+
37+
/// The largest amount of any unit of time which may be represented by a gRPC timeout.
38+
static let maxAmount: Int64 = 99_999_999
39+
40+
private let amount: Int64
41+
private let unit: Unit
42+
43+
@usableFromInline
44+
var duration: Duration {
45+
Duration(amount: amount, unit: unit)
46+
}
47+
48+
/// The wire encoding of this timeout as described in the gRPC protocol.
49+
/// See "Timeout" in https://github.com/grpc/grpc/blob/master/doc/PROTOCOL-HTTP2.md#requests
50+
var wireEncoding: String {
51+
"\(amount)\(unit.rawValue)"
52+
}
53+
54+
@usableFromInline
55+
var description: String {
56+
return self.wireEncoding
57+
}
58+
59+
@usableFromInline
60+
init?(decoding value: String) {
61+
guard (2 ... 8).contains(value.count) else {
62+
return nil
63+
}
64+
65+
if let amount = Int64(value.dropLast()),
66+
let unit = Unit(rawValue: value.last!)
67+
{
68+
self = Self.init(amount: amount, unit: unit)
69+
} else {
70+
return nil
71+
}
72+
}
73+
74+
/// Create a ``Timeout`` from a ``Duration``.
75+
///
76+
/// - Important: It's not possible to know with what precision the duration was created: that is,
77+
/// it's not possible to know whether `Duration.seconds(value)` or `Duration.milliseconds(value)`
78+
/// was used. For this reason, the unit chosen for the ``Timeout`` (and thus the wire encoding) may be
79+
/// different from the one originally used to create the ``Duration``. Despite this, we guarantee that
80+
/// both durations will be equivalent if there was no loss in precision during the transformation.
81+
/// For example, `Duration.hours(123)` will yield a ``Timeout`` with `wireEncoding` equal to
82+
/// `"442800S"`, which is in seconds. However, 442800 seconds and 123 hours are equivalent.
83+
/// However, you must note that there may be some loss of precision when dealing with transforming
84+
/// between units. For example, for very low precisions, such as a duration of only a few attoseconds,
85+
/// given the smallest unit we have is whole nanoseconds, we cannot represent it. Same when converting
86+
/// for instance, milliseconds to seconds. In these scenarios, we'll round to the closest whole number in
87+
/// the target unit.
88+
@usableFromInline
89+
init(duration: Duration) {
90+
let (seconds, attoseconds) = duration.components
91+
92+
if seconds == 0 {
93+
// There is no seconds component, so only pay attention to the attoseconds.
94+
// Try converting to nanoseconds first, and continue rounding up if the
95+
// max amount of digits is exceeded.
96+
let nanoseconds = Int64(Double(attoseconds) / 1e+9)
97+
self.init(rounding: nanoseconds, unit: .nanoseconds)
98+
} else if Self.exceedsDigitLimit(seconds) {
99+
// We don't have enough digits to represent this amount in seconds, so
100+
// we will have to use minutes or hours.
101+
// We can also ignore attoseconds, since we won't have enough precision
102+
// anyways to represent the (at most) one second that the attoseconds
103+
// component can express.
104+
self.init(rounding: seconds, unit: .seconds)
105+
} else {
106+
// We can't convert seconds to nanoseconds because that would take us
107+
// over the 8 digit limit (1 second = 1e+9 nanoseconds).
108+
// We can however, try converting to microseconds or milliseconds.
109+
let nanoseconds = Int64(Double(attoseconds) / 1e+9)
110+
let microseconds = nanoseconds / 1000
111+
if microseconds == 0 {
112+
self.init(amount: seconds, unit: .seconds)
113+
} else {
114+
let secondsInMicroseconds = seconds * 1000 * 1000
115+
let totalMicroseconds = microseconds + secondsInMicroseconds
116+
self.init(rounding: totalMicroseconds, unit: .microseconds)
117+
}
118+
}
119+
}
120+
121+
/// Create a timeout by rounding up the timeout so that it may be represented in the gRPC
122+
/// wire format.
123+
private init(rounding amount: Int64, unit: Unit) {
124+
var roundedAmount = amount
125+
var roundedUnit = unit
126+
127+
if roundedAmount <= 0 {
128+
roundedAmount = 0
129+
} else {
130+
while roundedAmount > Timeout.maxAmount {
131+
switch roundedUnit {
132+
case .nanoseconds:
133+
roundedAmount = roundedAmount.quotientRoundedUp(dividingBy: 1000)
134+
roundedUnit = .microseconds
135+
case .microseconds:
136+
roundedAmount = roundedAmount.quotientRoundedUp(dividingBy: 1000)
137+
roundedUnit = .milliseconds
138+
case .milliseconds:
139+
roundedAmount = roundedAmount.quotientRoundedUp(dividingBy: 1000)
140+
roundedUnit = .seconds
141+
case .seconds:
142+
roundedAmount = roundedAmount.quotientRoundedUp(dividingBy: 60)
143+
roundedUnit = .minutes
144+
case .minutes:
145+
roundedAmount = roundedAmount.quotientRoundedUp(dividingBy: 60)
146+
roundedUnit = .hours
147+
case .hours:
148+
roundedAmount = Timeout.maxAmount
149+
roundedUnit = .hours
150+
}
151+
}
152+
}
153+
154+
self.init(amount: roundedAmount, unit: roundedUnit)
155+
}
156+
157+
private static func exceedsDigitLimit(_ value: Int64) -> Bool {
158+
value > Timeout.maxAmount
159+
}
160+
161+
/// Creates a `GRPCTimeout`.
162+
///
163+
/// - Precondition: The amount should be greater than or equal to zero and less than or equal
164+
/// to `GRPCTimeout.maxAmount`.
165+
internal init(amount: Int64, unit: Unit) {
166+
precondition((0 ... Timeout.maxAmount).contains(amount))
167+
168+
self.amount = amount
169+
self.unit = unit
170+
}
171+
}
172+
173+
extension Int64 {
174+
/// Returns the quotient of this value when divided by `divisor` rounded up to the nearest
175+
/// multiple of `divisor` if the remainder is non-zero.
176+
///
177+
/// - Parameter divisor: The value to divide this value by.
178+
fileprivate func quotientRoundedUp(dividingBy divisor: Int64) -> Int64 {
179+
let (quotient, remainder) = self.quotientAndRemainder(dividingBy: divisor)
180+
return quotient + (remainder != 0 ? 1 : 0)
181+
}
182+
}
183+
184+
@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
185+
extension Duration {
186+
/// Construct a `Duration` given a number of minutes represented as an `Int64`.
187+
///
188+
/// let d: Duration = .minutes(5)
189+
///
190+
/// - Returns: A `Duration` representing a given number of minutes.
191+
internal static func minutes(_ minutes: Int64) -> Duration {
192+
return Self.init(secondsComponent: 60 * minutes, attosecondsComponent: 0)
193+
}
194+
195+
/// Construct a `Duration` given a number of hours represented as an `Int64`.
196+
///
197+
/// let d: Duration = .hours(3)
198+
///
199+
/// - Returns: A `Duration` representing a given number of hours.
200+
internal static func hours(_ hours: Int64) -> Duration {
201+
return Self.init(secondsComponent: 60 * 60 * hours, attosecondsComponent: 0)
202+
}
203+
204+
internal init(amount: Int64, unit: Timeout.Unit) {
205+
switch unit {
206+
case .hours:
207+
self = Self.hours(amount)
208+
case .minutes:
209+
self = Self.minutes(amount)
210+
case .seconds:
211+
self = Self.seconds(amount)
212+
case .milliseconds:
213+
self = Self.milliseconds(amount)
214+
case .microseconds:
215+
self = Self.microseconds(amount)
216+
case .nanoseconds:
217+
self = Self.nanoseconds(amount)
218+
}
219+
}
220+
}

0 commit comments

Comments
 (0)