Skip to content

Commit 1c19d3f

Browse files
authored
Convenience method to jitter keepalive interval (grpc#1697)
Motivation: If a large number of clients use keepalive and are brought up at the same time, their ping intervals could align resulting in servers receiving large numbers of pings simultaneously. Modifications: - Add a convenience method to the client keepalive configuration which allows the interval to be jittered while maintaining the invariant that the interval must be greater than the timeout. - Add additional checks on the keepalive invariants Result: Users can easily apply jitter to their keepalive configuration.
1 parent fe75fb1 commit 1c19d3f

File tree

2 files changed

+120
-6
lines changed

2 files changed

+120
-6
lines changed

Sources/GRPC/ConnectionKeepalive.swift

Lines changed: 86 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,21 @@ import NIOCore
2020
/// The defaults are determined by the gRPC keepalive
2121
/// [documentation] (https://github.com/grpc/grpc/blob/master/doc/keepalive.md).
2222
public struct ClientConnectionKeepalive: Hashable, Sendable {
23+
private func checkInvariants(line: UInt = #line) {
24+
precondition(self.timeout < self.interval, "'timeout' must be less than 'interval'", line: line)
25+
}
26+
2327
/// The amount of time to wait before sending a keepalive ping.
24-
public var interval: TimeAmount
28+
public var interval: TimeAmount {
29+
didSet { self.checkInvariants() }
30+
}
2531

2632
/// The amount of time to wait for an acknowledgment.
2733
/// If it does not receive an acknowledgment within this time, it will close the connection
2834
/// This value must be less than ``interval``.
29-
public var timeout: TimeAmount
35+
public var timeout: TimeAmount {
36+
didSet { self.checkInvariants() }
37+
}
3038

3139
/// Send keepalive pings even if there are no calls in flight.
3240
public var permitWithoutCalls: Bool
@@ -45,23 +53,63 @@ public struct ClientConnectionKeepalive: Hashable, Sendable {
4553
maximumPingsWithoutData: UInt = 2,
4654
minimumSentPingIntervalWithoutData: TimeAmount = .minutes(5)
4755
) {
48-
precondition(timeout < interval, "`timeout` must be less than `interval`")
4956
self.interval = interval
5057
self.timeout = timeout
5158
self.permitWithoutCalls = permitWithoutCalls
5259
self.maximumPingsWithoutData = maximumPingsWithoutData
5360
self.minimumSentPingIntervalWithoutData = minimumSentPingIntervalWithoutData
61+
self.checkInvariants()
62+
}
63+
}
64+
65+
extension ClientConnectionKeepalive {
66+
/// Applies jitter to the ``interval``.
67+
///
68+
/// The current ``interval`` will be adjusted by no more than `maxJitter` in either direction,
69+
/// that is the ``interval`` may increase or decrease by no more than `maxJitter`. As
70+
/// the ``timeout`` must be strictly less than the ``interval``, the lower range of the jittered
71+
/// interval is clamped to `max(interval - maxJitter, timeout + .nanoseconds(1)))`.
72+
///
73+
/// - Parameter maxJitter: The maximum amount of jitter to apply to the ``interval``, which may
74+
/// be applied in either direction.
75+
public mutating func jitterInterval(byAtMost maxJitter: TimeAmount) {
76+
// The interval must be larger than the timeout so clamp the lower bound to be greater than
77+
// the timeout.
78+
let lowerBound = max(self.interval - maxJitter, self.timeout + .nanoseconds(1))
79+
let upperBound = self.interval + maxJitter
80+
self.interval = .nanoseconds(.random(in: lowerBound.nanoseconds ... upperBound.nanoseconds))
81+
}
82+
83+
/// Returns a new ``ClientConnectionKeepalive`` with a jittered ``interval``.
84+
///
85+
/// See also ``jitterInterval(byAtMost:)``.
86+
///
87+
/// - Parameter maxJitter: The maximum amount of jitter to apply to the ``interval``, which may
88+
/// be applied in either direction.
89+
/// - Returns: A new ``ClientConnectionKeepalive``.
90+
public func jitteringInterval(byAtMost maxJitter: TimeAmount) -> Self {
91+
var copy = self
92+
copy.jitterInterval(byAtMost: maxJitter)
93+
return copy
5494
}
5595
}
5696

5797
public struct ServerConnectionKeepalive: Hashable {
98+
private func checkInvariants(line: UInt = #line) {
99+
precondition(self.timeout < self.interval, "'timeout' must be less than 'interval'", line: line)
100+
}
101+
58102
/// The amount of time to wait before sending a keepalive ping.
59-
public var interval: TimeAmount
103+
public var interval: TimeAmount {
104+
didSet { self.checkInvariants() }
105+
}
60106

61107
/// The amount of time to wait for an acknowledgment.
62108
/// If it does not receive an acknowledgment within this time, it will close the connection
63109
/// This value must be less than ``interval``.
64-
public var timeout: TimeAmount
110+
public var timeout: TimeAmount {
111+
didSet { self.checkInvariants() }
112+
}
65113

66114
/// Send keepalive pings even if there are no calls in flight.
67115
public var permitWithoutCalls: Bool
@@ -92,13 +140,45 @@ public struct ServerConnectionKeepalive: Hashable {
92140
minimumReceivedPingIntervalWithoutData: TimeAmount = .minutes(5),
93141
maximumPingStrikes: UInt = 2
94142
) {
95-
precondition(timeout < interval, "`timeout` must be less than `interval`")
96143
self.interval = interval
97144
self.timeout = timeout
98145
self.permitWithoutCalls = permitWithoutCalls
99146
self.maximumPingsWithoutData = maximumPingsWithoutData
100147
self.minimumSentPingIntervalWithoutData = minimumSentPingIntervalWithoutData
101148
self.minimumReceivedPingIntervalWithoutData = minimumReceivedPingIntervalWithoutData
102149
self.maximumPingStrikes = maximumPingStrikes
150+
self.checkInvariants()
151+
}
152+
}
153+
154+
extension ServerConnectionKeepalive {
155+
/// Applies jitter to the ``interval``.
156+
///
157+
/// The current ``interval`` will be adjusted by no more than `maxJitter` in either direction,
158+
/// that is the ``interval`` may increase or decrease by no more than `maxJitter`. As
159+
/// the ``timeout`` must be strictly less than the ``interval``, the lower range of the jittered
160+
/// interval is clamped to `max(interval - maxJitter, timeout + .nanoseconds(1)))`.
161+
///
162+
/// - Parameter maxJitter: The maximum amount of jitter to apply to the ``interval``, which may
163+
/// be applied in either direction.
164+
public mutating func jitterInterval(byAtMost maxJitter: TimeAmount) {
165+
// The interval must be larger than the timeout so clamp the lower bound to be greater than
166+
// the timeout.
167+
let lowerBound = max(self.interval - maxJitter, self.timeout + .nanoseconds(1))
168+
let upperBound = self.interval + maxJitter
169+
self.interval = .nanoseconds(.random(in: lowerBound.nanoseconds ... upperBound.nanoseconds))
170+
}
171+
172+
/// Returns a new ``ClientConnectionKeepalive`` with a jittered ``interval``.
173+
///
174+
/// See also ``jitterInterval(byAtMost:)``.
175+
///
176+
/// - Parameter maxJitter: The maximum amount of jitter to apply to the ``interval``, which may
177+
/// be applied in either direction.
178+
/// - Returns: A new ``ClientConnectionKeepalive``.
179+
public func jitteringInterval(byAtMost maxJitter: TimeAmount) -> Self {
180+
var copy = self
181+
copy.jitterInterval(byAtMost: maxJitter)
182+
return copy
103183
}
104184
}

Tests/GRPCTests/ConnectionManagerTests.swift

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1277,6 +1277,40 @@ extension ConnectionManagerTests {
12771277

12781278
XCTAssertThrowsError(try multiplexer.wait())
12791279
}
1280+
1281+
func testClientKeepaliveJitterWithoutClamping() {
1282+
let original = ClientConnectionKeepalive(interval: .seconds(2), timeout: .seconds(1))
1283+
let keepalive = original.jitteringInterval(byAtMost: .milliseconds(500))
1284+
1285+
XCTAssertGreaterThanOrEqual(keepalive.interval, .milliseconds(1500))
1286+
XCTAssertLessThanOrEqual(keepalive.interval, .milliseconds(2500))
1287+
}
1288+
1289+
func testClientKeepaliveJitterClampedToTimeout() {
1290+
let original = ClientConnectionKeepalive(interval: .seconds(2), timeout: .seconds(1))
1291+
let keepalive = original.jitteringInterval(byAtMost: .seconds(2))
1292+
1293+
// Strictly greater than the timeout of 1 seconds.
1294+
XCTAssertGreaterThan(keepalive.interval, .seconds(1))
1295+
XCTAssertLessThanOrEqual(keepalive.interval, .seconds(4))
1296+
}
1297+
1298+
func testServerKeepaliveJitterWithoutClamping() {
1299+
let original = ServerConnectionKeepalive(interval: .seconds(2), timeout: .seconds(1))
1300+
let keepalive = original.jitteringInterval(byAtMost: .milliseconds(500))
1301+
1302+
XCTAssertGreaterThanOrEqual(keepalive.interval, .milliseconds(1500))
1303+
XCTAssertLessThanOrEqual(keepalive.interval, .milliseconds(2500))
1304+
}
1305+
1306+
func testServerKeepaliveJitterClampedToTimeout() {
1307+
let original = ServerConnectionKeepalive(interval: .seconds(2), timeout: .seconds(1))
1308+
let keepalive = original.jitteringInterval(byAtMost: .seconds(2))
1309+
1310+
// Strictly greater than the timeout of 1 seconds.
1311+
XCTAssertGreaterThan(keepalive.interval, .seconds(1))
1312+
XCTAssertLessThanOrEqual(keepalive.interval, .seconds(4))
1313+
}
12801314
}
12811315

12821316
internal struct Change: Hashable, CustomStringConvertible {

0 commit comments

Comments
 (0)