Skip to content

Commit 2edf16c

Browse files
feat: add application-level keepalive to workflow runtime channel (#1795)
* feat: add application-level keepalive to prevent ALB idle connection timeouts AWS ALBs do not forward HTTP/2 PING frames, causing idle gRPC connections to be closed. This adds a background loop that periodically calls the existing Hello RPC as application-level traffic to keep the connection alive through L7 load balancers. Signed-off-by: joshvanl <me@joshvanl.dev> * Fix Signed-off-by: joshvanl <me@joshvanl.dev> * refactor: move app-level keepalive to workflow channel owners, behind opt-in property Relocate the hello() keepalive out of DurableTaskGrpcWorker (reverted to master) into sdk-workflows, per review feedback: the keepalive is a channel concern, so it now lives with the classes that create workflow channels. - New internal GrpcChannelKeepalive: pings hello() on a daemon thread, catches Throwable so a ping failure can never cancel the periodic task, applies the 5s deadline per ping. - WorkflowRuntimeBuilder creates it on the worker channel; WorkflowRuntime owns and closes it (new optional constructor arg). - DaprWorkflowClient wires it on the client channel, protecting long waitForInstanceCompletion calls through L7 load balancers too. - Gated behind dapr.workflows.app.keep.alive.enabled (default false) with dapr.workflows.app.keep.alive.interval.seconds (default 30), following the existing opt-in convention of dapr.grpc.enable.keep.alive. - Tests: in-process gRPC server coverage for ping cadence, failure tolerance, and close semantics; gating tests for both channel owners. Signed-off-by: Javier Aliaga <javier@diagrid.io> * feat: enable runtime keepalive by default, scoped to start/close Restore fix-by-default for the worker channel: the keepalive now defaults to on, but only pings between WorkflowRuntime.start() and close() - a started runtime is by definition talking to a sidecar, so idle apps and built-but-unstarted runtimes send nothing. - GrpcChannelKeepalive lifecycle is explicit: inert constructor, idempotent start() and close(). - dapr.workflows.app.keep.alive.enabled (opt-in) is replaced by dapr.workflows.runtime.app.keep.alive.enabled (default true, opt-out). - Drop the client-channel keepalive: a dead idle client connection reconnects on the next call, so it isn't worth the extra surface; DaprWorkflowClient reverts to master. Signed-off-by: Javier Aliaga <javier@diagrid.io> --------- Signed-off-by: joshvanl <me@joshvanl.dev> Signed-off-by: Javier Aliaga <javier@diagrid.io> Co-authored-by: joshvanl <me@joshvanl.dev>
1 parent 3165d17 commit 2edf16c

7 files changed

Lines changed: 339 additions & 1 deletion

File tree

sdk-workflows/pom.xml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,11 @@
4646
<groupId>io.opentelemetry</groupId>
4747
<artifactId>opentelemetry-api</artifactId>
4848
</dependency>
49+
<dependency>
50+
<groupId>io.grpc</groupId>
51+
<artifactId>grpc-testing</artifactId>
52+
<scope>test</scope>
53+
</dependency>
4954
</dependencies>
5055

5156
<build>
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
/*
2+
* Copyright 2026 The Dapr Authors
3+
* Licensed under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License.
5+
* You may obtain a copy of the License at
6+
* http://www.apache.org/licenses/LICENSE-2.0
7+
* Unless required by applicable law or agreed to in writing, software
8+
* distributed under the License is distributed on an "AS IS" BASIS,
9+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10+
* See the License for the specific language governing permissions and
11+
limitations under the License.
12+
*/
13+
14+
package io.dapr.workflows.internal;
15+
16+
import com.google.protobuf.Empty;
17+
import io.dapr.durabletask.implementation.protobuf.TaskHubSidecarServiceGrpc;
18+
import io.grpc.Channel;
19+
import org.slf4j.Logger;
20+
import org.slf4j.LoggerFactory;
21+
22+
import java.time.Duration;
23+
import java.util.concurrent.Executors;
24+
import java.util.concurrent.ScheduledExecutorService;
25+
import java.util.concurrent.TimeUnit;
26+
27+
/**
28+
* Application-level keepalive for a gRPC channel to the Dapr sidecar.
29+
*
30+
* <p>Periodically invokes the sidecar's {@code hello} RPC so that intermediaries
31+
* (e.g. AWS ALBs) that do not treat HTTP/2 PING frames as connection activity
32+
* never see the connection as idle and close it.</p>
33+
*/
34+
public final class GrpcChannelKeepalive implements AutoCloseable {
35+
36+
private static final Logger LOGGER = LoggerFactory.getLogger(GrpcChannelKeepalive.class);
37+
private static final long KEEPALIVE_DEADLINE_SECONDS = 5;
38+
39+
private final TaskHubSidecarServiceGrpc.TaskHubSidecarServiceBlockingStub stub;
40+
private final String threadName;
41+
private final Duration interval;
42+
private ScheduledExecutorService scheduler;
43+
44+
/**
45+
* Creates a keepalive for the given channel. No pings are sent until {@link #start()}.
46+
*
47+
* @param channel channel to the Dapr sidecar to keep alive.
48+
* @param threadName name of the keepalive thread, to tell instances apart in thread dumps.
49+
* @param interval delay between pings.
50+
*/
51+
public GrpcChannelKeepalive(Channel channel, String threadName, Duration interval) {
52+
this.stub = TaskHubSidecarServiceGrpc.newBlockingStub(channel);
53+
this.threadName = threadName;
54+
this.interval = interval;
55+
}
56+
57+
/**
58+
* Starts the keepalive loop. Does nothing if already started.
59+
*/
60+
public synchronized void start() {
61+
if (this.scheduler != null) {
62+
return;
63+
}
64+
ScheduledExecutorService newScheduler = Executors.newSingleThreadScheduledExecutor(runnable -> {
65+
Thread thread = new Thread(runnable, this.threadName);
66+
thread.setDaemon(true);
67+
return thread;
68+
});
69+
// The deadline must be applied per ping: Deadline.after snapshots the clock when
70+
// withDeadlineAfter is invoked, so hoisting it onto the cached stub would make
71+
// every ping after the first fail immediately with DEADLINE_EXCEEDED.
72+
// Catch Throwable, not just RuntimeException: any throwable escaping a periodic
73+
// task silently cancels all future runs.
74+
newScheduler.scheduleWithFixedDelay(() -> {
75+
try {
76+
this.stub.withDeadlineAfter(KEEPALIVE_DEADLINE_SECONDS, TimeUnit.SECONDS)
77+
.hello(Empty.getDefaultInstance());
78+
} catch (Throwable e) {
79+
LOGGER.debug("Sidecar keepalive ping failed", e);
80+
}
81+
}, this.interval.toMillis(), this.interval.toMillis(), TimeUnit.MILLISECONDS);
82+
this.scheduler = newScheduler;
83+
}
84+
85+
/**
86+
* Stops the keepalive loop. Does nothing if not started.
87+
*/
88+
@Override
89+
public synchronized void close() {
90+
if (this.scheduler != null) {
91+
this.scheduler.shutdownNow();
92+
this.scheduler = null;
93+
}
94+
}
95+
}

sdk-workflows/src/main/java/io/dapr/workflows/runtime/WorkflowRuntime.java

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,11 @@
1414
package io.dapr.workflows.runtime;
1515

1616
import io.dapr.durabletask.DurableTaskGrpcWorker;
17+
import io.dapr.workflows.internal.GrpcChannelKeepalive;
1718
import io.grpc.ManagedChannel;
1819

20+
import javax.annotation.Nullable;
21+
1922
import java.util.concurrent.ExecutorService;
2023
import java.util.concurrent.TimeUnit;
2124

@@ -27,6 +30,7 @@ public class WorkflowRuntime implements AutoCloseable {
2730
private final DurableTaskGrpcWorker worker;
2831
private final ManagedChannel managedChannel;
2932
private final ExecutorService executorService;
33+
private final GrpcChannelKeepalive keepalive;
3034

3135
/**
3236
* Constructor.
@@ -38,9 +42,27 @@ public class WorkflowRuntime implements AutoCloseable {
3842
public WorkflowRuntime(DurableTaskGrpcWorker worker,
3943
ManagedChannel managedChannel,
4044
ExecutorService executorService) {
45+
this(worker, managedChannel, executorService, null);
46+
}
47+
48+
/**
49+
* Constructor.
50+
*
51+
* @param worker grpcWorker processing activities.
52+
* @param managedChannel grpc channel.
53+
* @param executorService executor service responsible for running the threads.
54+
* @param keepalive application-level keepalive on the worker's channel, started with
55+
* {@link #start()} and stopped on {@link #close()}. May be null when
56+
* no keepalive is wanted.
57+
*/
58+
public WorkflowRuntime(DurableTaskGrpcWorker worker,
59+
ManagedChannel managedChannel,
60+
ExecutorService executorService,
61+
@Nullable GrpcChannelKeepalive keepalive) {
4162
this.worker = worker;
4263
this.managedChannel = managedChannel;
4364
this.executorService = executorService;
65+
this.keepalive = keepalive;
4466
}
4567

4668
/**
@@ -57,6 +79,9 @@ public void start() {
5779
* @param block block the thread if true
5880
*/
5981
public void start(boolean block) {
82+
if (this.keepalive != null) {
83+
this.keepalive.start();
84+
}
6085
if (block) {
6186
this.worker.startAndBlock();
6287
} else {
@@ -68,6 +93,9 @@ public void start(boolean block) {
6893
* {@inheritDoc}
6994
*/
7095
public void close() {
96+
if (this.keepalive != null) {
97+
this.keepalive.close();
98+
}
7199
this.shutDownWorkerPool();
72100
this.closeSideCarChannel();
73101
this.worker.close();

sdk-workflows/src/main/java/io/dapr/workflows/runtime/WorkflowRuntimeBuilder.java

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import io.dapr.workflows.Workflow;
2222
import io.dapr.workflows.WorkflowActivity;
2323
import io.dapr.workflows.internal.ApiTokenClientInterceptor;
24+
import io.dapr.workflows.internal.GrpcChannelKeepalive;
2425
import io.grpc.ClientInterceptor;
2526
import io.grpc.ManagedChannel;
2627
import org.apache.commons.lang3.StringUtils;
@@ -43,6 +44,7 @@ public class WorkflowRuntimeBuilder {
4344
private final Set<String> workflowSet = Collections.synchronizedSet(new HashSet<>());
4445
private final DurableTaskGrpcWorkerBuilder builder;
4546
private final ManagedChannel managedChannel;
47+
private final Properties properties;
4648
private ExecutorService executorService;
4749

4850
/**
@@ -69,6 +71,7 @@ private WorkflowRuntimeBuilder(Properties properties, Logger logger) {
6971
this.workflowApiTokenInterceptor = new ApiTokenClientInterceptor(properties);
7072
this.managedChannel = NetworkUtils.buildGrpcManagedChannel(properties, workflowApiTokenInterceptor);
7173
this.builder = new DurableTaskGrpcWorkerBuilder().grpcChannel(this.managedChannel);
74+
this.properties = properties;
7275
this.logger = logger;
7376
}
7477

@@ -82,9 +85,14 @@ public WorkflowRuntime build() {
8285
synchronized (WorkflowRuntime.class) {
8386
this.executorService = this.executorService == null ? Executors.newCachedThreadPool() : this.executorService;
8487
if (instance == null) {
88+
GrpcChannelKeepalive keepalive = null;
89+
if (this.properties.getValue(Properties.WORKFLOWS_RUNTIME_APP_KEEP_ALIVE_ENABLED)) {
90+
keepalive = new GrpcChannelKeepalive(this.managedChannel, "dapr-workflow-runtime-keepalive",
91+
this.properties.getValue(Properties.WORKFLOWS_APP_KEEP_ALIVE_INTERVAL_SECONDS));
92+
}
8593
instance = new WorkflowRuntime(
8694
this.builder.withExecutorService(this.executorService).build(),
87-
this.managedChannel, this.executorService);
95+
this.managedChannel, this.executorService, keepalive);
8896
}
8997
}
9098
}
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
/*
2+
* Copyright 2026 The Dapr Authors
3+
* Licensed under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License.
5+
* You may obtain a copy of the License at
6+
* http://www.apache.org/licenses/LICENSE-2.0
7+
* Unless required by applicable law or agreed to in writing, software
8+
* distributed under the License is distributed on an "AS IS" BASIS,
9+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10+
* See the License for the specific language governing permissions and
11+
limitations under the License.
12+
*/
13+
14+
package io.dapr.workflows.internal;
15+
16+
import com.google.protobuf.Empty;
17+
import io.dapr.durabletask.implementation.protobuf.TaskHubSidecarServiceGrpc;
18+
import io.grpc.ManagedChannel;
19+
import io.grpc.Server;
20+
import io.grpc.Status;
21+
import io.grpc.inprocess.InProcessChannelBuilder;
22+
import io.grpc.inprocess.InProcessServerBuilder;
23+
import io.grpc.stub.StreamObserver;
24+
import org.junit.jupiter.api.AfterEach;
25+
import org.junit.jupiter.api.BeforeEach;
26+
import org.junit.jupiter.api.Test;
27+
28+
import java.io.IOException;
29+
import java.time.Duration;
30+
import java.util.concurrent.TimeUnit;
31+
import java.util.concurrent.atomic.AtomicBoolean;
32+
import java.util.concurrent.atomic.AtomicInteger;
33+
34+
import static org.junit.jupiter.api.Assertions.assertEquals;
35+
import static org.junit.jupiter.api.Assertions.assertTrue;
36+
37+
public class GrpcChannelKeepaliveTest {
38+
39+
private static final Duration TEST_INTERVAL = Duration.ofMillis(50);
40+
41+
private final AtomicInteger helloCount = new AtomicInteger();
42+
private final AtomicBoolean failPings = new AtomicBoolean(false);
43+
44+
private Server server;
45+
private ManagedChannel channel;
46+
47+
@BeforeEach
48+
public void setUp() throws IOException {
49+
String serverName = InProcessServerBuilder.generateName();
50+
this.server = InProcessServerBuilder.forName(serverName)
51+
.directExecutor()
52+
.addService(new TaskHubSidecarServiceGrpc.TaskHubSidecarServiceImplBase() {
53+
@Override
54+
public void hello(Empty request, StreamObserver<Empty> responseObserver) {
55+
helloCount.incrementAndGet();
56+
if (failPings.get()) {
57+
responseObserver.onError(Status.UNAVAILABLE.asRuntimeException());
58+
return;
59+
}
60+
responseObserver.onNext(Empty.getDefaultInstance());
61+
responseObserver.onCompleted();
62+
}
63+
})
64+
.build()
65+
.start();
66+
this.channel = InProcessChannelBuilder.forName(serverName).directExecutor().build();
67+
}
68+
69+
@AfterEach
70+
public void tearDown() {
71+
this.channel.shutdownNow();
72+
this.server.shutdownNow();
73+
}
74+
75+
@Test
76+
public void noPingsBeforeStart() throws InterruptedException {
77+
try (GrpcChannelKeepalive keepalive = new GrpcChannelKeepalive(channel, "test-keepalive", TEST_INTERVAL)) {
78+
Thread.sleep(TEST_INTERVAL.toMillis() * 4);
79+
assertEquals(0, helloCount.get());
80+
}
81+
}
82+
83+
@Test
84+
public void pingsPeriodically() throws InterruptedException {
85+
try (GrpcChannelKeepalive keepalive = new GrpcChannelKeepalive(channel, "test-keepalive", TEST_INTERVAL)) {
86+
keepalive.start();
87+
// A second start must not schedule a second ping loop.
88+
keepalive.start();
89+
awaitHelloCountAtLeast(2);
90+
}
91+
}
92+
93+
@Test
94+
public void continuesPingingAfterFailures() throws InterruptedException {
95+
failPings.set(true);
96+
try (GrpcChannelKeepalive keepalive = new GrpcChannelKeepalive(channel, "test-keepalive", TEST_INTERVAL)) {
97+
keepalive.start();
98+
awaitHelloCountAtLeast(2);
99+
failPings.set(false);
100+
awaitHelloCountAtLeast(helloCount.get() + 2);
101+
}
102+
}
103+
104+
@Test
105+
public void closeStopsPinging() throws InterruptedException {
106+
GrpcChannelKeepalive keepalive = new GrpcChannelKeepalive(channel, "test-keepalive", TEST_INTERVAL);
107+
keepalive.start();
108+
awaitHelloCountAtLeast(1);
109+
keepalive.close();
110+
// Allow an already in-flight ping to finish before snapshotting.
111+
Thread.sleep(TEST_INTERVAL.toMillis() * 2);
112+
int countAfterClose = helloCount.get();
113+
Thread.sleep(TEST_INTERVAL.toMillis() * 4);
114+
assertEquals(countAfterClose, helloCount.get());
115+
}
116+
117+
@Test
118+
public void keepaliveThreadIsDaemonAndStopsOnClose() throws InterruptedException {
119+
String threadName = "test-keepalive-lifecycle";
120+
try (GrpcChannelKeepalive keepalive = new GrpcChannelKeepalive(channel, threadName, TEST_INTERVAL)) {
121+
keepalive.start();
122+
awaitHelloCountAtLeast(1);
123+
Thread thread = findThread(threadName);
124+
assertTrue(thread != null && thread.isDaemon(), "expected a live daemon keepalive thread");
125+
}
126+
long deadline = System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(5);
127+
while (findThread(threadName) != null && System.currentTimeMillis() < deadline) {
128+
Thread.sleep(10);
129+
}
130+
assertTrue(findThread(threadName) == null, "keepalive thread should terminate after close()");
131+
}
132+
133+
private void awaitHelloCountAtLeast(int expected) throws InterruptedException {
134+
long deadline = System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(5);
135+
while (helloCount.get() < expected && System.currentTimeMillis() < deadline) {
136+
Thread.sleep(10);
137+
}
138+
assertTrue(helloCount.get() >= expected,
139+
"expected at least " + expected + " hello pings, got " + helloCount.get());
140+
}
141+
142+
private static Thread findThread(String name) {
143+
return Thread.getAllStackTraces().keySet().stream()
144+
.filter(t -> t.getName().equals(name) && t.isAlive())
145+
.findFirst()
146+
.orElse(null);
147+
}
148+
}

0 commit comments

Comments
 (0)