aws-durable-execution-sdk-python-testing 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. aws_durable_execution_sdk_python_testing/__about__.py +4 -0
  2. aws_durable_execution_sdk_python_testing/__init__.py +20 -0
  3. aws_durable_execution_sdk_python_testing/checkpoint/__init__.py +1 -0
  4. aws_durable_execution_sdk_python_testing/checkpoint/processor.py +101 -0
  5. aws_durable_execution_sdk_python_testing/checkpoint/processors/__init__.py +1 -0
  6. aws_durable_execution_sdk_python_testing/checkpoint/processors/base.py +199 -0
  7. aws_durable_execution_sdk_python_testing/checkpoint/processors/callback.py +89 -0
  8. aws_durable_execution_sdk_python_testing/checkpoint/processors/context.py +59 -0
  9. aws_durable_execution_sdk_python_testing/checkpoint/processors/execution.py +52 -0
  10. aws_durable_execution_sdk_python_testing/checkpoint/processors/step.py +124 -0
  11. aws_durable_execution_sdk_python_testing/checkpoint/processors/wait.py +95 -0
  12. aws_durable_execution_sdk_python_testing/checkpoint/transformer.py +104 -0
  13. aws_durable_execution_sdk_python_testing/checkpoint/validators/__init__.py +1 -0
  14. aws_durable_execution_sdk_python_testing/checkpoint/validators/checkpoint.py +242 -0
  15. aws_durable_execution_sdk_python_testing/checkpoint/validators/operations/__init__.py +1 -0
  16. aws_durable_execution_sdk_python_testing/checkpoint/validators/operations/callback.py +45 -0
  17. aws_durable_execution_sdk_python_testing/checkpoint/validators/operations/context.py +73 -0
  18. aws_durable_execution_sdk_python_testing/checkpoint/validators/operations/execution.py +47 -0
  19. aws_durable_execution_sdk_python_testing/checkpoint/validators/operations/invoke.py +56 -0
  20. aws_durable_execution_sdk_python_testing/checkpoint/validators/operations/step.py +106 -0
  21. aws_durable_execution_sdk_python_testing/checkpoint/validators/operations/wait.py +54 -0
  22. aws_durable_execution_sdk_python_testing/checkpoint/validators/transitions.py +66 -0
  23. aws_durable_execution_sdk_python_testing/cli.py +501 -0
  24. aws_durable_execution_sdk_python_testing/client.py +50 -0
  25. aws_durable_execution_sdk_python_testing/exceptions.py +288 -0
  26. aws_durable_execution_sdk_python_testing/execution.py +440 -0
  27. aws_durable_execution_sdk_python_testing/executor.py +1214 -0
  28. aws_durable_execution_sdk_python_testing/invoker.py +327 -0
  29. aws_durable_execution_sdk_python_testing/model.py +3267 -0
  30. aws_durable_execution_sdk_python_testing/observer.py +144 -0
  31. aws_durable_execution_sdk_python_testing/py.typed +1 -0
  32. aws_durable_execution_sdk_python_testing/runner.py +1185 -0
  33. aws_durable_execution_sdk_python_testing/scheduler.py +246 -0
  34. aws_durable_execution_sdk_python_testing/stores/__init__.py +1 -0
  35. aws_durable_execution_sdk_python_testing/stores/base.py +147 -0
  36. aws_durable_execution_sdk_python_testing/stores/filesystem.py +104 -0
  37. aws_durable_execution_sdk_python_testing/stores/memory.py +38 -0
  38. aws_durable_execution_sdk_python_testing/stores/sqlite.py +283 -0
  39. aws_durable_execution_sdk_python_testing/token.py +49 -0
  40. aws_durable_execution_sdk_python_testing/web/__init__.py +1 -0
  41. aws_durable_execution_sdk_python_testing/web/errors.py +8 -0
  42. aws_durable_execution_sdk_python_testing/web/handlers.py +807 -0
  43. aws_durable_execution_sdk_python_testing/web/models.py +266 -0
  44. aws_durable_execution_sdk_python_testing/web/routes.py +684 -0
  45. aws_durable_execution_sdk_python_testing/web/serialization.py +245 -0
  46. aws_durable_execution_sdk_python_testing/web/server.py +243 -0
  47. aws_durable_execution_sdk_python_testing-1.0.0.dist-info/METADATA +221 -0
  48. aws_durable_execution_sdk_python_testing-1.0.0.dist-info/RECORD +52 -0
  49. aws_durable_execution_sdk_python_testing-1.0.0.dist-info/WHEEL +4 -0
  50. aws_durable_execution_sdk_python_testing-1.0.0.dist-info/entry_points.txt +2 -0
  51. aws_durable_execution_sdk_python_testing-1.0.0.dist-info/licenses/LICENSE +175 -0
  52. aws_durable_execution_sdk_python_testing-1.0.0.dist-info/licenses/NOTICE +1 -0
@@ -0,0 +1,1214 @@
1
+ """Execution life-cycle logic."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import time
7
+ import uuid
8
+ from datetime import UTC, datetime
9
+ from typing import TYPE_CHECKING
10
+
11
+ from aws_durable_execution_sdk_python.execution import (
12
+ DurableExecutionInvocationInput,
13
+ DurableExecutionInvocationOutput,
14
+ InvocationStatus,
15
+ )
16
+ from aws_durable_execution_sdk_python.lambda_service import (
17
+ CallbackTimeoutType,
18
+ ErrorObject,
19
+ Operation,
20
+ OperationUpdate,
21
+ OperationStatus,
22
+ OperationType,
23
+ CallbackOptions,
24
+ )
25
+
26
+ from aws_durable_execution_sdk_python_testing.exceptions import (
27
+ ExecutionAlreadyStartedException,
28
+ IllegalStateException,
29
+ InvalidParameterValueException,
30
+ ResourceNotFoundException,
31
+ )
32
+ from aws_durable_execution_sdk_python_testing.execution import Execution
33
+ from aws_durable_execution_sdk_python_testing.model import (
34
+ CheckpointDurableExecutionResponse,
35
+ CheckpointUpdatedExecutionState,
36
+ EventCreationContext,
37
+ EventType,
38
+ GetDurableExecutionHistoryResponse,
39
+ GetDurableExecutionResponse,
40
+ GetDurableExecutionStateResponse,
41
+ ListDurableExecutionsByFunctionResponse,
42
+ ListDurableExecutionsResponse,
43
+ SendDurableExecutionCallbackFailureResponse,
44
+ SendDurableExecutionCallbackHeartbeatResponse,
45
+ SendDurableExecutionCallbackSuccessResponse,
46
+ StartDurableExecutionInput,
47
+ StartDurableExecutionOutput,
48
+ StopDurableExecutionResponse,
49
+ TERMINAL_STATUSES,
50
+ )
51
+ from aws_durable_execution_sdk_python_testing.model import (
52
+ Event as HistoryEvent,
53
+ )
54
+ from aws_durable_execution_sdk_python_testing.model import (
55
+ Execution as ExecutionSummary,
56
+ )
57
+ from aws_durable_execution_sdk_python_testing.observer import ExecutionObserver
58
+ from aws_durable_execution_sdk_python_testing.token import CallbackToken
59
+
60
+
61
+ if TYPE_CHECKING:
62
+ from collections.abc import Awaitable, Callable
63
+ from concurrent.futures import Future
64
+
65
+ from aws_durable_execution_sdk_python_testing.checkpoint.processor import (
66
+ CheckpointProcessor,
67
+ )
68
+ from aws_durable_execution_sdk_python_testing.invoker import Invoker
69
+ from aws_durable_execution_sdk_python_testing.scheduler import Event, Scheduler
70
+ from aws_durable_execution_sdk_python_testing.stores.base import ExecutionStore
71
+
72
+ logger = logging.getLogger(__name__)
73
+
74
+
75
+ class Executor(ExecutionObserver):
76
+ MAX_CONSECUTIVE_FAILED_ATTEMPTS: int = 5
77
+ RETRY_BACKOFF_SECONDS: int = 5
78
+
79
+ def __init__(
80
+ self,
81
+ store: ExecutionStore,
82
+ scheduler: Scheduler,
83
+ invoker: Invoker,
84
+ checkpoint_processor: CheckpointProcessor,
85
+ ):
86
+ self._store = store
87
+ self._scheduler = scheduler
88
+ self._invoker = invoker
89
+ self._checkpoint_processor = checkpoint_processor
90
+ self._completion_events: dict[str, Event] = {}
91
+ self._callback_timeouts: dict[str, Future] = {}
92
+ self._callback_heartbeats: dict[str, Future] = {}
93
+
94
+ def start_execution(
95
+ self,
96
+ input: StartDurableExecutionInput, # noqa: A002
97
+ ) -> StartDurableExecutionOutput:
98
+ # Generate invocation_id if not provided
99
+ if input.invocation_id is None:
100
+ input = StartDurableExecutionInput(
101
+ account_id=input.account_id,
102
+ function_name=input.function_name,
103
+ function_qualifier=input.function_qualifier,
104
+ execution_name=input.execution_name,
105
+ execution_timeout_seconds=input.execution_timeout_seconds,
106
+ execution_retention_period_days=input.execution_retention_period_days,
107
+ invocation_id=str(uuid.uuid4()),
108
+ trace_fields=input.trace_fields,
109
+ tenant_id=input.tenant_id,
110
+ input=input.input,
111
+ lambda_endpoint=input.lambda_endpoint,
112
+ )
113
+
114
+ execution = Execution.new(input=input)
115
+ execution.start()
116
+ self._store.save(execution)
117
+
118
+ completion_event = self._scheduler.create_event()
119
+ self._completion_events[execution.durable_execution_arn] = completion_event
120
+
121
+ # Schedule initial invocation to run immediately
122
+ self._invoke_execution(execution.durable_execution_arn)
123
+
124
+ return StartDurableExecutionOutput(
125
+ execution_arn=execution.durable_execution_arn
126
+ )
127
+
128
+ def get_execution(self, execution_arn: str) -> Execution:
129
+ """Get execution by ARN.
130
+
131
+ Args:
132
+ execution_arn: The execution ARN to retrieve
133
+
134
+ Returns:
135
+ Execution: The execution object
136
+
137
+ Raises:
138
+ ResourceNotFoundException: If execution does not exist
139
+ """
140
+ try:
141
+ return self._store.load(execution_arn)
142
+ except KeyError as e:
143
+ msg: str = f"Execution {execution_arn} not found"
144
+ raise ResourceNotFoundException(msg) from e
145
+
146
+ def get_execution_details(self, execution_arn: str) -> GetDurableExecutionResponse:
147
+ """Get detailed execution information for web API response.
148
+
149
+ Args:
150
+ execution_arn: The execution ARN to retrieve
151
+
152
+ Returns:
153
+ GetDurableExecutionResponse: Detailed execution information
154
+
155
+ Raises:
156
+ ResourceNotFoundException: If execution does not exist
157
+ """
158
+ execution = self.get_execution(execution_arn)
159
+
160
+ # Extract execution details from the first operation (EXECUTION type)
161
+ execution_op = execution.get_operation_execution_started()
162
+ status = execution.current_status().value
163
+
164
+ # Extract result and error from execution result
165
+ result = None
166
+ error = None
167
+ if execution.result:
168
+ if execution.result.status == InvocationStatus.SUCCEEDED:
169
+ result = execution.result.result
170
+ elif execution.result.status == InvocationStatus.FAILED:
171
+ error = execution.result.error
172
+
173
+ return GetDurableExecutionResponse(
174
+ durable_execution_arn=execution.durable_execution_arn,
175
+ durable_execution_name=execution.start_input.execution_name,
176
+ function_arn=f"arn:aws:lambda:us-east-1:123456789012:function:{execution.start_input.function_name}",
177
+ status=status,
178
+ start_timestamp=execution_op.start_timestamp
179
+ if execution_op.start_timestamp
180
+ else datetime.now(UTC),
181
+ input_payload=execution_op.execution_details.input_payload
182
+ if execution_op.execution_details
183
+ else None,
184
+ result=result,
185
+ error=error,
186
+ end_timestamp=execution_op.end_timestamp
187
+ if execution_op.end_timestamp
188
+ else None,
189
+ version="1.0",
190
+ )
191
+
192
+ def list_executions(
193
+ self,
194
+ function_name: str | None = None,
195
+ function_version: str | None = None, # noqa: ARG002
196
+ execution_name: str | None = None,
197
+ status_filter: str | None = None,
198
+ started_after: str | None = None,
199
+ started_before: str | None = None,
200
+ marker: str | None = None,
201
+ max_items: int | None = None,
202
+ reverse_order: bool = False, # noqa: FBT001, FBT002
203
+ ) -> ListDurableExecutionsResponse:
204
+ """List executions with filtering and pagination.
205
+
206
+ Args:
207
+ function_name: Filter by function name
208
+ function_version: Filter by function version
209
+ execution_name: Filter by execution name
210
+ status_filter: Filter by status (RUNNING, SUCCEEDED, FAILED)
211
+ started_after: Filter executions started after this time
212
+ started_before: Filter executions started before this time
213
+ marker: Pagination marker
214
+ max_items: Maximum items to return (default 50)
215
+ reverse_order: Return results in reverse chronological order
216
+
217
+ Returns:
218
+ ListDurableExecutionsResponse: List of executions with pagination
219
+ """
220
+ # Convert marker to offset
221
+ offset: int = 0
222
+ if marker:
223
+ try:
224
+ offset = int(marker)
225
+ except ValueError:
226
+ offset = 0
227
+
228
+ # Query store directly with parameters
229
+ executions, next_marker = self._store.query(
230
+ function_name=function_name,
231
+ execution_name=execution_name,
232
+ status_filter=status_filter,
233
+ started_after=started_after,
234
+ started_before=started_before,
235
+ limit=max_items or 50,
236
+ offset=offset,
237
+ reverse_order=reverse_order,
238
+ )
239
+
240
+ # Convert to ExecutionSummary objects
241
+ execution_summaries: list[ExecutionSummary] = [
242
+ ExecutionSummary.from_execution(execution, execution.current_status().value)
243
+ for execution in executions
244
+ ]
245
+
246
+ return ListDurableExecutionsResponse(
247
+ durable_executions=execution_summaries, next_marker=next_marker
248
+ )
249
+
250
+ def list_executions_by_function(
251
+ self,
252
+ function_name: str,
253
+ qualifier: str | None = None, # noqa: ARG002
254
+ execution_name: str | None = None,
255
+ status_filter: str | None = None,
256
+ started_after: str | None = None,
257
+ started_before: str | None = None,
258
+ marker: str | None = None,
259
+ max_items: int | None = None,
260
+ reverse_order: bool = False, # noqa: FBT001, FBT002
261
+ ) -> ListDurableExecutionsByFunctionResponse:
262
+ """List executions for a specific function.
263
+
264
+ Args:
265
+ function_name: The function name to filter by
266
+ qualifier: Function qualifier/version
267
+ execution_name: Filter by execution name
268
+ status_filter: Filter by status (RUNNING, SUCCEEDED, FAILED)
269
+ started_after: Filter executions started after this time
270
+ started_before: Filter executions started before this time
271
+ marker: Pagination marker
272
+ max_items: Maximum items to return (default 50)
273
+ reverse_order: Return results in reverse chronological order
274
+
275
+ Returns:
276
+ ListDurableExecutionsByFunctionResponse: List of executions for the function
277
+ """
278
+ # Use the general list_executions method with function_name filter
279
+ list_response = self.list_executions(
280
+ function_name=function_name,
281
+ execution_name=execution_name,
282
+ status_filter=status_filter,
283
+ started_after=started_after,
284
+ started_before=started_before,
285
+ marker=marker,
286
+ max_items=max_items,
287
+ reverse_order=reverse_order,
288
+ )
289
+
290
+ return ListDurableExecutionsByFunctionResponse(
291
+ durable_executions=list_response.durable_executions,
292
+ next_marker=list_response.next_marker,
293
+ )
294
+
295
+ def stop_execution(
296
+ self, execution_arn: str, error: ErrorObject | None = None
297
+ ) -> StopDurableExecutionResponse:
298
+ """Stop a running execution.
299
+
300
+ Args:
301
+ execution_arn: The execution ARN to stop
302
+ error: Optional error to use when stopping the execution
303
+
304
+ Returns:
305
+ StopDurableExecutionResponse: Response containing end timestamp
306
+
307
+ Raises:
308
+ ResourceNotFoundException: If execution does not exist
309
+ ExecutionAlreadyStartedException: If execution is already completed
310
+ """
311
+ execution = self.get_execution(execution_arn)
312
+
313
+ if execution.is_complete:
314
+ # Context-aware mapping: execution already completed maps to ExecutionAlreadyStartedException
315
+ msg: str = f"Execution {execution_arn} is already completed"
316
+ raise ExecutionAlreadyStartedException(msg, execution_arn)
317
+
318
+ # Use provided error or create a default one
319
+ stop_error = error or ErrorObject.from_message(
320
+ "Execution stopped by user request"
321
+ )
322
+
323
+ # Stop sets TERMINATED close status (different from fail)
324
+ logger.exception("[%s] Stopping execution.", execution_arn)
325
+ execution.complete_stopped(error=stop_error) # Sets CloseStatus.TERMINATED
326
+ self._store.update(execution)
327
+ self._complete_events(execution_arn=execution_arn)
328
+
329
+ return StopDurableExecutionResponse(stop_timestamp=datetime.now(UTC))
330
+
331
+ def get_execution_state(
332
+ self,
333
+ execution_arn: str,
334
+ checkpoint_token: str | None = None,
335
+ marker: str | None = None,
336
+ max_items: int | None = None,
337
+ ) -> GetDurableExecutionStateResponse:
338
+ """Get execution state with operations.
339
+
340
+ Args:
341
+ execution_arn: The execution ARN
342
+ checkpoint_token: Checkpoint token for state consistency
343
+ marker: Pagination marker
344
+ max_items: Maximum items to return
345
+
346
+ Returns:
347
+ GetDurableExecutionStateResponse: Execution state with operations
348
+
349
+ Raises:
350
+ ResourceNotFoundException: If execution does not exist
351
+ InvalidParameterValueException: If checkpoint token is invalid
352
+ """
353
+ execution = self.get_execution(execution_arn)
354
+
355
+ # TODO: Validate checkpoint token if provided
356
+ if checkpoint_token and checkpoint_token not in execution.used_tokens:
357
+ msg: str = f"Invalid checkpoint token: {checkpoint_token}"
358
+ raise InvalidParameterValueException(msg)
359
+
360
+ # Get operations (excluding the initial EXECUTION operation for state)
361
+ operations = execution.get_assertable_operations()
362
+
363
+ # Apply pagination
364
+ if max_items is None:
365
+ max_items = 100
366
+
367
+ # Simple pagination - in real implementation would need proper marker handling
368
+ start_index = 0
369
+ if marker:
370
+ try:
371
+ start_index = int(marker)
372
+ except ValueError:
373
+ start_index = 0
374
+
375
+ end_index = start_index + max_items
376
+ paginated_operations = operations[start_index:end_index]
377
+
378
+ next_marker = None
379
+ if end_index < len(operations):
380
+ next_marker = str(end_index)
381
+
382
+ return GetDurableExecutionStateResponse(
383
+ operations=paginated_operations, next_marker=next_marker
384
+ )
385
+
386
+ def get_execution_history(
387
+ self,
388
+ execution_arn: str,
389
+ include_execution_data: bool = False, # noqa: FBT001, FBT002
390
+ reverse_order: bool = False, # noqa: FBT001, FBT002
391
+ marker: str | None = None,
392
+ max_items: int | None = None,
393
+ ) -> GetDurableExecutionHistoryResponse:
394
+ """Get execution history with events.
395
+
396
+ Args:
397
+ execution_arn: The execution ARN
398
+ include_execution_data: Whether to include execution data in events
399
+ reverse_order: Return events in reverse chronological order
400
+ marker: Pagination marker (event_id)
401
+ max_items: Maximum items to return
402
+
403
+ Returns:
404
+ GetDurableExecutionHistoryResponse: Execution history with events
405
+
406
+ Raises:
407
+ ResourceNotFoundException: If execution does not exist
408
+ """
409
+ execution: Execution = self.get_execution(execution_arn)
410
+
411
+ # Generate events
412
+ all_events: list[HistoryEvent] = []
413
+ ops: list[Operation] = execution.operations
414
+ updates: list[OperationUpdate] = execution.updates
415
+ updates_dict: dict[str, OperationUpdate] = {u.operation_id: u for u in updates}
416
+ durable_execution_arn: str = execution.durable_execution_arn
417
+
418
+ # Add InvocationCompleted events
419
+ for completion in execution.invocation_completions:
420
+ invocation_event = HistoryEvent.create_invocation_completed(
421
+ event_id=0, # Temporary, will be reassigned
422
+ event_timestamp=completion.end_timestamp,
423
+ start_timestamp=completion.start_timestamp,
424
+ end_timestamp=completion.end_timestamp,
425
+ request_id=completion.request_id,
426
+ )
427
+ all_events.append(invocation_event)
428
+
429
+ # Generate all events first (without final event IDs)
430
+ for op in ops:
431
+ operation_update: OperationUpdate | None = updates_dict.get(
432
+ op.operation_id, None
433
+ )
434
+
435
+ if op.status is OperationStatus.PENDING:
436
+ if (
437
+ op.operation_type is not OperationType.CHAINED_INVOKE
438
+ or op.start_timestamp is None
439
+ ):
440
+ continue
441
+ context: EventCreationContext = EventCreationContext(
442
+ op,
443
+ 0, # Temporary event_id, will be reassigned after sorting
444
+ durable_execution_arn,
445
+ execution.start_input,
446
+ execution.result,
447
+ operation_update,
448
+ include_execution_data,
449
+ )
450
+ pending = HistoryEvent.create_chained_invoke_event_pending(context)
451
+ all_events.append(pending)
452
+ if op.start_timestamp is not None:
453
+ context = EventCreationContext(
454
+ op,
455
+ 0, # Temporary event_id, will be reassigned after sorting
456
+ durable_execution_arn,
457
+ execution.start_input,
458
+ execution.result,
459
+ operation_update,
460
+ include_execution_data,
461
+ )
462
+ started = HistoryEvent.create_event_started(context)
463
+ all_events.append(started)
464
+ if op.end_timestamp is not None and op.status in TERMINAL_STATUSES:
465
+ context = EventCreationContext(
466
+ op,
467
+ 0, # Temporary event_id, will be reassigned after sorting
468
+ durable_execution_arn,
469
+ execution.start_input,
470
+ execution.result,
471
+ operation_update,
472
+ include_execution_data,
473
+ )
474
+ finished = HistoryEvent.create_event_terminated(context)
475
+ all_events.append(finished)
476
+
477
+ # Sort events by timestamp to get correct chronological order
478
+ all_events.sort(key=lambda event: event.event_timestamp)
479
+
480
+ # Reassign event IDs based on chronological order
481
+ all_events = [
482
+ HistoryEvent.from_event_with_id(event, i)
483
+ for i, event in enumerate(all_events, 1)
484
+ ]
485
+
486
+ # Apply cursor-based pagination
487
+ if max_items is None:
488
+ max_items = 100
489
+
490
+ # Handle pagination marker
491
+ if reverse_order:
492
+ all_events.reverse()
493
+ start_index: int = 0
494
+ if marker:
495
+ try:
496
+ marker_event_id: int = int(marker)
497
+ # Find the index of the first event with event_id >= marker
498
+ start_index = len(all_events)
499
+ for i, e in enumerate(all_events):
500
+ is_valid_page_start: bool = (
501
+ e.event_id < marker_event_id
502
+ if reverse_order
503
+ else e.event_id >= marker_event_id
504
+ )
505
+ if is_valid_page_start:
506
+ start_index = i
507
+ break
508
+ except ValueError:
509
+ start_index = 0
510
+
511
+ # Get paginated events
512
+ end_index: int = start_index + max_items
513
+ paginated_events: list[HistoryEvent] = all_events[start_index:end_index]
514
+
515
+ # Generate next marker
516
+ next_marker: str | None = None
517
+ if end_index < len(all_events):
518
+ if reverse_order:
519
+ # Next marker is the event_id of the last returned event
520
+ next_marker = (
521
+ str(paginated_events[-1].event_id) if paginated_events else None
522
+ )
523
+ else:
524
+ # Next marker is the event_id of the next event after the last returned
525
+ next_marker = (
526
+ str(all_events[end_index].event_id)
527
+ if end_index < len(all_events)
528
+ else None
529
+ )
530
+
531
+ return GetDurableExecutionHistoryResponse(
532
+ events=paginated_events, next_marker=next_marker
533
+ )
534
+
535
+ def checkpoint_execution(
536
+ self,
537
+ execution_arn: str,
538
+ checkpoint_token: str,
539
+ updates: list[OperationUpdate] | None = None,
540
+ client_token: str | None = None,
541
+ ) -> CheckpointDurableExecutionResponse:
542
+ """Process checkpoint for an execution.
543
+
544
+ Args:
545
+ execution_arn: The execution ARN
546
+ checkpoint_token: Current checkpoint token
547
+ updates: List of operation updates to process
548
+ client_token: Client token for idempotency
549
+
550
+ Returns:
551
+ CheckpointDurableExecutionResponse: Updated checkpoint token and state
552
+
553
+ Raises:
554
+ ResourceNotFoundException: If execution does not exist
555
+ InvalidParameterValueException: If checkpoint token is invalid
556
+ """
557
+ execution = self.get_execution(execution_arn)
558
+
559
+ # Validate checkpoint token
560
+ if checkpoint_token not in execution.used_tokens:
561
+ msg: str = f"Invalid checkpoint token: {checkpoint_token}"
562
+ raise InvalidParameterValueException(msg)
563
+
564
+ if updates:
565
+ checkpoint_output = self._checkpoint_processor.process_checkpoint(
566
+ checkpoint_token=checkpoint_token,
567
+ updates=updates,
568
+ client_token=client_token,
569
+ )
570
+
571
+ new_execution_state = None
572
+ if checkpoint_output.new_execution_state:
573
+ new_execution_state = CheckpointUpdatedExecutionState(
574
+ operations=checkpoint_output.new_execution_state.operations,
575
+ next_marker=checkpoint_output.new_execution_state.next_marker,
576
+ )
577
+
578
+ return CheckpointDurableExecutionResponse(
579
+ checkpoint_token=checkpoint_output.checkpoint_token,
580
+ new_execution_state=new_execution_state,
581
+ )
582
+
583
+ # Save execution state after generating new token
584
+ new_checkpoint_token = execution.get_new_checkpoint_token()
585
+ self._store.update(execution)
586
+
587
+ return CheckpointDurableExecutionResponse(
588
+ checkpoint_token=new_checkpoint_token,
589
+ new_execution_state=None,
590
+ )
591
+
592
+ def send_callback_success(
593
+ self,
594
+ callback_id: str,
595
+ result: bytes | None = None,
596
+ ) -> SendDurableExecutionCallbackSuccessResponse:
597
+ """Send callback success response.
598
+
599
+ Args:
600
+ callback_id: The callback ID to respond to
601
+ result: Optional result data for the callback
602
+
603
+ Returns:
604
+ SendDurableExecutionCallbackSuccessResponse: Empty response
605
+
606
+ Raises:
607
+ InvalidParameterValueException: If callback_id is invalid
608
+ ResourceNotFoundException: If callback does not exist
609
+ """
610
+ if not callback_id:
611
+ msg: str = "callback_id is required"
612
+ raise InvalidParameterValueException(msg)
613
+
614
+ try:
615
+ callback_token = CallbackToken.from_str(callback_id)
616
+ execution = self.get_execution(callback_token.execution_arn)
617
+ execution.complete_callback_success(callback_id, result)
618
+ self._store.update(execution)
619
+ self._cleanup_callback_timeouts(callback_id)
620
+ self._invoke_execution(callback_token.execution_arn)
621
+ logger.info("Callback success completed for callback_id: %s", callback_id)
622
+ except Exception as e:
623
+ msg = f"Failed to process callback success: {e}"
624
+ raise ResourceNotFoundException(msg) from e
625
+
626
+ return SendDurableExecutionCallbackSuccessResponse()
627
+
628
+ def send_callback_failure(
629
+ self,
630
+ callback_id: str,
631
+ error: ErrorObject | None = None,
632
+ ) -> SendDurableExecutionCallbackFailureResponse:
633
+ """Send callback failure response.
634
+
635
+ Args:
636
+ callback_id: The callback ID to respond to
637
+ error: Optional error object for the callback failure
638
+
639
+ Returns:
640
+ SendDurableExecutionCallbackFailureResponse: Empty response
641
+
642
+ Raises:
643
+ InvalidParameterValueException: If callback_id is invalid
644
+ ResourceNotFoundException: If callback does not exist
645
+ """
646
+ if not callback_id:
647
+ msg: str = "callback_id is required"
648
+ raise InvalidParameterValueException(msg)
649
+
650
+ callback_error: ErrorObject = error or ErrorObject.from_message("")
651
+
652
+ try:
653
+ callback_token: CallbackToken = CallbackToken.from_str(callback_id)
654
+ execution: Execution = self.get_execution(callback_token.execution_arn)
655
+ execution.complete_callback_failure(callback_id, callback_error)
656
+ self._store.update(execution)
657
+ self._cleanup_callback_timeouts(callback_id)
658
+ self._invoke_execution(callback_token.execution_arn)
659
+ logger.info("Callback failure completed for callback_id: %s", callback_id)
660
+ except Exception as e:
661
+ msg = f"Failed to process callback failure: {e}"
662
+ raise ResourceNotFoundException(msg) from e
663
+
664
+ return SendDurableExecutionCallbackFailureResponse()
665
+
666
+ def send_callback_heartbeat(
667
+ self, callback_id: str
668
+ ) -> SendDurableExecutionCallbackHeartbeatResponse:
669
+ """Send callback heartbeat to keep callback alive.
670
+
671
+ Args:
672
+ callback_id: The callback ID to send heartbeat for
673
+
674
+ Returns:
675
+ SendDurableExecutionCallbackHeartbeatResponse: Empty response
676
+
677
+ Raises:
678
+ InvalidParameterValueException: If callback_id is invalid
679
+ ResourceNotFoundException: If callback does not exist
680
+ """
681
+ if not callback_id:
682
+ msg: str = "callback_id is required"
683
+ raise InvalidParameterValueException(msg)
684
+
685
+ try:
686
+ callback_token: CallbackToken = CallbackToken.from_str(callback_id)
687
+ execution: Execution = self.get_execution(callback_token.execution_arn)
688
+
689
+ # Find callback operation to verify it exists and is active
690
+ _, operation = execution.find_callback_operation(callback_id)
691
+ if operation.status != OperationStatus.STARTED:
692
+ msg = f"Callback {callback_id} is not active"
693
+ raise ResourceNotFoundException(msg)
694
+
695
+ # Reset heartbeat timeout if configured
696
+ self._reset_callback_heartbeat_timeout(
697
+ callback_id, execution.durable_execution_arn
698
+ )
699
+ logger.info("Callback heartbeat processed for callback_id: %s", callback_id)
700
+ except Exception as e:
701
+ msg = f"Failed to process callback heartbeat: {e}"
702
+ raise ResourceNotFoundException(msg) from e
703
+
704
+ return SendDurableExecutionCallbackHeartbeatResponse()
705
+
706
+ def _validate_invocation_response_and_store(
707
+ self,
708
+ execution_arn: str,
709
+ response: DurableExecutionInvocationOutput,
710
+ execution: Execution,
711
+ ):
712
+ """Validate response status and save it to the store if fine.
713
+
714
+ Raises:
715
+ InvalidParameterValueException: If the response status is invalid.
716
+ IllegalStateException: If the response status is valid but the execution is already completed.
717
+ """
718
+ if execution.is_complete:
719
+ msg_already_complete: str = "Execution already completed, ignoring result"
720
+
721
+ raise IllegalStateException(msg_already_complete)
722
+
723
+ if response.status is None:
724
+ msg_status_required: str = "Response status is required"
725
+
726
+ raise InvalidParameterValueException(msg_status_required)
727
+
728
+ match response.status:
729
+ case InvocationStatus.FAILED:
730
+ if response.result is not None:
731
+ msg_failed_result: str = (
732
+ "Cannot provide a Result for FAILED status."
733
+ )
734
+ raise InvalidParameterValueException(msg_failed_result)
735
+ logger.info("[%s] Execution failed", execution_arn)
736
+ self._complete_workflow(
737
+ execution_arn, result=None, error=response.error
738
+ )
739
+
740
+ case InvocationStatus.SUCCEEDED:
741
+ if response.error is not None:
742
+ msg_success_error: str = (
743
+ "Cannot provide an Error for SUCCEEDED status."
744
+ )
745
+ raise InvalidParameterValueException(msg_success_error)
746
+ logger.info("[%s] Execution succeeded", execution_arn)
747
+ self._complete_workflow(
748
+ execution_arn, result=response.result, error=None
749
+ )
750
+
751
+ case InvocationStatus.PENDING:
752
+ if not execution.has_pending_operations(execution):
753
+ msg_pending_ops: str = (
754
+ "Cannot return PENDING status with no pending operations."
755
+ )
756
+ raise InvalidParameterValueException(msg_pending_ops)
757
+ logger.info("[%s] Execution pending async work", execution_arn)
758
+
759
+ case _:
760
+ msg_unexpected_status: str = (
761
+ f"Unexpected invocation status: {response.status}"
762
+ )
763
+ raise IllegalStateException(msg_unexpected_status)
764
+
765
+ def _invoke_handler(self, execution_arn: str) -> Callable[[], Awaitable[None]]:
766
+ """Create a parameterless callable that captures execution arn for the scheduler."""
767
+
768
+ async def invoke() -> None:
769
+ execution: Execution = self._store.load(execution_arn)
770
+
771
+ # Early exit if execution is already completed - like Java's COMPLETED check
772
+ if execution.is_complete:
773
+ logger.info(
774
+ "[%s] Execution already completed, ignoring result", execution_arn
775
+ )
776
+ return
777
+
778
+ try:
779
+ invocation_input: DurableExecutionInvocationInput = (
780
+ self._invoker.create_invocation_input(execution=execution)
781
+ )
782
+
783
+ self._store.save(execution)
784
+
785
+ invocation_start = datetime.now(UTC)
786
+ invoke_response = self._invoker.invoke(
787
+ execution.start_input.function_name,
788
+ invocation_input,
789
+ execution.start_input.lambda_endpoint,
790
+ )
791
+ invocation_end = datetime.now(UTC)
792
+
793
+ # Reload execution after invocation in case it was completed via checkpoint
794
+ execution = self._store.load(execution_arn)
795
+
796
+ # Record invocation completion and save immediately
797
+ execution.record_invocation_completion(
798
+ invocation_start, invocation_end, invoke_response.request_id
799
+ )
800
+ self._store.save(execution)
801
+
802
+ if execution.is_complete:
803
+ logger.info(
804
+ "[%s] Execution completed during invocation, ignoring result",
805
+ execution_arn,
806
+ )
807
+ return
808
+
809
+ # Process successful received response - validate status and handle accordingly
810
+ response = invoke_response.invocation_output
811
+ try:
812
+ self._validate_invocation_response_and_store(
813
+ execution_arn, response, execution
814
+ )
815
+ except (InvalidParameterValueException, IllegalStateException) as e:
816
+ logger.warning(
817
+ "[%s] Lambda output validation failure: %s", execution_arn, e
818
+ )
819
+ error_obj = ErrorObject.from_exception(e)
820
+ self._retry_invocation(execution, error_obj)
821
+
822
+ except ResourceNotFoundException:
823
+ logger.warning(
824
+ "[%s] Function No longer exists: %s",
825
+ execution_arn,
826
+ execution.start_input.function_name,
827
+ )
828
+ error_obj = ErrorObject.from_message(
829
+ message=f"Function not found: {execution.start_input.function_name}"
830
+ )
831
+ self._fail_workflow(execution_arn, error_obj)
832
+
833
+ except Exception as e: # noqa: BLE001
834
+ # Handle invocation errors (network, function not found, etc.)
835
+ logger.warning("[%s] Invocation failed: %s", execution_arn, e)
836
+ error_obj = ErrorObject.from_exception(e)
837
+ self._retry_invocation(execution, error_obj)
838
+
839
+ return invoke
840
+
841
+ def _invoke_execution(self, execution_arn: str, delay: float = 0) -> None:
842
+ """Invoke execution after delay in seconds."""
843
+ completion_event = self._completion_events.get(execution_arn)
844
+ self._scheduler.call_later(
845
+ self._invoke_handler(execution_arn),
846
+ delay=delay,
847
+ completion_event=completion_event,
848
+ )
849
+
850
+ def _complete_workflow(
851
+ self, execution_arn: str, result: str | None, error: ErrorObject | None
852
+ ):
853
+ """Complete workflow - handles both success and failure with terminal state validation."""
854
+ execution = self._store.load(execution_arn)
855
+
856
+ if execution.is_complete:
857
+ msg: str = "Cannot make multiple close workflow decisions."
858
+
859
+ raise IllegalStateException(msg)
860
+
861
+ if error is not None:
862
+ self.fail_execution(execution_arn, error)
863
+ else:
864
+ self.complete_execution(execution_arn, result)
865
+
866
+ def _fail_workflow(self, execution_arn: str, error: ErrorObject):
867
+ """Fail workflow with terminal state validation."""
868
+ execution = self._store.load(execution_arn)
869
+
870
+ if execution.is_complete:
871
+ msg: str = "Cannot make multiple close workflow decisions."
872
+
873
+ raise IllegalStateException(msg)
874
+
875
+ self.fail_execution(execution_arn, error)
876
+
877
+ def _retry_invocation(self, execution: Execution, error: ErrorObject):
878
+ """Handle retry logic or fail execution if retries exhausted."""
879
+ if (
880
+ execution.consecutive_failed_invocation_attempts
881
+ > self.MAX_CONSECUTIVE_FAILED_ATTEMPTS
882
+ ):
883
+ # Exhausted retries - fail the execution
884
+ self._fail_workflow(
885
+ execution_arn=execution.durable_execution_arn, error=error
886
+ )
887
+ else:
888
+ # Schedule retry with backoff
889
+ execution.consecutive_failed_invocation_attempts += 1
890
+ self._store.save(execution)
891
+ self._invoke_execution(
892
+ execution_arn=execution.durable_execution_arn,
893
+ delay=self.RETRY_BACKOFF_SECONDS,
894
+ )
895
+
896
+ def _complete_events(self, execution_arn: str):
897
+ # complete doesn't actually checkpoint explicitly
898
+ if event := self._completion_events.get(execution_arn):
899
+ event.set()
900
+
901
+ def wait_until_complete(
902
+ self, execution_arn: str, timeout: float | None = None
903
+ ) -> bool:
904
+ """Block until execution completion. Don't do this unless you actually want to block.
905
+
906
+ Args
907
+ timeout (int|float|None): Wait for event to set until this timeout.
908
+
909
+ Returns:
910
+ True when set. False if the event timed out without being set.
911
+ """
912
+ if event := self._completion_events.get(execution_arn):
913
+ return event.wait(timeout)
914
+
915
+ # this really shouldn't happen - implies execution timed out?
916
+ msg: str = "execution does not exist."
917
+
918
+ raise ResourceNotFoundException(msg)
919
+
920
+ def complete_execution(self, execution_arn: str, result: str | None = None) -> None:
921
+ """Complete execution successfully (COMPLETE_WORKFLOW_EXECUTION decision)."""
922
+ logger.debug("[%s] Completing execution with result: %s", execution_arn, result)
923
+ execution: Execution = self._store.load(execution_arn=execution_arn)
924
+ execution.complete_success(result=result) # Sets CloseStatus.COMPLETED
925
+ self._store.update(execution)
926
+ if execution.result is None:
927
+ msg: str = "Execution result is required"
928
+ raise IllegalStateException(msg)
929
+ self._complete_events(execution_arn=execution_arn)
930
+
931
+ def fail_execution(self, execution_arn: str, error: ErrorObject) -> None:
932
+ """Fail execution with error (FAIL_WORKFLOW_EXECUTION decision)."""
933
+ logger.error("[%s] Completing execution with error: %s", execution_arn, error)
934
+ execution: Execution = self._store.load(execution_arn=execution_arn)
935
+ execution.complete_fail(error=error) # Sets CloseStatus.FAILED
936
+ self._store.update(execution)
937
+ # set by complete_fail
938
+ if execution.result is None:
939
+ msg: str = "Execution result is required"
940
+ raise IllegalStateException(msg)
941
+ self._complete_events(execution_arn=execution_arn)
942
+
943
+ def _on_wait_succeeded(self, execution_arn: str, operation_id: str) -> None:
944
+ """Private method - called when a wait operation completes successfully."""
945
+ execution = self._store.load(execution_arn)
946
+
947
+ if execution.is_complete:
948
+ logger.info(
949
+ "[%s] Execution already completed, ignoring wait succeeded event",
950
+ execution_arn,
951
+ )
952
+ return
953
+
954
+ try:
955
+ execution.complete_wait(operation_id=operation_id)
956
+ self._store.update(execution)
957
+ logger.debug(
958
+ "[%s] Wait succeeded for operation %s", execution_arn, operation_id
959
+ )
960
+ except Exception:
961
+ logger.exception("[%s] Error processing wait succeeded.", execution_arn)
962
+
963
+ def _on_retry_ready(self, execution_arn: str, operation_id: str) -> None:
964
+ """Private method - called when a retry delay has elapsed and retry is ready."""
965
+ execution = self._store.load(execution_arn)
966
+
967
+ if execution.is_complete:
968
+ logger.info(
969
+ "[%s] Execution already completed, ignoring retry", execution_arn
970
+ )
971
+ return
972
+
973
+ try:
974
+ execution.complete_retry(operation_id=operation_id)
975
+ self._store.update(execution)
976
+ logger.debug(
977
+ "[%s] Retry ready for operation %s", execution_arn, operation_id
978
+ )
979
+ except Exception:
980
+ logger.exception("[%s] Error processing retry ready.", execution_arn)
981
+
982
+ # region ExecutionObserver
983
+ def on_completed(self, execution_arn: str, result: str | None = None) -> None:
984
+ """Complete execution successfully. Observer method triggered by notifier."""
985
+ self.complete_execution(execution_arn, result)
986
+
987
+ def on_failed(self, execution_arn: str, error: ErrorObject) -> None:
988
+ """Fail execution. Observer method triggered by notifier."""
989
+ self.fail_execution(execution_arn, error)
990
+
991
+ def on_timed_out(self, execution_arn: str, error: ErrorObject) -> None:
992
+ """Handle execution timeout (workflow timeout). Observer method triggered by notifier."""
993
+ logger.exception("[%s] Execution timed out.", execution_arn)
994
+ execution: Execution = self._store.load(execution_arn=execution_arn)
995
+ execution.complete_timeout(error=error) # Sets CloseStatus.TIMED_OUT
996
+ self._store.update(execution)
997
+ self._complete_events(execution_arn=execution_arn)
998
+
999
+ def on_stopped(self, execution_arn: str, error: ErrorObject) -> None:
1000
+ """Handle execution stop. Observer method triggered by notifier."""
1001
+ # This should not be called directly - stop_execution handles termination
1002
+ self.fail_execution(execution_arn, error)
1003
+
1004
+ def on_wait_timer_scheduled(
1005
+ self, execution_arn: str, operation_id: str, delay: float
1006
+ ) -> None:
1007
+ """Schedule a wait operation. Observer method triggered by notifier."""
1008
+ logger.debug("[%s] scheduling wait with delay: %d", execution_arn, delay)
1009
+
1010
+ def wait_handler() -> None:
1011
+ self._on_wait_succeeded(execution_arn, operation_id)
1012
+ self._invoke_execution(execution_arn, delay=0)
1013
+
1014
+ completion_event = self._completion_events.get(execution_arn)
1015
+ self._scheduler.call_later(
1016
+ wait_handler, delay=delay, completion_event=completion_event
1017
+ )
1018
+
1019
+ def on_step_retry_scheduled(
1020
+ self, execution_arn: str, operation_id: str, delay: float
1021
+ ) -> None:
1022
+ """Schedule a retry a step. Observer method triggered by notifier."""
1023
+ logger.debug(
1024
+ "[%s] scheduling retry for %s with delay: %d",
1025
+ execution_arn,
1026
+ operation_id,
1027
+ delay,
1028
+ )
1029
+
1030
+ def retry_handler() -> None:
1031
+ self._on_retry_ready(execution_arn, operation_id)
1032
+ self._invoke_execution(execution_arn, delay=0)
1033
+
1034
+ completion_event = self._completion_events.get(execution_arn)
1035
+ self._scheduler.call_later(
1036
+ retry_handler, delay=delay, completion_event=completion_event
1037
+ )
1038
+
1039
+ def on_callback_created(
1040
+ self,
1041
+ execution_arn: str,
1042
+ operation_id: str,
1043
+ callback_options: CallbackOptions | None,
1044
+ callback_token: CallbackToken,
1045
+ ) -> None:
1046
+ """Handle callback creation. Observer method triggered by notifier."""
1047
+ callback_id = callback_token.to_str()
1048
+ logger.debug(
1049
+ "[%s] Callback created for operation %s with callback_id: %s",
1050
+ execution_arn,
1051
+ operation_id,
1052
+ callback_id,
1053
+ )
1054
+
1055
+ # Schedule callback timeouts if configured
1056
+ self._schedule_callback_timeouts(execution_arn, callback_options, callback_id)
1057
+
1058
+ # endregion ExecutionObserver
1059
+
1060
+ # region Callback Timeouts
1061
+ def _schedule_callback_timeouts(
1062
+ self,
1063
+ execution_arn: str,
1064
+ callback_options: CallbackOptions | None,
1065
+ callback_id: str,
1066
+ ) -> None:
1067
+ """Schedule callback timeout and heartbeat timeout if configured."""
1068
+ try:
1069
+ if not callback_options:
1070
+ return
1071
+
1072
+ completion_event = self._completion_events.get(execution_arn)
1073
+
1074
+ # Schedule main timeout if configured
1075
+ if callback_options.timeout_seconds > 0:
1076
+
1077
+ def timeout_handler():
1078
+ self._on_callback_timeout(execution_arn, callback_id)
1079
+
1080
+ timeout_future = self._scheduler.call_later(
1081
+ timeout_handler,
1082
+ delay=callback_options.timeout_seconds,
1083
+ completion_event=completion_event,
1084
+ )
1085
+ self._callback_timeouts[callback_id] = timeout_future
1086
+
1087
+ # Schedule heartbeat timeout if configured
1088
+ if callback_options.heartbeat_timeout_seconds > 0:
1089
+
1090
+ def heartbeat_timeout_handler():
1091
+ self._on_callback_heartbeat_timeout(execution_arn, callback_id)
1092
+
1093
+ heartbeat_future = self._scheduler.call_later(
1094
+ heartbeat_timeout_handler,
1095
+ delay=callback_options.heartbeat_timeout_seconds,
1096
+ completion_event=completion_event,
1097
+ )
1098
+ self._callback_heartbeats[callback_id] = heartbeat_future
1099
+
1100
+ except Exception:
1101
+ logger.exception(
1102
+ "[%s] Error scheduling callback timeouts for %s",
1103
+ execution_arn,
1104
+ callback_id,
1105
+ )
1106
+
1107
+ def _reset_callback_heartbeat_timeout(
1108
+ self, callback_id: str, execution_arn: str
1109
+ ) -> None:
1110
+ """Reset the heartbeat timeout for a callback."""
1111
+ # Cancel existing heartbeat timeout
1112
+ if heartbeat_future := self._callback_heartbeats.pop(callback_id, None):
1113
+ heartbeat_future.cancel()
1114
+
1115
+ # Find callback options to reschedule heartbeat timeout
1116
+ try:
1117
+ callback_token = CallbackToken.from_str(callback_id)
1118
+ execution = self.get_execution(callback_token.execution_arn)
1119
+
1120
+ callback_options = None
1121
+ for update in execution.updates:
1122
+ if (
1123
+ update.operation_id == callback_token.operation_id
1124
+ and update.callback_options
1125
+ and update.action.value == "START"
1126
+ ):
1127
+ callback_options = update.callback_options
1128
+ break
1129
+
1130
+ if callback_options and callback_options.heartbeat_timeout_seconds > 0:
1131
+
1132
+ def heartbeat_timeout_handler():
1133
+ self._on_callback_heartbeat_timeout(execution_arn, callback_id)
1134
+
1135
+ completion_event = self._completion_events.get(execution_arn)
1136
+
1137
+ heartbeat_future = self._scheduler.call_later(
1138
+ heartbeat_timeout_handler,
1139
+ delay=callback_options.heartbeat_timeout_seconds,
1140
+ completion_event=completion_event,
1141
+ )
1142
+ self._callback_heartbeats[callback_id] = heartbeat_future
1143
+
1144
+ except Exception:
1145
+ logger.exception(
1146
+ "[%s] Error resetting callback heartbeat timeout for %s",
1147
+ execution_arn,
1148
+ callback_id,
1149
+ )
1150
+
1151
+ def _cleanup_callback_timeouts(self, callback_id: str) -> None:
1152
+ """Clean up timeout events for a completed callback."""
1153
+ # Clean up main timeout
1154
+ if timeout_future := self._callback_timeouts.pop(callback_id, None):
1155
+ timeout_future.cancel()
1156
+
1157
+ # Clean up heartbeat timeout
1158
+ if heartbeat_future := self._callback_heartbeats.pop(callback_id, None):
1159
+ heartbeat_future.cancel()
1160
+
1161
+ def _on_callback_timeout(self, execution_arn: str, callback_id: str) -> None:
1162
+ """Handle callback timeout."""
1163
+ try:
1164
+ callback_token = CallbackToken.from_str(callback_id)
1165
+ execution = self.get_execution(callback_token.execution_arn)
1166
+
1167
+ if execution.is_complete:
1168
+ return
1169
+
1170
+ # Fail the callback with timeout error
1171
+ timeout_error = ErrorObject.from_message(
1172
+ f"Callback timed out: {CallbackTimeoutType.TIMEOUT.value}"
1173
+ )
1174
+ execution.complete_callback_timeout(callback_id, timeout_error)
1175
+ self._store.update(execution)
1176
+ logger.warning("[%s] Callback %s timed out", execution_arn, callback_id)
1177
+ self._invoke_execution(callback_token.execution_arn)
1178
+ except Exception:
1179
+ logger.exception(
1180
+ "[%s] Error processing callback timeout for %s",
1181
+ execution_arn,
1182
+ callback_id,
1183
+ )
1184
+
1185
+ def _on_callback_heartbeat_timeout(
1186
+ self, execution_arn: str, callback_id: str
1187
+ ) -> None:
1188
+ """Handle callback heartbeat timeout."""
1189
+ try:
1190
+ callback_token = CallbackToken.from_str(callback_id)
1191
+ execution = self.get_execution(callback_token.execution_arn)
1192
+
1193
+ if execution.is_complete:
1194
+ return
1195
+
1196
+ # Fail the callback with heartbeat timeout error
1197
+
1198
+ heartbeat_error = ErrorObject.from_message(
1199
+ f"Callback heartbeat timed out: {CallbackTimeoutType.HEARTBEAT.value}"
1200
+ )
1201
+ execution.complete_callback_timeout(callback_id, heartbeat_error)
1202
+ self._store.update(execution)
1203
+ logger.warning(
1204
+ "[%s] Callback %s heartbeat timed out", execution_arn, callback_id
1205
+ )
1206
+ self._invoke_execution(callback_token.execution_arn)
1207
+ except Exception:
1208
+ logger.exception(
1209
+ "[%s] Error processing callback heartbeat timeout for %s",
1210
+ execution_arn,
1211
+ callback_id,
1212
+ )
1213
+
1214
+ # endregion Callback Timeouts