Skip to content

Commit 3028568

Browse files
committed
fix: renew long-lived subscriptions
Signed-off-by: mattisonchao <mattisonchao@gmail.com>
1 parent 1dc3c79 commit 3028568

12 files changed

Lines changed: 583 additions & 41 deletions

File tree

client-api/src/main/java/io/oxia/client/api/OxiaClientBuilder.java

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,44 @@ static OxiaClientBuilder create(String serviceAddress) {
259259
*/
260260
OxiaClientBuilder connectionKeepAliveTime(Duration connectionKeepAlive);
261261

262+
/**
263+
* Configure the maximum age of long-lived subscriptions.
264+
*
265+
* <p>Here, a subscription means a client operation that continuously receives shard assignments,
266+
* notifications, or sequence updates. This setting does not expose the underlying transport and
267+
* is not an inactivity timeout. The client transparently renews each subscription at a random age
268+
* between half this value and this value, even when it is healthy.
269+
*
270+
* <p>Bounding the age lets the client recover when an intermediary loses its upstream connection
271+
* while leaving the downstream connection apparently healthy. Randomization spreads renewals
272+
* across clients, following the Kubernetes {@code client-go} Reflector pattern.
273+
*
274+
* <p>Renewal preserves logical progress: shard assignments restart from a complete snapshot,
275+
* notifications continue after the last received offset, and sequence updates suppress the
276+
* repeated current key returned when they restart.
277+
*
278+
* <p>Default is <code>10 minutes</code>, resulting in subscription ages between 5 and 10 minutes.
279+
* Calling this method also re-enables the maximum age if it was previously disabled with {@link
280+
* #disableSubscriptionMaxAge()}.
281+
*
282+
* @param subscriptionMaxAge the upper bound for a subscription's randomized age
283+
* @return the builder instance
284+
* @see <a
285+
* href="https://github.com/kubernetes/client-go/blob/master/tools/cache/reflector.go">Kubernetes
286+
* client-go Reflector</a>
287+
*/
288+
OxiaClientBuilder subscriptionMaxAge(Duration subscriptionMaxAge);
289+
290+
/**
291+
* Disable the maximum age for long-lived subscriptions.
292+
*
293+
* <p>With the maximum age disabled, a subscription can appear healthy indefinitely if an
294+
* intermediary loses its upstream connection while keeping the downstream HTTP/2 connection open.
295+
*
296+
* @return the builder instance
297+
*/
298+
OxiaClientBuilder disableSubscriptionMaxAge();
299+
262300
/**
263301
* Configure the authentication plugin and its parameters.
264302
*

client/src/main/java/io/oxia/client/AsyncOxiaClientImpl.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -795,7 +795,8 @@ public Closeable getSequenceUpdates(
795795
this.rpcProvider,
796796
this.shardManager,
797797
this.instrumentProvider,
798-
x -> closed);
798+
x -> closed,
799+
this.scheduledExecutor);
799800
}
800801

801802
@Override

client/src/main/java/io/oxia/client/ClientConfig.java

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,4 +40,49 @@ public record ClientConfig(
4040
@NonNull Duration connectionBackoffMaxDelay,
4141
Duration connectionKeepAliveTime,
4242
Duration connectionKeepAliveTimeout,
43-
int maxConnectionPerNode) {}
43+
int maxConnectionPerNode,
44+
@Nullable Duration subscriptionMaxAge) {
45+
46+
public ClientConfig(
47+
@NonNull String serviceAddress,
48+
@NonNull Duration requestTimeout,
49+
int maxRequestsPerBatch,
50+
int maxBatchSize,
51+
long maxPendingBytes,
52+
int maxWriteBatchesInFlight,
53+
int maxReadBatchesInFlight,
54+
int batchingThreads,
55+
@NonNull Duration sessionTimeout,
56+
@NonNull String clientIdentifier,
57+
OpenTelemetry openTelemetry,
58+
@NonNull String namespace,
59+
@Nullable Authentication authentication,
60+
boolean enableTls,
61+
@NonNull Duration connectionBackoffMinDelay,
62+
@NonNull Duration connectionBackoffMaxDelay,
63+
Duration connectionKeepAliveTime,
64+
Duration connectionKeepAliveTimeout,
65+
int maxConnectionPerNode) {
66+
this(
67+
serviceAddress,
68+
requestTimeout,
69+
maxRequestsPerBatch,
70+
maxBatchSize,
71+
maxPendingBytes,
72+
maxWriteBatchesInFlight,
73+
maxReadBatchesInFlight,
74+
batchingThreads,
75+
sessionTimeout,
76+
clientIdentifier,
77+
openTelemetry,
78+
namespace,
79+
authentication,
80+
enableTls,
81+
connectionBackoffMinDelay,
82+
connectionBackoffMaxDelay,
83+
connectionKeepAliveTime,
84+
connectionKeepAliveTimeout,
85+
maxConnectionPerNode,
86+
OxiaClientBuilderImpl.DefaultSubscriptionMaxAge);
87+
}
88+
}

client/src/main/java/io/oxia/client/OxiaClientBuilderImpl.java

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ public class OxiaClientBuilderImpl implements OxiaClientBuilder {
5757
public static final int DefaultBatchingThreads = 1;
5858
public static final Duration DefaultRequestTimeout = Duration.ofSeconds(30);
5959
public static final Duration DefaultSessionTimeout = Duration.ofSeconds(15);
60+
public static final Duration DefaultSubscriptionMaxAge = Duration.ofMinutes(10);
6061
public static final String DefaultNamespace = "default";
6162
public static final boolean DefaultEnableTls = false;
6263
public static final int DefaultMaxConnectionPerNode = 1;
@@ -92,6 +93,8 @@ public class OxiaClientBuilderImpl implements OxiaClientBuilder {
9293
protected Duration connectionKeepAliveTime = Duration.ofSeconds(10);
9394
protected Duration connectionKeepAliveTimeout = Duration.ofSeconds(3);
9495

96+
@Nullable protected Duration subscriptionMaxAge = DefaultSubscriptionMaxAge;
97+
9598
protected int maxConnectionsPerNode = DefaultMaxConnectionPerNode;
9699

97100
@Nullable protected SharedResources sharedResources;
@@ -252,6 +255,29 @@ public OxiaClientBuilder connectionKeepAliveTime(Duration keepAliveTime) {
252255
return this;
253256
}
254257

258+
@Override
259+
public OxiaClientBuilder subscriptionMaxAge(@NonNull Duration subscriptionMaxAge) {
260+
final long maxAgeMillis;
261+
try {
262+
maxAgeMillis = subscriptionMaxAge.toMillis();
263+
} catch (ArithmeticException e) {
264+
throw new IllegalArgumentException(
265+
"subscriptionMaxAge is too large: " + subscriptionMaxAge, e);
266+
}
267+
if (maxAgeMillis < 2) {
268+
throw new IllegalArgumentException(
269+
"subscriptionMaxAge must be at least 2 ms: " + subscriptionMaxAge);
270+
}
271+
this.subscriptionMaxAge = subscriptionMaxAge;
272+
return this;
273+
}
274+
275+
@Override
276+
public OxiaClientBuilder disableSubscriptionMaxAge() {
277+
this.subscriptionMaxAge = null;
278+
return this;
279+
}
280+
255281
@Override
256282
public OxiaClientBuilder authentication(String authPluginClassName, String authParamsString)
257283
throws UnsupportedAuthenticationException {
@@ -392,7 +418,8 @@ public ClientConfig getClientConfig() {
392418
connectionBackoffMaxDelay,
393419
connectionKeepAliveTime,
394420
connectionKeepAliveTimeout,
395-
maxConnectionsPerNode);
421+
maxConnectionsPerNode,
422+
subscriptionMaxAge);
396423
}
397424

398425
@Override

client/src/main/java/io/oxia/client/SequenceUpdates.java

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,10 @@
1515
*/
1616
package io.oxia.client;
1717

18+
import static com.google.common.base.Throwables.getRootCause;
19+
1820
import io.github.merlimat.slog.Logger;
21+
import io.grpc.Status;
1922
import io.opentelemetry.api.common.Attributes;
2023
import io.oxia.client.grpc.RpcProvider;
2124
import io.oxia.client.grpc.observer.CancelableStreamObserver;
@@ -27,6 +30,7 @@
2730
import io.oxia.proto.GetSequenceUpdatesResponse;
2831
import java.io.Closeable;
2932
import java.io.IOException;
33+
import java.util.concurrent.ScheduledExecutorService;
3034
import java.util.function.Consumer;
3135
import java.util.function.Function;
3236
import lombok.NonNull;
@@ -43,9 +47,11 @@ public class SequenceUpdates implements Closeable {
4347
private final ShardManager shardManager;
4448
private final Counter counterSequenceUpdatesReceived;
4549
private final Function<Void, Boolean> isClientClosed;
50+
private final ScheduledExecutorService executor;
4651

4752
private boolean closed = false;
4853
private CancelableStreamObserver<?> stream;
54+
private String lastDeliveredSequenceKey;
4955

5056
SequenceUpdates(
5157
@NonNull String key,
@@ -54,13 +60,15 @@ public class SequenceUpdates implements Closeable {
5460
@NonNull RpcProvider rpcProvider,
5561
@NonNull ShardManager shardManager,
5662
@NonNull InstrumentProvider instrumentProvider,
57-
Function<Void, Boolean> isClientClosed) {
63+
Function<Void, Boolean> isClientClosed,
64+
@NonNull ScheduledExecutorService executor) {
5865
this.key = key;
5966
this.partitionKey = partitionKey;
6067
this.listener = listener;
6168
this.rpcProvider = rpcProvider;
6269
this.shardManager = shardManager;
6370
this.isClientClosed = isClientClosed;
71+
this.executor = executor;
6472

6573
this.counterSequenceUpdatesReceived =
6674
instrumentProvider.newCounter(
@@ -73,7 +81,7 @@ public class SequenceUpdates implements Closeable {
7381
}
7482

7583
private synchronized void createStream() {
76-
if (closed) {
84+
if (closed || isClientClosed.apply(null)) {
7785
return;
7886
}
7987

@@ -115,20 +123,38 @@ public void close() throws IOException {
115123
}
116124
}
117125

118-
private void handleUpdate(@NonNull GetSequenceUpdatesResponse value) {
119-
listener.accept(value.getHighestSequenceKey());
126+
private synchronized void handleUpdate(@NonNull GetSequenceUpdatesResponse value) {
127+
var highestSequenceKey = value.getHighestSequenceKey();
128+
if (highestSequenceKey.equals(lastDeliveredSequenceKey)) {
129+
// A renewed subscription starts with the server's current highest sequence key. Skip it
130+
// when the previous subscription already delivered that value.
131+
return;
132+
}
133+
lastDeliveredSequenceKey = highestSequenceKey;
134+
listener.accept(highestSequenceKey);
120135
counterSequenceUpdatesReceived.increment();
121136
}
122137

123138
private synchronized void handleError(@NonNull Throwable t) {
124139
if (closed || isClientClosed.apply(null)) {
125140
return;
126141
}
127-
log.warn().exception(t).log("Failure while processing sequence updates");
128-
createStream();
142+
if (Status.fromThrowable(getRootCause(t)).getCode() == Status.Code.DEADLINE_EXCEEDED) {
143+
log.debug("Sequence updates subscription reached its configured maximum age");
144+
} else {
145+
log.warn().exception(t).log("Failure while processing sequence updates");
146+
}
147+
scheduleRestart();
129148
}
130149

131150
private synchronized void handleCompleted() {
132-
createStream();
151+
if (closed || isClientClosed.apply(null)) {
152+
return;
153+
}
154+
scheduleRestart();
155+
}
156+
157+
private void scheduleRestart() {
158+
executor.execute(this::createStream);
133159
}
134160
}

client/src/main/java/io/oxia/client/grpc/GrpcRpcProvider.java

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
import io.oxia.proto.ListResponse;
3838
import io.oxia.proto.NotificationBatch;
3939
import io.oxia.proto.NotificationsRequest;
40+
import io.oxia.proto.OxiaClientGrpc;
4041
import io.oxia.proto.RangeScanRequest;
4142
import io.oxia.proto.RangeScanResponse;
4243
import io.oxia.proto.ReadRequest;
@@ -50,6 +51,7 @@
5051
import java.util.Map;
5152
import java.util.concurrent.CompletableFuture;
5253
import java.util.concurrent.ScheduledExecutorService;
54+
import java.util.concurrent.ThreadLocalRandom;
5355
import java.util.concurrent.TimeUnit;
5456
import java.util.concurrent.atomic.AtomicReference;
5557
import java.util.function.LongFunction;
@@ -119,10 +121,10 @@ public void getShardAssignments(
119121
final var barrierObserver =
120122
ManagedObservers.toBarrierStreamObserver(guardedObserver, barrierFuture);
121123
try {
122-
connectionManager
123-
.getConnection(clientConfig.serviceAddress())
124-
.stub()
125-
.getShardAssignments(request, barrierObserver);
124+
var stub =
125+
withSubscriptionMaxAge(
126+
connectionManager.getConnection(clientConfig.serviceAddress()).stub());
127+
stub.getShardAssignments(request, barrierObserver);
126128
} catch (Throwable error) {
127129
barrierFuture.completeExceptionally(OxiaStatusException.from(error));
128130
}
@@ -154,9 +156,10 @@ public void getNotifications(
154156
final var barrierObserver =
155157
ManagedObservers.toBarrierStreamObserver(guardedObserver, barrierFuture);
156158
try {
157-
connectionManager
158-
.getConnection(getLeader(request.getShard(), hint))
159-
.stub()
159+
withSubscriptionMaxAge(
160+
connectionManager
161+
.getConnection(getLeader(request.getShard(), hint))
162+
.stub())
160163
.getNotifications(request, barrierObserver);
161164
} catch (Throwable error) {
162165
barrierFuture.completeExceptionally(OxiaStatusException.from(error));
@@ -394,9 +397,10 @@ public void getSequenceUpdates(
394397
final var barrierObserver =
395398
ManagedObservers.toBarrierClientResponseObserver(observer, barrierFuture);
396399
try {
397-
connectionManager
398-
.getConnection(getLeader(request.getShard(), hint))
399-
.stub()
400+
withSubscriptionMaxAge(
401+
connectionManager
402+
.getConnection(getLeader(request.getShard(), hint))
403+
.stub())
400404
.getSequenceUpdates(request, barrierObserver);
401405
} catch (Throwable error) {
402406
barrierFuture.completeExceptionally(OxiaStatusException.from(error));
@@ -413,6 +417,20 @@ public void getSequenceUpdates(
413417
}
414418
}
415419

420+
private OxiaClientGrpc.OxiaClientStub withSubscriptionMaxAge(OxiaClientGrpc.OxiaClientStub stub) {
421+
var maxAge = clientConfig.subscriptionMaxAge();
422+
if (maxAge == null) {
423+
return stub;
424+
}
425+
426+
long maxAgeMillis = maxAge.toMillis();
427+
long minAgeMillis = maxAgeMillis / 2;
428+
// Match Kubernetes Reflector watches: renew each subscription at a random point in
429+
// [maxAge/2, maxAge) to prevent hanging operations without synchronizing clients.
430+
long ageMillis = ThreadLocalRandom.current().nextLong(minAgeMillis, maxAgeMillis);
431+
return stub.withDeadlineAfter(ageMillis, TimeUnit.MILLISECONDS);
432+
}
433+
416434
@Override
417435
public void close() throws Exception {
418436
try {

0 commit comments

Comments
 (0)