Skip to content

Commit 46e46f0

Browse files
authored
Make primary channel failover timeout configurable (apache#39646)
1 parent 9451007 commit 46e46f0

4 files changed

Lines changed: 165 additions & 27 deletions

File tree

runners/google-cloud-dataflow-java/worker/src/main/java/org/apache/beam/runners/dataflow/worker/StreamingDataflowWorker.java

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
import java.util.concurrent.ScheduledExecutorService;
3535
import java.util.concurrent.TimeUnit;
3636
import java.util.concurrent.atomic.AtomicBoolean;
37+
import java.util.concurrent.atomic.AtomicLong;
3738
import java.util.concurrent.atomic.AtomicReference;
3839
import java.util.function.BooleanSupplier;
3940
import java.util.function.Consumer;
@@ -173,6 +174,8 @@ public final class StreamingDataflowWorker {
173174
private static final String CHANNELZ_PATH = "/channelz";
174175
private static final String BEAM_FN_API_EXPERIMENT = "beam_fn_api";
175176
private static final String ELEMENT_METADATA_SUPPORTED_EXPERIMENT = "element_metadata_supported";
177+
private static final AtomicLong DIRECTPATH_PRIMARY_NOT_READY_WAIT_MILLIS =
178+
new AtomicLong(TimeUnit.SECONDS.toMillis(15));
176179

177180
@SuppressWarnings("unused")
178181
private static final String STREAMING_ENGINE_USE_JOB_SETTINGS_FOR_HEARTBEAT_POOL_EXPERIMENT =
@@ -867,16 +870,20 @@ private static ChannelCache createChannelCache(
867870
workerOptions.getWindmillServiceRpcChannelAliveTimeoutSec(),
868871
currentFlowControlSettings),
869872
MoreCallCredentials.from(
870-
new VendoredCredentialsAdapter(workerOptions.getGcpCredential()))),
873+
new VendoredCredentialsAdapter(workerOptions.getGcpCredential())),
874+
DIRECTPATH_PRIMARY_NOT_READY_WAIT_MILLIS::get),
871875
currentFlowControlSettings.getOnReadyThresholdBytes());
872876
});
873877

874878
configFetcher
875879
.getGlobalConfigHandle()
876880
.registerConfigObserver(
877-
config ->
878-
channelCache.consumeFlowControlSettings(
879-
config.userWorkerJobSettings().getFlowControlSettings()));
881+
config -> {
882+
DIRECTPATH_PRIMARY_NOT_READY_WAIT_MILLIS.set(
883+
config.userWorkerJobSettings().getDirectpathPrimaryNotReadyWaitMillis());
884+
channelCache.consumeFlowControlSettings(
885+
config.userWorkerJobSettings().getFlowControlSettings());
886+
});
880887
return channelCache;
881888
}
882889

runners/google-cloud-dataflow-java/worker/src/main/java/org/apache/beam/runners/dataflow/worker/windmill/client/grpc/stubs/FailoverChannel.java

Lines changed: 44 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,9 @@
4545
* <p>Routes requests to either primary or fallback channel based on two independent failover modes:
4646
*
4747
* <ul>
48-
* <li><b>Connection Status Failover:</b> If the primary channel is not ready for 10+ seconds
49-
* (e.g., during network issues), routes to fallback channel. Switches back as soon as the
50-
* primary channel becomes READY again.
48+
* <li><b>Connection Status Failover:</b> If the primary channel is not ready for the configured
49+
* wait time (e.g., during network issues), routes to fallback channel. Switches back as soon
50+
* as the primary channel becomes READY again.
5151
* <li><b>RPC Failover:</b> If primary channel RPCs fail continuously with transient errors
5252
* ({@link Status.Code#UNAVAILABLE} or {@link Status.Code#UNKNOWN}), or with {@link
5353
* Status.Code#DEADLINE_EXCEEDED} before receiving any response (indicating the connection was
@@ -61,7 +61,6 @@ public final class FailoverChannel extends ManagedChannel {
6161
private static final AtomicInteger CHANNEL_ID_COUNTER = new AtomicInteger(0);
6262
// Time to wait before retrying the primary channel after an RPC-based fallback.
6363
private static final long FALLBACK_COOLING_PERIOD_NANOS = TimeUnit.HOURS.toNanos(1);
64-
private static final long PRIMARY_NOT_READY_WAIT_NANOS = TimeUnit.SECONDS.toNanos(10);
6564
// Minimum duration of continuous RPC failures required before switching to fallback.
6665
private static final long RPC_FAILURE_THRESHOLD_NANOS = TimeUnit.SECONDS.toNanos(30);
6766

@@ -96,10 +95,15 @@ private static final class FailoverState {
9695

9796
private final int channelId;
9897
private final long rpcFailureThresholdNanos;
98+
private final LongSupplier primaryNotReadyWaitMillisSupplier;
9999

100-
FailoverState(int channelId, long rpcFailureThresholdNanos) {
100+
FailoverState(
101+
int channelId,
102+
long rpcFailureThresholdNanos,
103+
LongSupplier primaryNotReadyWaitMillisSupplier) {
101104
this.channelId = channelId;
102105
this.rpcFailureThresholdNanos = rpcFailureThresholdNanos;
106+
this.primaryNotReadyWaitMillisSupplier = primaryNotReadyWaitMillisSupplier;
103107
}
104108

105109
/**
@@ -118,14 +122,16 @@ synchronized boolean computeUseFallback(long nowNanos) {
118122
}
119123
// Check if primary has been not-ready long enough to switch to fallback.
120124
// primaryNotReadySinceNanos is set by the state-change callback when primary is not ready.
121-
if (!useFallbackDueToRPC
122-
&& !useFallbackDueToState
123-
&& primaryNotReadySinceNanos >= 0
124-
&& nowNanos - primaryNotReadySinceNanos > PRIMARY_NOT_READY_WAIT_NANOS) {
125-
useFallbackDueToState = true;
126-
LOG.warn(
127-
"[channel-{}] Primary connection unavailable. Switching to secondary connection.",
128-
channelId);
125+
if (!useFallbackDueToRPC && !useFallbackDueToState && primaryNotReadySinceNanos >= 0) {
126+
long elapsedPrimaryNotReadyNanos = nowNanos - primaryNotReadySinceNanos;
127+
long primaryNotReadyWaitNanos =
128+
TimeUnit.MILLISECONDS.toNanos(primaryNotReadyWaitMillisSupplier.getAsLong());
129+
if (elapsedPrimaryNotReadyNanos > primaryNotReadyWaitNanos) {
130+
useFallbackDueToState = true;
131+
LOG.warn(
132+
"[channel-{}] Primary connection unavailable. Switching to secondary connection.",
133+
channelId);
134+
}
129135
}
130136
return useFallbackDueToRPC || useFallbackDueToState;
131137
}
@@ -193,11 +199,13 @@ private FailoverChannel(
193199
Supplier<ManagedChannel> fallbackSupplier,
194200
@Nullable CallCredentials fallbackCallCredentials,
195201
LongSupplier nanoClock,
196-
long rpcFailureThresholdNanos) {
202+
long rpcFailureThresholdNanos,
203+
LongSupplier primaryNotReadyWaitMillisSupplier) {
197204
this.primary = primary;
198205
this.fallbackSupplier = Suppliers.memoize(fallbackSupplier::get);
199206
this.channelId = CHANNEL_ID_COUNTER.getAndIncrement();
200-
this.state = new FailoverState(channelId, rpcFailureThresholdNanos);
207+
this.state =
208+
new FailoverState(channelId, rpcFailureThresholdNanos, primaryNotReadyWaitMillisSupplier);
201209
this.fallbackCallCredentials = fallbackCallCredentials;
202210
this.nanoClock = nanoClock;
203211
// Register callback to monitor primary channel state changes
@@ -207,23 +215,31 @@ private FailoverChannel(
207215
public static FailoverChannel create(
208216
ManagedChannel primary,
209217
Supplier<ManagedChannel> fallbackSupplier,
210-
CallCredentials fallbackCallCredentials) {
218+
CallCredentials fallbackCallCredentials,
219+
LongSupplier primaryNotReadyWaitMillisSupplier) {
211220
return new FailoverChannel(
212221
primary,
213222
fallbackSupplier,
214223
fallbackCallCredentials,
215224
System::nanoTime,
216-
RPC_FAILURE_THRESHOLD_NANOS);
225+
RPC_FAILURE_THRESHOLD_NANOS,
226+
primaryNotReadyWaitMillisSupplier);
217227
}
218228

219229
static FailoverChannel forTest(
220230
ManagedChannel primary,
221231
ManagedChannel fallback,
222232
CallCredentials fallbackCallCredentials,
223233
LongSupplier nanoClock,
224-
long rpcFailureThresholdNanos) {
234+
long rpcFailureThresholdNanos,
235+
LongSupplier primaryNotReadyWaitMillisSupplier) {
225236
return new FailoverChannel(
226-
primary, () -> fallback, fallbackCallCredentials, nanoClock, rpcFailureThresholdNanos);
237+
primary,
238+
() -> fallback,
239+
fallbackCallCredentials,
240+
nanoClock,
241+
rpcFailureThresholdNanos,
242+
primaryNotReadyWaitMillisSupplier);
227243
}
228244

229245
/** Returns the fallback channel, creating it from the supplier at most once. */
@@ -399,7 +415,12 @@ private void registerPrimaryStateChangeListener() {
399415
// never transitions, markPrimaryNotReady() would never be called and state-based
400416
// failover would not trigger even after the grace period.
401417
if (currentState == ConnectivityState.READY || currentState == ConnectivityState.IDLE) {
402-
state.markPrimaryReady();
418+
if (state.markPrimaryReady()) {
419+
LOG.info(
420+
"[channel-{}] Primary channel observed healthy during state change registration;"
421+
+ " switching back from fallback.",
422+
channelId);
423+
}
403424
} else {
404425
// Seed the not-ready timer even if there is no future state transition.
405426
state.markPrimaryNotReady(nanoClock.getAsLong());
@@ -426,11 +447,12 @@ private void onPrimaryStateChanged() {
426447
if (newState == ConnectivityState.READY || newState == ConnectivityState.IDLE) {
427448
if (state.markPrimaryReady()) {
428449
LOG.info(
429-
"[channel-{}] Primary channel recovered; switching back from fallback.", channelId);
450+
"[channel-{}] Primary channel observed healthy during state change notification; switching back from fallback.",
451+
channelId);
430452
}
431453
} else {
432454
// Primary is not ready; start the grace period timer so computeUseFallback can
433-
// switch to fallback once PRIMARY_NOT_READY_WAIT_NANOS elapses.
455+
// switch to fallback once the configured wait time elapses.
434456
state.markPrimaryNotReady(nanoClock.getAsLong());
435457
}
436458

runners/google-cloud-dataflow-java/worker/src/test/java/org/apache/beam/runners/dataflow/worker/windmill/client/grpc/stubs/FailoverChannelTest.java

Lines changed: 106 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
import static org.mockito.Mockito.doAnswer;
2626
import static org.mockito.Mockito.mock;
2727
import static org.mockito.Mockito.never;
28+
import static org.mockito.Mockito.times;
2829
import static org.mockito.Mockito.verify;
2930
import static org.mockito.Mockito.when;
3031

@@ -87,7 +88,8 @@ private static FailoverChannel createForTest(
8788
fallback,
8889
fallbackCallCredentials,
8990
nanoClock,
90-
rpcFailureThresholdNanos != null ? rpcFailureThresholdNanos : 0L);
91+
rpcFailureThresholdNanos != null ? rpcFailureThresholdNanos : 0L,
92+
() -> TimeUnit.SECONDS.toMillis(10));
9193
}
9294

9395
/**
@@ -283,11 +285,110 @@ public void testStateFallbackAfterPrimaryNotReady() {
283285
// Within 10 seconds: grace period not elapsed, routes to primary.
284286
failoverChannel.newCall(methodDescriptor, CallOptions.DEFAULT);
285287
verify(mockChannel).newCall(any(), any());
288+
// Fallback must not have been used yet.
289+
verify(mockFallbackChannel, never()).newCall(any(), any());
286290

287291
// After 10 seconds: routes to fallback.
288292
time.addAndGet(TimeUnit.SECONDS.toNanos(11));
289293
failoverChannel.newCall(methodDescriptor, CallOptions.DEFAULT);
290294
verify(mockFallbackChannel).newCall(any(), any());
295+
// Primary must not have been used for this call (still only 1 invocation).
296+
verify(mockChannel, times(1)).newCall(any(), any());
297+
}
298+
299+
@Test
300+
public void testTimeoutThresholdDecreaseTriggersFallbackEarlier() {
301+
ManagedChannel mockChannel = mock(ManagedChannel.class);
302+
ManagedChannel mockFallbackChannel = mock(ManagedChannel.class);
303+
when(mockChannel.newCall(any(), any())).thenReturn(mock(ClientCall.class));
304+
when(mockFallbackChannel.newCall(any(), any())).thenReturn(mock(ClientCall.class));
305+
// Simulate primary being TRANSIENT_FAILURE from the start.
306+
when(mockChannel.getState(false)).thenReturn(ConnectivityState.TRANSIENT_FAILURE);
307+
308+
AtomicLong time = new AtomicLong(0);
309+
// Start with 10s timeout
310+
AtomicLong timeoutThreshold = new AtomicLong(TimeUnit.SECONDS.toMillis(10));
311+
312+
// Constructor seeds timer at time=0.
313+
FailoverChannel failoverChannel =
314+
FailoverChannel.forTest(
315+
mockChannel, mockFallbackChannel, null, time::get, 0L, timeoutThreshold::get);
316+
317+
// Call at time=0. elapsed 0 <= 10s —> failover condition false.
318+
failoverChannel.newCall(methodDescriptor, CallOptions.DEFAULT);
319+
verify(mockChannel).newCall(any(), any());
320+
321+
// Advance time by 5 seconds.
322+
time.addAndGet(TimeUnit.SECONDS.toNanos(5));
323+
324+
// Call at time=5s. elapsed 5s <= 10s —> failover condition false.
325+
// Primary is used.
326+
failoverChannel.newCall(methodDescriptor, CallOptions.DEFAULT);
327+
verify(mockChannel, times(2)).newCall(any(), any());
328+
// Fallback must not have been used yet.
329+
verify(mockFallbackChannel, never()).newCall(any(), any());
330+
331+
// Decrease threshold to 2 seconds.
332+
timeoutThreshold.set(TimeUnit.SECONDS.toMillis(2));
333+
334+
// Call at time=5s. elapsed 5s > 2s —> failover condition true.
335+
// Fallback is used.
336+
failoverChannel.newCall(methodDescriptor, CallOptions.DEFAULT);
337+
verify(mockFallbackChannel).newCall(any(), any());
338+
// Primary must not have been used for this call (still only 2 invocations).
339+
verify(mockChannel, times(2)).newCall(any(), any());
340+
}
341+
342+
@Test
343+
public void testTimeoutThresholdIncreaseDelaysFallback() {
344+
ManagedChannel mockChannel = mock(ManagedChannel.class);
345+
ManagedChannel mockFallbackChannel = mock(ManagedChannel.class);
346+
when(mockChannel.newCall(any(), any())).thenReturn(mock(ClientCall.class));
347+
when(mockFallbackChannel.newCall(any(), any())).thenReturn(mock(ClientCall.class));
348+
// Simulate primary being TRANSIENT_FAILURE from the start.
349+
when(mockChannel.getState(false)).thenReturn(ConnectivityState.TRANSIENT_FAILURE);
350+
351+
AtomicLong time = new AtomicLong(0);
352+
// Start with 10s timeout
353+
AtomicLong timeoutThreshold = new AtomicLong(TimeUnit.SECONDS.toMillis(10));
354+
355+
// Constructor seeds timer at time=0.
356+
FailoverChannel failoverChannel =
357+
FailoverChannel.forTest(
358+
mockChannel, mockFallbackChannel, null, time::get, 0L, timeoutThreshold::get);
359+
360+
// Advance time by 9 seconds.
361+
time.addAndGet(TimeUnit.SECONDS.toNanos(9));
362+
363+
// Call at time=9s. elapsed 9s <= 10s —> failover condition false.
364+
// Primary is used.
365+
failoverChannel.newCall(methodDescriptor, CallOptions.DEFAULT);
366+
verify(mockChannel).newCall(any(), any());
367+
// Fallback must not have been used yet.
368+
verify(mockFallbackChannel, never()).newCall(any(), any());
369+
370+
// Increase threshold to 20 seconds.
371+
timeoutThreshold.set(TimeUnit.SECONDS.toMillis(20));
372+
373+
// Advance time by 5 seconds (total 14s).
374+
time.addAndGet(TimeUnit.SECONDS.toNanos(5));
375+
376+
// Call at time=14s. elapsed 14s <= 20s —> failover condition false.
377+
// Still routes to primary because of increased threshold
378+
failoverChannel.newCall(methodDescriptor, CallOptions.DEFAULT);
379+
verify(mockChannel, times(2)).newCall(any(), any());
380+
// Fallback must still not have been used yet.
381+
verify(mockFallbackChannel, never()).newCall(any(), any());
382+
383+
// Advance time by 7s (total 21s).
384+
time.addAndGet(TimeUnit.SECONDS.toNanos(7));
385+
386+
// Call at time=21s. elapsed 21s > 20s —> failover condition true.
387+
// Fallback is used.
388+
failoverChannel.newCall(methodDescriptor, CallOptions.DEFAULT);
389+
verify(mockFallbackChannel).newCall(any(), any());
390+
// Primary must not have been used for this call (still only 2 invocations).
391+
verify(mockChannel, times(2)).newCall(any(), any());
291392
}
292393

293394
@Test
@@ -307,11 +408,15 @@ public void testStateFallbackWhenPrimaryStartsNonReadyWithoutTransition() {
307408
// Before grace period, still routes to primary.
308409
failoverChannel.newCall(methodDescriptor, CallOptions.DEFAULT);
309410
verify(mockChannel).newCall(any(), any());
411+
// Fallback must not have been used yet.
412+
verify(mockFallbackChannel, never()).newCall(any(), any());
310413

311414
// After 10 seconds in non-ready state, should route to fallback.
312415
time.addAndGet(TimeUnit.SECONDS.toNanos(11));
313416
failoverChannel.newCall(methodDescriptor, CallOptions.DEFAULT);
314417
verify(mockFallbackChannel).newCall(any(), any());
418+
// Primary must not have been used for this call (still only 1 invocation).
419+
verify(mockChannel, times(1)).newCall(any(), any());
315420
}
316421

317422
@Test

runners/google-cloud-dataflow-java/worker/windmill/src/main/proto/windmill.proto

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1018,6 +1018,10 @@ message UserWorkerRunnerV1Settings {
10181018

10191019
optional int64 max_cached_entry_bytes = 5 [default = -1];
10201020

1021+
// Time to wait before switching to fallback connectivity if primary channel is not ready.
1022+
// Only used if directpath is enabled for the job. Default is 15 seconds.
1023+
optional int64 directpath_primary_not_ready_wait_millis = 6 [default = 15000];
1024+
10211025
reserved 1, 2;
10221026
}
10231027

0 commit comments

Comments
 (0)