|
13 | 13 | import io.temporal.api.workflowservice.v1.WorkflowServiceGrpc; |
14 | 14 | import io.temporal.client.ActivityCanceledException; |
15 | 15 | import io.temporal.client.ActivityCompletionException; |
| 16 | +import io.temporal.client.ActivityWorkerShutdownException; |
16 | 17 | import io.temporal.common.converter.GlobalDataConverter; |
17 | 18 | import io.temporal.failure.TimeoutFailure; |
18 | 19 | import io.temporal.serviceclient.WorkflowServiceStubs; |
@@ -183,6 +184,221 @@ public void heartbeatTimeoutPersistsAcrossMultipleCalls() { |
183 | 184 | ctx.cancelOutstandingHeartbeat(); |
184 | 185 | } |
185 | 186 |
|
| 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 | + |
186 | 402 | private HeartbeatContextImpl createHeartbeatContext(ActivityInfo info) { |
187 | 403 | return new HeartbeatContextImpl( |
188 | 404 | service, |
|
0 commit comments