Skip to content

Commit 6ba8743

Browse files
committed
Let activities heartbeat during worker shutdown
1 parent 3ed4985 commit 6ba8743

3 files changed

Lines changed: 251 additions & 0 deletions

File tree

temporal-sdk/src/main/java/io/temporal/client/ActivityWorkerShutdownException.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@
99
* called. It is OK to ignore the exception to let the activity complete. It assumes that {@link
1010
* WorkerFactory#awaitTermination(long, TimeUnit)} is called with a timeout larger than the activity
1111
* execution time.
12+
*
13+
* <p>Before this exception is thrown, the heartbeat is sent to the server on a best-effort,
14+
* throttled basis. A caller that keeps heartbeating during the {@link
15+
* WorkerFactory#awaitTermination(long, TimeUnit)} grace period thus prevents the server from timing
16+
* the activity out before it finishes. Throttled heartbeats may skip sending details, so persist
17+
* final progress through the activity's completion result rather than the last heartbeat.
1218
*/
1319
public final class ActivityWorkerShutdownException extends ActivityCompletionException {
1420

temporal-sdk/src/main/java/io/temporal/internal/activity/HeartbeatContextImpl.java

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,11 @@ static long getLocalHeartbeatTimeoutBufferMillis() {
7373
private long heartbeatTimeoutDeadlineNanos;
7474
private boolean heartbeatTimedOut;
7575

76+
// Throttle state for the worker-shutdown heartbeat path, which has no scheduled executor:
77+
// last send attempt (nanos, 0 = none) and whether it reached the server.
78+
private long lastHeartbeatAttemptNanos;
79+
private boolean lastHeartbeatSucceeded;
80+
7681
private ActivityCompletionException lastException;
7782

7883
public HeartbeatContextImpl(
@@ -144,6 +149,26 @@ public HeartbeatContextImpl(
144149
@Override
145150
public <V> void heartbeat(V details) throws ActivityCompletionException {
146151
if (heartbeatExecutor.isShutdown()) {
152+
// Worker shutting down: scheduled executor is gone, so emit synchronously then
153+
// signal shutdown. Caller may ignore the exception and keep heartbeating.
154+
lock.lock();
155+
try {
156+
// Throttle as the scheduled path would, so a caller looping on heartbeat() doesn't flood.
157+
long nowNanos = System.nanoTime();
158+
long throttleMillis =
159+
lastHeartbeatSucceeded ? heartbeatIntervalMillis : HEARTBEAT_RETRY_WAIT_MILLIS;
160+
if (lastHeartbeatAttemptNanos == 0
161+
|| nowNanos - lastHeartbeatAttemptNanos
162+
>= TimeUnit.MILLISECONDS.toNanos(throttleMillis)) {
163+
sendHeartbeatRequest(details);
164+
}
165+
} catch (Exception e) {
166+
// Best-effort: don't let a send error mask the shutdown signal the caller relies on.
167+
log.warn("Heartbeat during worker shutdown failed", e);
168+
} finally {
169+
lock.unlock();
170+
}
171+
// Cancel/pause/reset from a successful send is intentionally not surfaced during shutdown.
147172
throw new ActivityWorkerShutdownException(info);
148173
}
149174
lock.lock();
@@ -297,6 +322,8 @@ private void checkHeartbeatTimeoutDeadlineLocked() {
297322
}
298323

299324
private void sendHeartbeatRequest(Object details) {
325+
lastHeartbeatAttemptNanos = System.nanoTime();
326+
lastHeartbeatSucceeded = false;
300327
try {
301328
RecordActivityTaskHeartbeatResponse status =
302329
ActivityClientHelper.sendHeartbeatRequest(
@@ -306,6 +333,8 @@ private void sendHeartbeatRequest(Object details) {
306333
info.getTaskToken(),
307334
dataConverterWithActivityContext.toPayloads(details),
308335
metricsScope);
336+
// Reached the server, so the server-side heartbeat deadline was refreshed.
337+
lastHeartbeatSucceeded = true;
309338
if (status.getCancelRequested()) {
310339
lastException = new ActivityCanceledException(info);
311340
} else if (status.getActivityReset()) {

temporal-sdk/src/test/java/io/temporal/internal/activity/HeartbeatContextImplTest.java

Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import io.temporal.api.workflowservice.v1.WorkflowServiceGrpc;
1414
import io.temporal.client.ActivityCanceledException;
1515
import io.temporal.client.ActivityCompletionException;
16+
import io.temporal.client.ActivityWorkerShutdownException;
1617
import io.temporal.common.converter.GlobalDataConverter;
1718
import io.temporal.failure.TimeoutFailure;
1819
import io.temporal.serviceclient.WorkflowServiceStubs;
@@ -183,6 +184,221 @@ public void heartbeatTimeoutPersistsAcrossMultipleCalls() {
183184
ctx.cancelOutstandingHeartbeat();
184185
}
185186

187+
@Test
188+
public void heartbeatEmitsThenThrowsDuringWorkerShutdown() {
189+
// Simulate worker shutdown: the heartbeat executor is shut down first.
190+
heartbeatExecutor.shutdown();
191+
192+
when(blockingStub.recordActivityTaskHeartbeat(any()))
193+
.thenReturn(RecordActivityTaskHeartbeatResponse.getDefaultInstance());
194+
195+
ActivityInfo info = activityInfoWithHeartbeatTimeout(Duration.ofSeconds(60));
196+
HeartbeatContextImpl ctx = createHeartbeatContext(info);
197+
198+
try {
199+
ctx.heartbeat("final-progress");
200+
fail("Expected ActivityWorkerShutdownException");
201+
} catch (ActivityWorkerShutdownException e) {
202+
// expected: the caller is still notified that the worker is shutting down
203+
}
204+
205+
// The heartbeat must be emitted to the server before the shutdown signal is thrown,
206+
// so the server does not time out the activity during the awaitTermination grace period.
207+
verify(blockingStub, times(1)).recordActivityTaskHeartbeat(any());
208+
}
209+
210+
@Test
211+
public void heartbeatDuringWorkerShutdownThrowsShutdownEvenIfEmitFails() {
212+
// Simulate worker shutdown.
213+
heartbeatExecutor.shutdown();
214+
215+
// The final heartbeat RPC fails transiently.
216+
when(blockingStub.recordActivityTaskHeartbeat(any()))
217+
.thenThrow(new StatusRuntimeException(Status.UNAVAILABLE));
218+
219+
ActivityInfo info = activityInfoWithHeartbeatTimeout(Duration.ofSeconds(60));
220+
HeartbeatContextImpl ctx = createHeartbeatContext(info);
221+
222+
// A transient RPC failure must not mask the shutdown signal with a raw gRPC error.
223+
try {
224+
ctx.heartbeat("final-progress");
225+
fail("Expected ActivityWorkerShutdownException");
226+
} catch (ActivityWorkerShutdownException e) {
227+
// expected
228+
}
229+
230+
verify(blockingStub, times(1)).recordActivityTaskHeartbeat(any());
231+
}
232+
233+
@Test
234+
public void heartbeatDuringWorkerShutdownThrottlesRepeatedHeartbeats() {
235+
// Simulate worker shutdown.
236+
heartbeatExecutor.shutdown();
237+
238+
when(blockingStub.recordActivityTaskHeartbeat(any()))
239+
.thenReturn(RecordActivityTaskHeartbeatResponse.getDefaultInstance());
240+
241+
// Large heartbeat timeout => large throttle interval, so rapid successive calls during the
242+
// grace period are throttled instead of flooding the server (the scheduled-executor throttle is
243+
// unavailable once the executor is shut down).
244+
ActivityInfo info = activityInfoWithHeartbeatTimeout(Duration.ofSeconds(60));
245+
HeartbeatContextImpl ctx = createHeartbeatContext(info);
246+
247+
for (int i = 0; i < 5; i++) {
248+
try {
249+
ctx.heartbeat("progress-" + i);
250+
fail("Expected ActivityWorkerShutdownException");
251+
} catch (ActivityWorkerShutdownException e) {
252+
// expected on every call
253+
}
254+
}
255+
256+
// Only the first heartbeat reaches the server; the rest fall within the throttle interval.
257+
verify(blockingStub, times(1)).recordActivityTaskHeartbeat(any());
258+
}
259+
260+
@Test
261+
public void heartbeatDuringWorkerShutdownThrowsShutdownEvenIfEmitThrowsNonGrpcException() {
262+
// Simulate worker shutdown.
263+
heartbeatExecutor.shutdown();
264+
265+
// The final heartbeat fails with a non-gRPC runtime exception (e.g. a serialization error).
266+
when(blockingStub.recordActivityTaskHeartbeat(any()))
267+
.thenThrow(new RuntimeException("serialization boom"));
268+
269+
ActivityInfo info = activityInfoWithHeartbeatTimeout(Duration.ofSeconds(60));
270+
HeartbeatContextImpl ctx = createHeartbeatContext(info);
271+
272+
// Any failure while emitting the heartbeat must not mask the shutdown signal the caller relies
273+
// on; the caller must still observe ActivityWorkerShutdownException.
274+
try {
275+
ctx.heartbeat("final-progress");
276+
fail("Expected ActivityWorkerShutdownException");
277+
} catch (ActivityWorkerShutdownException e) {
278+
// expected
279+
}
280+
281+
verify(blockingStub, times(1)).recordActivityTaskHeartbeat(any());
282+
}
283+
284+
@Test
285+
public void heartbeatDuringWorkerShutdownRetriesPromptlyAfterFailedSend() {
286+
// Simulate worker shutdown.
287+
heartbeatExecutor.shutdown();
288+
289+
// The first send fails transiently; subsequent sends succeed.
290+
when(blockingStub.recordActivityTaskHeartbeat(any()))
291+
.thenThrow(new StatusRuntimeException(Status.UNAVAILABLE))
292+
.thenReturn(RecordActivityTaskHeartbeatResponse.getDefaultInstance());
293+
294+
// Heartbeat timeout 60s => full throttle interval 48s, but a failed send must only block
295+
// re-sending for the short retry interval (HEARTBEAT_RETRY_WAIT_MILLIS, 1s), mirroring the
296+
// normal heartbeat path. Otherwise a transient failure would suppress heartbeats for a full
297+
// interval and let the server time the activity out during the grace period.
298+
ActivityInfo info = activityInfoWithHeartbeatTimeout(Duration.ofSeconds(60));
299+
HeartbeatContextImpl ctx = createHeartbeatContext(info);
300+
301+
// First send fails.
302+
try {
303+
ctx.heartbeat("p1");
304+
fail("Expected ActivityWorkerShutdownException");
305+
} catch (ActivityWorkerShutdownException e) {
306+
// expected
307+
}
308+
309+
// An immediate retry is still throttled: a failed send does not open the floodgates.
310+
try {
311+
ctx.heartbeat("p2");
312+
fail("Expected ActivityWorkerShutdownException");
313+
} catch (ActivityWorkerShutdownException e) {
314+
// expected
315+
}
316+
verify(blockingStub, times(1)).recordActivityTaskHeartbeat(any());
317+
318+
// A resend must follow within the short retry interval. Polling well under the 48s
319+
// full interval (but over the 1s retry interval) proves the failed send is retried
320+
// promptly, not suppressed for a full interval. If the full interval were wrongly
321+
// applied, this would never reach 2.
322+
Eventually.assertEventually(
323+
Duration.ofSeconds(10),
324+
() -> {
325+
try {
326+
ctx.heartbeat("retry");
327+
fail("Expected ActivityWorkerShutdownException");
328+
} catch (ActivityWorkerShutdownException e) {
329+
// expected on every call
330+
}
331+
verify(blockingStub, times(2)).recordActivityTaskHeartbeat(any());
332+
});
333+
}
334+
335+
@Test
336+
public void heartbeatDuringWorkerShutdownResendsAfterThrottleIntervalElapses() {
337+
// Simulate worker shutdown.
338+
heartbeatExecutor.shutdown();
339+
340+
when(blockingStub.recordActivityTaskHeartbeat(any()))
341+
.thenReturn(RecordActivityTaskHeartbeatResponse.getDefaultInstance());
342+
343+
// Heartbeat timeout 500ms => throttle interval is 400ms (0.8 * timeout).
344+
ActivityInfo info = activityInfoWithHeartbeatTimeout(Duration.ofMillis(500));
345+
HeartbeatContextImpl ctx = createHeartbeatContext(info);
346+
347+
// The first heartbeat goes out immediately.
348+
try {
349+
ctx.heartbeat("p1");
350+
fail("Expected ActivityWorkerShutdownException");
351+
} catch (ActivityWorkerShutdownException e) {
352+
// expected
353+
}
354+
verify(blockingStub, times(1)).recordActivityTaskHeartbeat(any());
355+
356+
// Once the throttle interval elapses, a later call resends to keep the server-side deadline
357+
// fresh during the grace period.
358+
Eventually.assertEventually(
359+
Duration.ofSeconds(10),
360+
() -> {
361+
try {
362+
ctx.heartbeat("p2");
363+
fail("Expected ActivityWorkerShutdownException");
364+
} catch (ActivityWorkerShutdownException e) {
365+
// expected on every call
366+
}
367+
verify(blockingStub, times(2)).recordActivityTaskHeartbeat(any());
368+
});
369+
}
370+
371+
@Test
372+
public void heartbeatDuringWorkerShutdownThrottlesAgainstPrecedingNormalHeartbeat() {
373+
when(blockingStub.recordActivityTaskHeartbeat(any()))
374+
.thenReturn(RecordActivityTaskHeartbeatResponse.getDefaultInstance());
375+
376+
// Large heartbeat timeout => 48s throttle interval, so a heartbeat sent just before shutdown
377+
// keeps the next one throttled for far longer than this test runs.
378+
ActivityInfo info = activityInfoWithHeartbeatTimeout(Duration.ofSeconds(60));
379+
HeartbeatContextImpl ctx = createHeartbeatContext(info);
380+
381+
// A normal heartbeat (executor still running) sends synchronously and records the send time.
382+
ctx.heartbeat("normal");
383+
verify(blockingStub, times(1)).recordActivityTaskHeartbeat(any());
384+
385+
// The worker now shuts down.
386+
heartbeatExecutor.shutdown();
387+
388+
// The first shutdown-path heartbeat must reuse the throttle state left by the normal send: it
389+
// falls within the 48s interval, so it throws without sending again. If the shutdown path did
390+
// not carry over the normal send's timestamp, it would emit a second heartbeat here.
391+
try {
392+
ctx.heartbeat("shutdown");
393+
fail("Expected ActivityWorkerShutdownException");
394+
} catch (ActivityWorkerShutdownException e) {
395+
// expected
396+
}
397+
verify(blockingStub, times(1)).recordActivityTaskHeartbeat(any());
398+
399+
ctx.cancelOutstandingHeartbeat();
400+
}
401+
186402
private HeartbeatContextImpl createHeartbeatContext(ActivityInfo info) {
187403
return new HeartbeatContextImpl(
188404
service,

0 commit comments

Comments
 (0)