Coverage for async-durable-execution/src/async_durable_execution/runner/local/executor.py: 98%

511 statements  

« prev     ^ index     » next       coverage.py v7.15.0, created at 2026-07-07 16:54 +0000

1"""Execution life-cycle logic.""" 

2 

3from __future__ import annotations 

4 

5import logging 

6import uuid 

7from datetime import datetime, timezone 

8from typing import TYPE_CHECKING 

9 

10from ...execution import ( 

11 DurableExecutionInvocationInput, 

12 DurableExecutionInvocationOutput, 

13 InvocationStatus, 

14) 

15from ...models import ( 

16 CallbackOptions, 

17 CallbackTimeoutType, 

18 CheckpointUpdatedExecutionState, 

19 ErrorObject, 

20 Operation, 

21 OperationStatus, 

22 OperationType, 

23 OperationUpdate, 

24) 

25from ..exceptions import ( 

26 IllegalStateException, 

27 InvalidParameterValueException, 

28 ResourceNotFoundException, 

29) 

30from ..model import ( 

31 TERMINAL_STATUSES, 

32 EventCreationContext, 

33 GetDurableExecutionHistoryResponse, 

34) 

35from ..model import ( 

36 Event as HistoryEvent, 

37) 

38from .model import ( 

39 CallbackToken, 

40 CheckpointDurableExecutionResponse, 

41 GetDurableExecutionStateResponse, 

42 Invoker, 

43 SendDurableExecutionCallbackFailureResponse, 

44 SendDurableExecutionCallbackHeartbeatResponse, 

45 SendDurableExecutionCallbackSuccessResponse, 

46 StartDurableExecutionInput, 

47 StartDurableExecutionOutput, 

48) 

49from .time_scale import scale_delay 

50from .execution import Execution 

51 

52if TYPE_CHECKING: 

53 from collections.abc import Awaitable, Callable 

54 from asyncio import Future 

55 

56 from . import InMemoryServiceClient 

57 from .scheduler import Event, Scheduler 

58 

59logger = logging.getLogger(__name__) 

60 

61_CALLBACK_TIMEOUT_MINIMUM_DELAY_SECONDS = 5.0 

62 

63 

64class Executor: 

65 MAX_CONSECUTIVE_FAILED_ATTEMPTS: int = 5 

66 RETRY_BACKOFF_SECONDS: int = 5 

67 

68 def __init__( 

69 self, 

70 scheduler: Scheduler, 

71 invoker: Invoker, 

72 service_client: InMemoryServiceClient, 

73 ): 

74 self._scheduler = scheduler 

75 self._invoker = invoker 

76 self._service_client = service_client 

77 self._execution: Execution | None = None 

78 self._execution_arn: str | None = None 

79 self._completion_event: Event | None = None 

80 self._callback_timeouts: dict[str, Future] = {} 

81 self._callback_heartbeats: dict[str, Future] = {} 

82 self._execution_timeout: Future | None = None 

83 self._active_invocation: bool = False 

84 self._scheduled_resume: bool = False 

85 self._pending_resume: bool = False 

86 self._deferred_wait_resumes: set[str] = set() 

87 self._deferred_retry_resumes: set[str] = set() 

88 self._deferred_callback_timeouts: set[tuple[str, CallbackTimeoutType]] = set() 

89 

90 def start_execution( 

91 self, 

92 input: StartDurableExecutionInput, # noqa: A002 

93 ) -> StartDurableExecutionOutput: 

94 self._ensure_can_start_execution() 

95 

96 # Generate invocation_id if not provided 

97 if input.invocation_id is None: 

98 input = StartDurableExecutionInput( 

99 account_id=input.account_id, 

100 function_name=input.function_name, 

101 function_qualifier=input.function_qualifier, 

102 execution_name=input.execution_name, 

103 execution_timeout_seconds=input.execution_timeout_seconds, 

104 execution_retention_period_days=input.execution_retention_period_days, 

105 invocation_id=str(uuid.uuid4()), 

106 trace_fields=input.trace_fields, 

107 tenant_id=input.tenant_id, 

108 input=input.input, 

109 lambda_endpoint=input.lambda_endpoint, 

110 ) 

111 

112 execution = Execution.new(input=input) 

113 execution.start() 

114 self._save_execution(execution) 

115 logger.debug("Created execution with ARN: %s", execution.durable_execution_arn) 

116 

117 completion_event = self._scheduler.create_event() 

118 self._execution_arn = execution.durable_execution_arn 

119 self._completion_event = completion_event 

120 self._execution_timeout = None 

121 self._active_invocation = False 

122 self._scheduled_resume = False 

123 self._pending_resume = False 

124 self._deferred_wait_resumes.clear() 

125 self._deferred_retry_resumes.clear() 

126 self._deferred_callback_timeouts.clear() 

127 

128 # Schedule execution timeout 

129 if input.execution_timeout_seconds > 0: 

130 

131 async def timeout_handler() -> None: 

132 error = ErrorObject.from_message( 

133 f"Execution timed out after {input.execution_timeout_seconds} seconds." 

134 ) 

135 self.timeout_execution(execution.durable_execution_arn, error) 

136 

137 self._execution_timeout = self._scheduler.call_later( 

138 timeout_handler, 

139 delay=input.execution_timeout_seconds, 

140 completion_event=completion_event, 

141 ) 

142 

143 # Schedule initial invocation to run immediately 

144 self._invoke_execution(execution.durable_execution_arn) 

145 

146 return StartDurableExecutionOutput( 

147 execution_arn=execution.durable_execution_arn 

148 ) 

149 

150 def _ensure_can_start_execution(self) -> None: 

151 """Reject overlapping executions in the local runner.""" 

152 if self._execution is None: 

153 return 

154 

155 if not self._execution.is_complete: 

156 msg = "Local runner supports only one execution at a time." 

157 raise IllegalStateException(msg) 

158 

159 def _save_execution(self, execution: Execution) -> None: 

160 self._execution = execution 

161 self._execution_arn = execution.durable_execution_arn 

162 

163 def _update_execution(self, execution: Execution) -> None: 

164 self._execution = execution 

165 self._execution_arn = execution.durable_execution_arn 

166 

167 def set_execution(self, execution: Execution) -> None: 

168 """Replace the current local execution object.""" 

169 self._update_execution(execution) 

170 

171 def _validate_current_execution(self, execution_arn: str) -> None: 

172 if self._execution_arn is None: 

173 return 

174 

175 if self._execution_arn != execution_arn: 

176 msg = f"Execution {execution_arn} is not the active local execution." 

177 raise ResourceNotFoundException(msg) 

178 

179 def get_execution(self, execution_arn: str) -> Execution: 

180 """Get execution by ARN. 

181 

182 Args: 

183 execution_arn: The execution ARN to retrieve 

184 

185 Returns: 

186 Execution: The execution object 

187 

188 Raises: 

189 ResourceNotFoundException: If execution does not exist 

190 """ 

191 if ( 

192 self._execution is None 

193 or self._execution.durable_execution_arn != execution_arn 

194 ): 

195 msg: str = f"Execution {execution_arn} not found" 

196 raise ResourceNotFoundException(msg) 

197 return self._execution 

198 

199 def get_execution_state( 

200 self, 

201 execution_arn: str, 

202 checkpoint_token: str | None = None, 

203 marker: str | None = None, 

204 max_items: int | None = None, 

205 ) -> GetDurableExecutionStateResponse: 

206 """Get execution state with operations. 

207 

208 Args: 

209 execution_arn: The execution ARN 

210 checkpoint_token: Checkpoint token for state consistency 

211 marker: Pagination marker 

212 max_items: Maximum items to return 

213 

214 Returns: 

215 GetDurableExecutionStateResponse: Execution state with operations 

216 

217 Raises: 

218 ResourceNotFoundException: If execution does not exist 

219 InvalidParameterValueException: If checkpoint token is invalid 

220 """ 

221 execution = self.get_execution(execution_arn) 

222 

223 # TODO: Validate checkpoint token if provided 

224 if checkpoint_token and checkpoint_token not in execution.used_tokens: 

225 msg: str = f"Invalid checkpoint token: {checkpoint_token}" 

226 raise InvalidParameterValueException(msg) 

227 

228 # Get operations (excluding the initial EXECUTION operation for state) 

229 operations = execution.get_assertable_operations() 

230 

231 # Apply pagination 

232 if max_items is None: 

233 max_items = 100 

234 

235 # Simple pagination - in real implementation would need proper marker handling 

236 start_index = 0 

237 if marker: 

238 try: 

239 start_index = int(marker) 

240 except ValueError: 

241 start_index = 0 

242 

243 end_index = start_index + max_items 

244 paginated_operations = operations[start_index:end_index] 

245 

246 next_marker = None 

247 if end_index < len(operations): 

248 next_marker = str(end_index) 

249 

250 return GetDurableExecutionStateResponse( 

251 operations=paginated_operations, next_marker=next_marker 

252 ) 

253 

254 def get_execution_history( 

255 self, 

256 execution_arn: str, 

257 include_execution_data: bool = False, # noqa: FBT001, FBT002 

258 reverse_order: bool = False, # noqa: FBT001, FBT002 

259 marker: str | None = None, 

260 max_items: int | None = None, 

261 ) -> GetDurableExecutionHistoryResponse: 

262 """Get execution history with events. 

263 

264 Args: 

265 execution_arn: The execution ARN 

266 include_execution_data: Whether to include execution data in events 

267 reverse_order: Return events in reverse chronological order 

268 marker: Pagination marker (event_id) 

269 max_items: Maximum items to return 

270 

271 Returns: 

272 GetDurableExecutionHistoryResponse: Execution history with events 

273 

274 Raises: 

275 ResourceNotFoundException: If execution does not exist 

276 """ 

277 execution: Execution = self.get_execution(execution_arn) 

278 

279 # Generate events 

280 all_events: list[HistoryEvent] = [] 

281 ops: list[Operation] = execution.operations 

282 updates: list[OperationUpdate] = execution.updates 

283 updates_dict: dict[str, OperationUpdate] = {u.operation_id: u for u in updates} 

284 durable_execution_arn: str = execution.durable_execution_arn 

285 

286 # Add InvocationCompleted events 

287 for completion in execution.invocation_completions: 

288 invocation_event = HistoryEvent.create_invocation_completed( 

289 event_id=0, # Temporary, will be reassigned 

290 event_timestamp=completion.end_timestamp, 

291 start_timestamp=completion.start_timestamp, 

292 end_timestamp=completion.end_timestamp, 

293 request_id=completion.request_id, 

294 ) 

295 all_events.append(invocation_event) 

296 

297 # Generate all events first (without final event IDs) 

298 for op in ops: 

299 operation_update: OperationUpdate | None = updates_dict.get(op.operation_id) 

300 

301 if op.status is OperationStatus.PENDING: 

302 if ( 

303 op.operation_type is not OperationType.CHAINED_INVOKE 

304 or op.start_timestamp is None 

305 ): 

306 continue 

307 context: EventCreationContext = EventCreationContext( 

308 op, 

309 0, # Temporary event_id, will be reassigned after sorting 

310 durable_execution_arn, 

311 execution.start_input, 

312 execution.result, 

313 operation_update, 

314 include_execution_data, 

315 ) 

316 pending = HistoryEvent.create_chained_invoke_event_pending(context) 

317 all_events.append(pending) 

318 if op.start_timestamp is not None: 

319 context = EventCreationContext( 

320 op, 

321 0, # Temporary event_id, will be reassigned after sorting 

322 durable_execution_arn, 

323 execution.start_input, 

324 execution.result, 

325 operation_update, 

326 include_execution_data, 

327 ) 

328 started = HistoryEvent.create_event_started(context) 

329 all_events.append(started) 

330 if op.end_timestamp is not None and op.status in TERMINAL_STATUSES: 

331 context = EventCreationContext( 

332 op, 

333 0, # Temporary event_id, will be reassigned after sorting 

334 durable_execution_arn, 

335 execution.start_input, 

336 execution.result, 

337 operation_update, 

338 include_execution_data, 

339 ) 

340 finished = HistoryEvent.create_event_terminated(context) 

341 all_events.append(finished) 

342 

343 # Sort events by timestamp to get correct chronological order 

344 all_events.sort(key=lambda event: event.event_timestamp) 

345 

346 # Reassign event IDs based on chronological order 

347 all_events = [ 

348 HistoryEvent.from_event_with_id(event, i) 

349 for i, event in enumerate(all_events, 1) 

350 ] 

351 

352 # Apply cursor-based pagination 

353 if max_items is None: 

354 max_items = 100 

355 

356 # Handle pagination marker 

357 if reverse_order: 

358 all_events.reverse() 

359 start_index: int = 0 

360 if marker: 

361 try: 

362 marker_event_id: int = int(marker) 

363 # Find the index of the first event with event_id >= marker 

364 start_index = len(all_events) 

365 for i, e in enumerate(all_events): 

366 is_valid_page_start: bool = ( 

367 e.event_id < marker_event_id 

368 if reverse_order 

369 else e.event_id >= marker_event_id 

370 ) 

371 if is_valid_page_start: 

372 start_index = i 

373 break 

374 except ValueError: 

375 start_index = 0 

376 

377 # Get paginated events 

378 end_index: int = start_index + max_items 

379 paginated_events: list[HistoryEvent] = all_events[start_index:end_index] 

380 

381 # Generate next marker 

382 next_marker: str | None = None 

383 if end_index < len(all_events): 

384 if reverse_order: 

385 # Next marker is the event_id of the last returned event 

386 next_marker = ( 

387 str(paginated_events[-1].event_id) if paginated_events else None 

388 ) 

389 else: 

390 # Next marker is the event_id of the next event after the last returned 

391 next_marker = ( 

392 str(all_events[end_index].event_id) 

393 if end_index < len(all_events) 

394 else None 

395 ) 

396 

397 return GetDurableExecutionHistoryResponse( 

398 events=paginated_events, next_marker=next_marker 

399 ) 

400 

401 def checkpoint_execution( 

402 self, 

403 execution_arn: str, 

404 checkpoint_token: str, 

405 updates: list[OperationUpdate] | None = None, 

406 client_token: str | None = None, 

407 ) -> CheckpointDurableExecutionResponse: 

408 """Process checkpoint for an execution. 

409 

410 Args: 

411 execution_arn: The execution ARN 

412 checkpoint_token: Current checkpoint token 

413 updates: List of operation updates to process 

414 client_token: Client token for idempotency 

415 

416 Returns: 

417 CheckpointDurableExecutionResponse: Updated checkpoint token and state 

418 

419 Raises: 

420 ResourceNotFoundException: If execution does not exist 

421 InvalidParameterValueException: If checkpoint token is invalid 

422 """ 

423 execution = self.get_execution(execution_arn) 

424 

425 # Validate checkpoint token 

426 if checkpoint_token not in execution.used_tokens: 

427 msg: str = f"Invalid checkpoint token: {checkpoint_token}" 

428 raise InvalidParameterValueException(msg) 

429 

430 if updates: 

431 checkpoint_output = self._service_client.process_checkpoint( 

432 checkpoint_token=checkpoint_token, 

433 updates=updates, 

434 client_token=client_token, 

435 ) 

436 

437 new_execution_state = None 

438 if checkpoint_output.new_execution_state: 

439 new_execution_state = CheckpointUpdatedExecutionState( 

440 operations=checkpoint_output.new_execution_state.operations, 

441 next_marker=checkpoint_output.new_execution_state.next_marker, 

442 ) 

443 

444 return CheckpointDurableExecutionResponse( 

445 checkpoint_token=checkpoint_output.checkpoint_token, 

446 new_execution_state=new_execution_state, 

447 ) 

448 

449 # Save execution state after generating new token 

450 new_checkpoint_token = execution.get_new_checkpoint_token() 

451 self._update_execution(execution) 

452 

453 return CheckpointDurableExecutionResponse( 

454 checkpoint_token=new_checkpoint_token, 

455 new_execution_state=None, 

456 ) 

457 

458 def send_callback_success( 

459 self, 

460 callback_id: str, 

461 result: bytes | None = None, 

462 ) -> SendDurableExecutionCallbackSuccessResponse: 

463 """Send callback success response. 

464 

465 Args: 

466 callback_id: The callback ID to respond to 

467 result: Optional result data for the callback 

468 

469 Returns: 

470 SendDurableExecutionCallbackSuccessResponse: Empty response 

471 

472 Raises: 

473 InvalidParameterValueException: If callback_id is invalid 

474 ResourceNotFoundException: If callback does not exist 

475 """ 

476 if not callback_id: 

477 msg: str = "callback_id is required" 

478 raise InvalidParameterValueException(msg) 

479 

480 try: 

481 callback_token = CallbackToken.from_str(callback_id) 

482 execution = self.get_execution(callback_token.execution_arn) 

483 execution.complete_callback_success(callback_id, result) 

484 self._update_execution(execution) 

485 self._cleanup_callback_timeouts(callback_id) 

486 self._schedule_resume(callback_token.execution_arn) 

487 logger.info("Callback success completed for callback_id: %s", callback_id) 

488 except Exception as e: 

489 msg = f"Failed to process callback success: {e}" 

490 raise ResourceNotFoundException(msg) from e 

491 

492 return SendDurableExecutionCallbackSuccessResponse() 

493 

494 def send_callback_failure( 

495 self, 

496 callback_id: str, 

497 error: ErrorObject | None = None, 

498 ) -> SendDurableExecutionCallbackFailureResponse: 

499 """Send callback failure response. 

500 

501 Args: 

502 callback_id: The callback ID to respond to 

503 error: Optional error object for the callback failure 

504 

505 Returns: 

506 SendDurableExecutionCallbackFailureResponse: Empty response 

507 

508 Raises: 

509 InvalidParameterValueException: If callback_id is invalid 

510 ResourceNotFoundException: If callback does not exist 

511 """ 

512 if not callback_id: 

513 msg: str = "callback_id is required" 

514 raise InvalidParameterValueException(msg) 

515 

516 callback_error: ErrorObject = error or ErrorObject.from_message("") 

517 

518 try: 

519 callback_token: CallbackToken = CallbackToken.from_str(callback_id) 

520 execution: Execution = self.get_execution(callback_token.execution_arn) 

521 execution.complete_callback_failure(callback_id, callback_error) 

522 self._update_execution(execution) 

523 self._cleanup_callback_timeouts(callback_id) 

524 self._schedule_resume(callback_token.execution_arn) 

525 logger.info("Callback failure completed for callback_id: %s", callback_id) 

526 except Exception as e: 

527 msg = f"Failed to process callback failure: {e}" 

528 raise ResourceNotFoundException(msg) from e 

529 

530 return SendDurableExecutionCallbackFailureResponse() 

531 

532 def send_callback_heartbeat( 

533 self, callback_id: str 

534 ) -> SendDurableExecutionCallbackHeartbeatResponse: 

535 """Send callback heartbeat to keep callback alive. 

536 

537 Args: 

538 callback_id: The callback ID to send heartbeat for 

539 

540 Returns: 

541 SendDurableExecutionCallbackHeartbeatResponse: Empty response 

542 

543 Raises: 

544 InvalidParameterValueException: If callback_id is invalid 

545 ResourceNotFoundException: If callback does not exist 

546 """ 

547 if not callback_id: 

548 msg: str = "callback_id is required" 

549 raise InvalidParameterValueException(msg) 

550 

551 try: 

552 callback_token: CallbackToken = CallbackToken.from_str(callback_id) 

553 execution: Execution = self.get_execution(callback_token.execution_arn) 

554 

555 # Find callback operation to verify it exists and is active 

556 _, operation = execution.find_callback_operation(callback_id) 

557 if operation.status != OperationStatus.STARTED: 

558 msg = f"Callback {callback_id} is not active" 

559 raise ResourceNotFoundException(msg) 

560 

561 # Reset heartbeat timeout if configured 

562 self._reset_callback_heartbeat_timeout( 

563 callback_id, execution.durable_execution_arn 

564 ) 

565 logger.info("Callback heartbeat processed for callback_id: %s", callback_id) 

566 except Exception as e: 

567 msg = f"Failed to process callback heartbeat: {e}" 

568 raise ResourceNotFoundException(msg) from e 

569 

570 return SendDurableExecutionCallbackHeartbeatResponse() 

571 

572 def _validate_invocation_response( 

573 self, 

574 execution_arn: str, 

575 response: DurableExecutionInvocationOutput, 

576 execution: Execution, 

577 ): 

578 """Validate response status and apply the resulting execution changes. 

579 

580 Raises: 

581 InvalidParameterValueException: If the response status is invalid. 

582 IllegalStateException: If the response status is valid but the execution is already completed. 

583 """ 

584 if execution.is_complete: 

585 msg_already_complete: str = "Execution already completed, ignoring result" 

586 

587 raise IllegalStateException(msg_already_complete) 

588 

589 if response.status is None: 

590 msg_status_required: str = "Response status is required" 

591 

592 raise InvalidParameterValueException(msg_status_required) 

593 

594 match response.status: 

595 case InvocationStatus.FAILED: 

596 if response.result is not None: 

597 msg_failed_result: str = ( 

598 "Cannot provide a Result for FAILED status." 

599 ) 

600 raise InvalidParameterValueException(msg_failed_result) 

601 logger.info("[%s] Execution failed", execution_arn) 

602 self._complete_workflow( 

603 execution_arn, result=None, error=response.error 

604 ) 

605 

606 case InvocationStatus.SUCCEEDED: 

607 if response.error is not None: 

608 msg_success_error: str = ( 

609 "Cannot provide an Error for SUCCEEDED status." 

610 ) 

611 raise InvalidParameterValueException(msg_success_error) 

612 logger.info("[%s] Execution succeeded", execution_arn) 

613 self._complete_workflow( 

614 execution_arn, result=response.result, error=None 

615 ) 

616 

617 case InvocationStatus.PENDING: 

618 if not execution.has_pending_operations(): 

619 msg_pending_ops: str = ( 

620 "Cannot return PENDING status with no pending operations." 

621 ) 

622 raise InvalidParameterValueException(msg_pending_ops) 

623 logger.info("[%s] Execution pending async work", execution_arn) 

624 

625 case _: 

626 msg_unexpected_status: str = ( 

627 f"Unexpected invocation status: {response.status}" 

628 ) 

629 raise IllegalStateException(msg_unexpected_status) 

630 

631 def _invoke_handler(self, execution_arn: str) -> Callable[[], Awaitable[None]]: 

632 """Create a parameterless callable that captures execution arn for the scheduler.""" 

633 

634 async def invoke() -> None: 

635 self._mark_invocation_started(execution_arn) 

636 execution: Execution = self.get_execution(execution_arn) 

637 

638 # Early exit if execution is already completed - like Java's COMPLETED check 

639 if execution.is_complete: 

640 logger.info( 

641 "[%s] Execution already completed, ignoring result", execution_arn 

642 ) 

643 return 

644 

645 try: 

646 checkpoint_token = execution.get_new_checkpoint_token() 

647 invocation_input: DurableExecutionInvocationInput = ( 

648 self._invoker.create_invocation_input( 

649 start_input=execution.start_input, 

650 durable_execution_arn=execution.durable_execution_arn, 

651 checkpoint_token=checkpoint_token, 

652 operations=execution.operations, 

653 ) 

654 ) 

655 

656 self._save_execution(execution) 

657 

658 invocation_start = datetime.now(timezone.utc) 

659 invoke_response = await self._invoker.invoke( 

660 execution.start_input.function_name, 

661 invocation_input, 

662 execution.start_input.lambda_endpoint, 

663 ) 

664 invocation_end = datetime.now(timezone.utc) 

665 

666 # Reload execution after invocation in case it was completed via checkpoint 

667 execution = self.get_execution(execution_arn) 

668 

669 # Record invocation completion and save immediately 

670 execution.record_invocation_completion( 

671 invocation_start, invocation_end, invoke_response.request_id 

672 ) 

673 self._save_execution(execution) 

674 

675 if execution.is_complete: 

676 logger.info( 

677 "[%s] Execution completed during invocation, ignoring result", 

678 execution_arn, 

679 ) 

680 return 

681 

682 # Process successful received response - validate status and handle accordingly 

683 response = invoke_response.invocation_output 

684 try: 

685 self._validate_invocation_response( 

686 execution_arn, response, execution 

687 ) 

688 except (InvalidParameterValueException, IllegalStateException) as e: 

689 logger.warning( 

690 "[%s] Lambda output validation failure: %s", execution_arn, e 

691 ) 

692 error_obj = ErrorObject.from_exception(e) 

693 self._retry_invocation(execution, error_obj) 

694 

695 except ResourceNotFoundException: 

696 logger.warning( 

697 "[%s] Function No longer exists: %s", 

698 execution_arn, 

699 execution.start_input.function_name, 

700 ) 

701 error_obj = ErrorObject.from_message( 

702 message=f"Function not found: {execution.start_input.function_name}" 

703 ) 

704 self._fail_workflow(execution_arn, error_obj) 

705 

706 except Exception as e: # noqa: BLE001 

707 # Handle invocation errors (network, function not found, etc.) 

708 logger.warning("[%s] Invocation failed: %s", execution_arn, e) 

709 error_obj = ErrorObject.from_exception(e) 

710 self._retry_invocation(execution, error_obj) 

711 finally: 

712 self._mark_invocation_finished(execution_arn) 

713 

714 return invoke 

715 

716 def _schedule_callback_resume(self, execution_arn: str) -> None: 

717 """Schedule a callback-triggered resume. 

718 

719 Kept as a named wrapper for tests and callers that exercise callback behavior. 

720 """ 

721 self._schedule_resume(execution_arn) 

722 

723 def _schedule_resume(self, execution_arn: str) -> None: 

724 """Coalesce external resumes to avoid overlapping replays.""" 

725 self._validate_current_execution(execution_arn) 

726 if self._active_invocation: 

727 self._pending_resume = True 

728 return 

729 

730 if self._scheduled_resume: 

731 return 

732 

733 self._scheduled_resume = True 

734 self._invoke_execution(execution_arn) 

735 

736 def _mark_invocation_started(self, execution_arn: str) -> None: 

737 self._validate_current_execution(execution_arn) 

738 self._scheduled_resume = False 

739 self._active_invocation = True 

740 

741 def _mark_invocation_finished(self, execution_arn: str) -> None: 

742 self._validate_current_execution(execution_arn) 

743 self._active_invocation = False 

744 should_resume = self._apply_deferred_resume_events(execution_arn) 

745 if self._pending_resume: 

746 self._pending_resume = False 

747 should_resume = True 

748 

749 if should_resume: 

750 execution = self.get_execution(execution_arn) 

751 if not execution.is_complete: 

752 self._scheduled_resume = True 

753 self._invoke_execution(execution_arn) 

754 

755 def _invoke_execution(self, execution_arn: str, delay: float = 0) -> None: 

756 """Invoke execution after delay in seconds.""" 

757 self._validate_current_execution(execution_arn) 

758 self._scheduler.call_later( 

759 self._invoke_handler(execution_arn), 

760 delay=delay, 

761 completion_event=self._completion_event, 

762 ) 

763 

764 def _complete_workflow( 

765 self, execution_arn: str, result: str | None, error: ErrorObject | None 

766 ): 

767 """Complete workflow - handles both success and failure with terminal state validation.""" 

768 execution = self.get_execution(execution_arn) 

769 

770 if execution.is_complete: 

771 msg: str = "Cannot make multiple close workflow decisions." 

772 

773 raise IllegalStateException(msg) 

774 

775 if error is not None: 

776 self.fail_execution(execution_arn, error) 

777 else: 

778 self.complete_execution(execution_arn, result) 

779 

780 def _fail_workflow(self, execution_arn: str, error: ErrorObject): 

781 """Fail workflow with terminal state validation.""" 

782 execution = self.get_execution(execution_arn) 

783 

784 if execution.is_complete: 

785 msg: str = "Cannot make multiple close workflow decisions." 

786 

787 raise IllegalStateException(msg) 

788 

789 self.fail_execution(execution_arn, error) 

790 

791 def _retry_invocation(self, execution: Execution, error: ErrorObject): 

792 """Handle retry logic or fail execution if retries exhausted.""" 

793 if ( 

794 execution.consecutive_failed_invocation_attempts 

795 > self.MAX_CONSECUTIVE_FAILED_ATTEMPTS 

796 ): 

797 # Exhausted retries - fail the execution 

798 self._fail_workflow( 

799 execution_arn=execution.durable_execution_arn, error=error 

800 ) 

801 else: 

802 # Schedule retry with backoff 

803 execution.consecutive_failed_invocation_attempts += 1 

804 self._save_execution(execution) 

805 self._invoke_execution( 

806 execution_arn=execution.durable_execution_arn, 

807 delay=self.RETRY_BACKOFF_SECONDS, 

808 ) 

809 

810 def _complete_events(self, execution_arn: str): 

811 self._validate_current_execution(execution_arn) 

812 # complete doesn't actually checkpoint explicitly 

813 if self._completion_event: 

814 self._completion_event.set() 

815 if execution_timeout := self._execution_timeout: 

816 execution_timeout.cancel() 

817 self._execution_timeout = None 

818 

819 async def wait_until_complete( 

820 self, execution_arn: str, timeout: float | None = None 

821 ) -> bool: 

822 """Wait until execution completion. 

823 

824 Args 

825 timeout (int|float|None): Wait for event to set until this timeout. 

826 

827 Returns: 

828 True when set. False if the event timed out without being set. 

829 """ 

830 self._validate_current_execution(execution_arn) 

831 if event := self._completion_event: 

832 return await event.wait_async(timeout) 

833 

834 # this really shouldn't happen - implies execution timed out? 

835 msg: str = "execution does not exist." 

836 

837 raise ResourceNotFoundException(msg) 

838 

839 def complete_execution(self, execution_arn: str, result: str | None = None) -> None: 

840 """Complete execution successfully (COMPLETE_WORKFLOW_EXECUTION decision).""" 

841 logger.debug("[%s] Completing execution with result: %s", execution_arn, result) 

842 execution: Execution = self.get_execution(execution_arn) 

843 execution.complete_success(result=result) # Sets CloseStatus.COMPLETED 

844 self._update_execution(execution) 

845 if execution.result is None: 

846 msg: str = "Execution result is required" 

847 raise IllegalStateException(msg) 

848 self._complete_events(execution_arn=execution_arn) 

849 

850 def fail_execution(self, execution_arn: str, error: ErrorObject) -> None: 

851 """Fail execution with error (FAIL_WORKFLOW_EXECUTION decision).""" 

852 logger.error("[%s] Completing execution with error: %s", execution_arn, error) 

853 execution: Execution = self.get_execution(execution_arn) 

854 execution.complete_fail(error=error) # Sets CloseStatus.FAILED 

855 self._update_execution(execution) 

856 # set by complete_fail 

857 if execution.result is None: 

858 msg: str = "Execution result is required" 

859 raise IllegalStateException(msg) 

860 self._complete_events(execution_arn=execution_arn) 

861 

862 def _on_wait_succeeded(self, execution_arn: str, operation_id: str) -> bool: 

863 """Private method - called when a wait operation completes successfully.""" 

864 execution = self.get_execution(execution_arn) 

865 

866 if execution.is_complete: 

867 logger.info( 

868 "[%s] Execution already completed, ignoring wait succeeded event", 

869 execution_arn, 

870 ) 

871 return False 

872 

873 try: 

874 execution.complete_wait(operation_id=operation_id) 

875 self._update_execution(execution) 

876 logger.debug( 

877 "[%s] Wait succeeded for operation %s", execution_arn, operation_id 

878 ) 

879 return True 

880 except Exception: 

881 logger.exception("[%s] Error processing wait succeeded.", execution_arn) 

882 return False 

883 

884 def _on_retry_ready(self, execution_arn: str, operation_id: str) -> bool: 

885 """Private method - called when a retry delay has elapsed and retry is ready.""" 

886 execution = self.get_execution(execution_arn) 

887 

888 if execution.is_complete: 

889 logger.info( 

890 "[%s] Execution already completed, ignoring retry", execution_arn 

891 ) 

892 return False 

893 

894 try: 

895 execution.complete_retry(operation_id=operation_id) 

896 self._update_execution(execution) 

897 logger.debug( 

898 "[%s] Retry ready for operation %s", execution_arn, operation_id 

899 ) 

900 return True 

901 except Exception: 

902 logger.exception("[%s] Error processing retry ready.", execution_arn) 

903 return False 

904 

905 def _defer_resume_event_if_active( 

906 self, 

907 execution_arn: str, 

908 operation_id: str, 

909 deferred_events: set[str], 

910 ) -> bool: 

911 self._validate_current_execution(execution_arn) 

912 if not self._active_invocation: 

913 return False 

914 

915 deferred_events.add(operation_id) 

916 return True 

917 

918 def _defer_callback_timeout_if_active( 

919 self, 

920 execution_arn: str, 

921 callback_id: str, 

922 timeout_type: CallbackTimeoutType, 

923 ) -> bool: 

924 self._validate_current_execution(execution_arn) 

925 if not self._active_invocation: 

926 return False 

927 

928 self._deferred_callback_timeouts.add((callback_id, timeout_type)) 

929 return True 

930 

931 def _apply_deferred_resume_events(self, execution_arn: str) -> bool: 

932 self._validate_current_execution(execution_arn) 

933 should_resume = False 

934 

935 for operation_id in list(self._deferred_wait_resumes): 

936 self._deferred_wait_resumes.discard(operation_id) 

937 should_resume = ( 

938 self._on_wait_succeeded(execution_arn, operation_id) or should_resume 

939 ) 

940 

941 for operation_id in list(self._deferred_retry_resumes): 

942 self._deferred_retry_resumes.discard(operation_id) 

943 should_resume = ( 

944 self._on_retry_ready(execution_arn, operation_id) or should_resume 

945 ) 

946 

947 for callback_event in list(self._deferred_callback_timeouts): 

948 callback_id, timeout_type = callback_event 

949 self._deferred_callback_timeouts.discard(callback_event) 

950 should_resume = ( 

951 self._complete_callback_timeout( 

952 execution_arn, callback_id, timeout_type 

953 ) 

954 or should_resume 

955 ) 

956 

957 return should_resume 

958 

959 def timeout_execution(self, execution_arn: str, error: ErrorObject) -> None: 

960 """Handle execution timeout.""" 

961 logger.exception("[%s] Execution timed out.", execution_arn) 

962 execution: Execution = self.get_execution(execution_arn) 

963 execution.complete_timeout(error=error) # Sets CloseStatus.TIMED_OUT 

964 self._update_execution(execution) 

965 self._complete_events(execution_arn=execution_arn) 

966 

967 def stop_execution(self, execution_arn: str, error: ErrorObject) -> None: 

968 """Handle execution stop.""" 

969 self.fail_execution(execution_arn, error) 

970 

971 def schedule_wait_timer( 

972 self, execution_arn: str, operation_id: str, delay: float 

973 ) -> None: 

974 """Schedule a wait operation.""" 

975 logger.debug("[%s] scheduling wait with delay: %d", execution_arn, delay) 

976 

977 async def wait_handler() -> None: 

978 if self._defer_resume_event_if_active( 

979 execution_arn, operation_id, self._deferred_wait_resumes 

980 ): 

981 return 

982 if self._on_wait_succeeded(execution_arn, operation_id): 

983 self._schedule_resume(execution_arn) 

984 

985 self._scheduler.call_later( 

986 wait_handler, delay=delay, completion_event=self._completion_event 

987 ) 

988 

989 def schedule_step_retry( 

990 self, execution_arn: str, operation_id: str, delay: float 

991 ) -> None: 

992 """Schedule a step retry.""" 

993 logger.debug( 

994 "[%s] scheduling retry for %s with delay: %d", 

995 execution_arn, 

996 operation_id, 

997 delay, 

998 ) 

999 

1000 async def retry_handler() -> None: 

1001 if self._defer_resume_event_if_active( 

1002 execution_arn, operation_id, self._deferred_retry_resumes 

1003 ): 

1004 return 

1005 if self._on_retry_ready(execution_arn, operation_id): 

1006 self._schedule_resume(execution_arn) 

1007 

1008 self._scheduler.call_later( 

1009 retry_handler, delay=delay, completion_event=self._completion_event 

1010 ) 

1011 

1012 def schedule_callback_timeouts( 

1013 self, 

1014 execution_arn: str, 

1015 callback_options: CallbackOptions | None, 

1016 callback_id: str, 

1017 ) -> None: 

1018 """Schedule callback timeout and heartbeat timeout if configured.""" 

1019 self._schedule_callback_timeouts(execution_arn, callback_options, callback_id) 

1020 

1021 def _schedule_callback_timeouts( 

1022 self, 

1023 execution_arn: str, 

1024 callback_options: CallbackOptions | None, 

1025 callback_id: str, 

1026 ) -> None: 

1027 """Schedule callback timeout and heartbeat timeout if configured.""" 

1028 try: 

1029 if not callback_options: 

1030 return 

1031 

1032 # Schedule main timeout if configured 

1033 if callback_options.timeout_seconds > 0: 

1034 timeout_delay = scale_delay( 

1035 callback_options.timeout_seconds, 

1036 minimum=_CALLBACK_TIMEOUT_MINIMUM_DELAY_SECONDS, 

1037 ) 

1038 

1039 async def timeout_handler() -> None: 

1040 self._on_callback_timeout(execution_arn, callback_id) 

1041 

1042 timeout_future = self._scheduler.call_later( 

1043 timeout_handler, 

1044 delay=timeout_delay, 

1045 completion_event=self._completion_event, 

1046 ) 

1047 self._callback_timeouts[callback_id] = timeout_future 

1048 

1049 # Schedule heartbeat timeout if configured 

1050 if callback_options.heartbeat_timeout_seconds > 0: 

1051 heartbeat_delay = scale_delay( 

1052 callback_options.heartbeat_timeout_seconds, 

1053 minimum=_CALLBACK_TIMEOUT_MINIMUM_DELAY_SECONDS, 

1054 ) 

1055 

1056 async def heartbeat_timeout_handler() -> None: 

1057 self._on_callback_heartbeat_timeout(execution_arn, callback_id) 

1058 

1059 heartbeat_future = self._scheduler.call_later( 

1060 heartbeat_timeout_handler, 

1061 delay=heartbeat_delay, 

1062 completion_event=self._completion_event, 

1063 ) 

1064 self._callback_heartbeats[callback_id] = heartbeat_future 

1065 

1066 except Exception: 

1067 logger.exception( 

1068 "[%s] Error scheduling callback timeouts for %s", 

1069 execution_arn, 

1070 callback_id, 

1071 ) 

1072 

1073 def _reset_callback_heartbeat_timeout( 

1074 self, callback_id: str, execution_arn: str 

1075 ) -> None: 

1076 """Reset the heartbeat timeout for a callback.""" 

1077 # Cancel existing heartbeat timeout 

1078 if heartbeat_future := self._callback_heartbeats.pop(callback_id, None): 

1079 heartbeat_future.cancel() 

1080 

1081 # Find callback options to reschedule heartbeat timeout 

1082 try: 

1083 callback_token = CallbackToken.from_str(callback_id) 

1084 execution = self.get_execution(callback_token.execution_arn) 

1085 

1086 callback_options = None 

1087 for update in execution.updates: 

1088 if ( 

1089 update.operation_id == callback_token.operation_id 

1090 and update.callback_options 

1091 and update.action.value == "START" 

1092 ): 

1093 callback_options = update.callback_options 

1094 break 

1095 

1096 if callback_options and callback_options.heartbeat_timeout_seconds > 0: 

1097 heartbeat_delay = scale_delay( 

1098 callback_options.heartbeat_timeout_seconds, 

1099 minimum=_CALLBACK_TIMEOUT_MINIMUM_DELAY_SECONDS, 

1100 ) 

1101 

1102 async def heartbeat_timeout_handler() -> None: 

1103 self._on_callback_heartbeat_timeout(execution_arn, callback_id) 

1104 

1105 heartbeat_future = self._scheduler.call_later( 

1106 heartbeat_timeout_handler, 

1107 delay=heartbeat_delay, 

1108 completion_event=self._completion_event, 

1109 ) 

1110 self._callback_heartbeats[callback_id] = heartbeat_future 

1111 

1112 except Exception: 

1113 logger.exception( 

1114 "[%s] Error resetting callback heartbeat timeout for %s", 

1115 execution_arn, 

1116 callback_id, 

1117 ) 

1118 

1119 def _cleanup_callback_timeouts(self, callback_id: str) -> None: 

1120 """Clean up timeout events for a completed callback.""" 

1121 # Clean up main timeout 

1122 if timeout_future := self._callback_timeouts.pop(callback_id, None): 

1123 timeout_future.cancel() 

1124 

1125 # Clean up heartbeat timeout 

1126 if heartbeat_future := self._callback_heartbeats.pop(callback_id, None): 

1127 heartbeat_future.cancel() 

1128 

1129 def _complete_callback_timeout( 

1130 self, 

1131 execution_arn: str, 

1132 callback_id: str, 

1133 timeout_type: CallbackTimeoutType, 

1134 ) -> bool: 

1135 """Complete a callback with a timeout if the execution is still active.""" 

1136 try: 

1137 callback_token = CallbackToken.from_str(callback_id) 

1138 execution = self.get_execution(callback_token.execution_arn) 

1139 

1140 if execution.is_complete: 

1141 return False 

1142 

1143 timeout_label = ( 

1144 "Callback heartbeat timed out" 

1145 if timeout_type is CallbackTimeoutType.HEARTBEAT 

1146 else "Callback timed out" 

1147 ) 

1148 timeout_error = ErrorObject.from_message( 

1149 f"{timeout_label}: {timeout_type.value}" 

1150 ) 

1151 execution.complete_callback_timeout(callback_id, timeout_error) 

1152 self._update_execution(execution) 

1153 logger.warning("[%s] %s %s", execution_arn, timeout_label, callback_id) 

1154 return True 

1155 except Exception: 

1156 logger.exception( 

1157 "[%s] Error processing callback timeout for %s", 

1158 execution_arn, 

1159 callback_id, 

1160 ) 

1161 return False 

1162 

1163 def _on_callback_timeout(self, execution_arn: str, callback_id: str) -> None: 

1164 """Handle callback timeout.""" 

1165 if self._defer_callback_timeout_if_active( 

1166 execution_arn, callback_id, CallbackTimeoutType.TIMEOUT 

1167 ): 

1168 return 

1169 if self._complete_callback_timeout( 

1170 execution_arn, callback_id, CallbackTimeoutType.TIMEOUT 

1171 ): 

1172 self._schedule_resume(execution_arn) 

1173 

1174 def _on_callback_heartbeat_timeout( 

1175 self, execution_arn: str, callback_id: str 

1176 ) -> None: 

1177 """Handle callback heartbeat timeout.""" 

1178 if self._defer_callback_timeout_if_active( 

1179 execution_arn, callback_id, CallbackTimeoutType.HEARTBEAT 

1180 ): 

1181 return 

1182 if self._complete_callback_timeout( 

1183 execution_arn, callback_id, CallbackTimeoutType.HEARTBEAT 

1184 ): 

1185 self._schedule_resume(execution_arn)