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

181 statements  

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

1from __future__ import annotations 

2 

3from dataclasses import replace 

4from datetime import datetime, timezone 

5from enum import Enum 

6from typing import Any 

7from uuid import uuid4 

8 

9from ...execution import ( 

10 DurableExecutionInvocationOutput, 

11 InvocationStatus, 

12) 

13from ...models import ( 

14 ErrorObject, 

15 ExecutionDetails, 

16 Operation, 

17 OperationStatus, 

18 OperationType, 

19 OperationUpdate, 

20) 

21from ..exceptions import ( 

22 IllegalStateException, 

23 InvalidParameterValueException, 

24) 

25 

26# Import AWS exceptions 

27from ..model import ( 

28 InvocationCompletedDetails, 

29) 

30from .model import ( 

31 StartDurableExecutionInput, 

32 CheckpointToken, 

33) 

34 

35 

36class ExecutionStatus(Enum): 

37 """Execution status for API responses.""" 

38 

39 RUNNING = "RUNNING" 

40 SUCCEEDED = "SUCCEEDED" 

41 FAILED = "FAILED" 

42 STOPPED = "STOPPED" 

43 TIMED_OUT = "TIMED_OUT" 

44 

45 

46class Execution: 

47 """Execution state.""" 

48 

49 def __init__( 

50 self, 

51 durable_execution_arn: str, 

52 start_input: StartDurableExecutionInput, 

53 operations: list[Operation], 

54 ): 

55 self.durable_execution_arn: str = durable_execution_arn 

56 # operation is frozen, it won't mutate - no need to clone/deep-copy 

57 self.start_input: StartDurableExecutionInput = start_input 

58 self.operations: list[Operation] = operations 

59 self.updates: list[OperationUpdate] = [] 

60 self.invocation_completions: list[InvocationCompletedDetails] = [] 

61 self.used_tokens: set[str] = set() 

62 # TODO: this will need to persist/rehydrate depending on inmemory vs sqllite store 

63 self._token_sequence: int = 0 

64 self.is_complete: bool = False 

65 self.result: DurableExecutionInvocationOutput | None = None 

66 self.consecutive_failed_invocation_attempts: int = 0 

67 self.close_status: ExecutionStatus | None = None 

68 

69 @property 

70 def token_sequence(self) -> int: 

71 """Get current token sequence value.""" 

72 return self._token_sequence 

73 

74 def current_status(self) -> ExecutionStatus: 

75 """Get execution status.""" 

76 if not self.is_complete: 

77 return ExecutionStatus.RUNNING 

78 

79 if not self.close_status: 

80 msg: str = "close_status must be set when execution is complete" 

81 raise IllegalStateException(msg) 

82 

83 return self.close_status 

84 

85 @staticmethod 

86 def new(input: StartDurableExecutionInput) -> Execution: # noqa: A002 

87 # make a nicer arn 

88 # Pattern: arn:(aws[a-zA-Z-]*)?:lambda:[a-z]{2}(-gov)?-[a-z]+-\d{1}:\d{12}:durable-execution:[a-zA-Z0-9-_\.]+:[a-zA-Z0-9-_\.]+:[a-zA-Z0-9-_\.]+ 

89 # Example: arn:aws:lambda:us-east-1:123456789012:durable-execution:myDurableFunction:myDurableExecutionName:ce67da72-3701-4f83-9174-f4189d27b0a5 

90 return Execution( 

91 durable_execution_arn=str(uuid4()) 

92 + "/" 

93 + (input.invocation_id or str(uuid4())), 

94 start_input=input, 

95 operations=[], 

96 ) 

97 

98 def to_json_dict(self) -> dict[str, Any]: 

99 """Serialize execution to JSON-serializable dictionary""" 

100 return { 

101 "DurableExecutionArn": self.durable_execution_arn, 

102 "StartInput": self.start_input.to_dict(), 

103 "Operations": [op.to_json_dict() for op in self.operations], 

104 "Updates": [update.to_dict() for update in self.updates], 

105 "InvocationCompletions": [ 

106 completion.to_json_dict() for completion in self.invocation_completions 

107 ], 

108 "UsedTokens": list(self.used_tokens), 

109 "TokenSequence": self._token_sequence, 

110 "IsComplete": self.is_complete, 

111 "Result": self.result.to_dict() if self.result else None, 

112 "ConsecutiveFailedInvocationAttempts": self.consecutive_failed_invocation_attempts, 

113 "CloseStatus": self.close_status.value if self.close_status else None, 

114 } 

115 

116 @classmethod 

117 def from_json_dict(cls, data: dict[str, Any]) -> Execution: 

118 """Deserialize execution from dictionary.""" 

119 # Reconstruct start_input 

120 start_input = StartDurableExecutionInput.from_dict(data["StartInput"]) 

121 

122 # Reconstruct operations 

123 operations = [ 

124 Operation.from_json_dict(op_data) for op_data in data["Operations"] 

125 ] 

126 

127 # Create execution 

128 execution = cls( 

129 durable_execution_arn=data["DurableExecutionArn"], 

130 start_input=start_input, 

131 operations=operations, 

132 ) 

133 

134 # Set additional fields 

135 execution.updates = [ 

136 OperationUpdate.from_dict(update_data) for update_data in data["Updates"] 

137 ] 

138 execution.invocation_completions = [ 

139 InvocationCompletedDetails.from_json_dict(item) 

140 for item in data.get("InvocationCompletions", []) 

141 ] 

142 execution.used_tokens = set(data["UsedTokens"]) 

143 execution._token_sequence = data["TokenSequence"] 

144 execution.is_complete = data["IsComplete"] 

145 execution.result = ( 

146 DurableExecutionInvocationOutput.from_dict(data["Result"]) 

147 if data["Result"] 

148 else None 

149 ) 

150 execution.consecutive_failed_invocation_attempts = data[ 

151 "ConsecutiveFailedInvocationAttempts" 

152 ] 

153 close_status_str = data.get("CloseStatus") 

154 execution.close_status = ( 

155 ExecutionStatus(close_status_str) if close_status_str else None 

156 ) 

157 

158 return execution 

159 

160 def start(self) -> None: 

161 if self.start_input.invocation_id is None: 

162 msg: str = "invocation_id is required" 

163 raise InvalidParameterValueException(msg) 

164 self.operations.append( 

165 Operation( 

166 operation_id=self.start_input.invocation_id, 

167 parent_id=None, 

168 name=self.start_input.execution_name, 

169 start_timestamp=datetime.now(timezone.utc), 

170 operation_type=OperationType.EXECUTION, 

171 status=OperationStatus.STARTED, 

172 execution_details=ExecutionDetails( 

173 input_payload=self.start_input.get_normalized_input() 

174 ), 

175 ) 

176 ) 

177 

178 def get_operation_execution_started(self) -> Operation: 

179 if not self.operations: 

180 msg: str = "execution not started." 

181 

182 raise IllegalStateException(msg) 

183 

184 return self.operations[0] 

185 

186 def get_new_checkpoint_token(self) -> str: 

187 """Generate a new checkpoint token with incremented sequence""" 

188 self._token_sequence += 1 

189 new_token_sequence = self._token_sequence 

190 token = CheckpointToken( 

191 execution_arn=self.durable_execution_arn, 

192 token_sequence=new_token_sequence, 

193 ) 

194 token_str = token.to_str() 

195 self.used_tokens.add(token_str) 

196 return token_str 

197 

198 def get_navigable_operations(self) -> list[Operation]: 

199 """Get list of operations, but exclude child operations where the parent has already completed.""" 

200 return self.operations 

201 

202 def get_assertable_operations(self) -> list[Operation]: 

203 """Get list of operations, but exclude the EXECUTION operations""" 

204 # TODO: this excludes EXECUTION at start, but can there be an EXECUTION at the end if there was a checkpoint with large payload? 

205 return self.operations[1:] 

206 

207 def has_pending_operations(self) -> bool: 

208 """True if execution has pending operations.""" 

209 

210 for operation in self.operations: 

211 if ( 

212 operation.operation_type == OperationType.STEP 

213 and operation.status == OperationStatus.PENDING 

214 ) or ( 

215 operation.operation_type 

216 in [ 

217 OperationType.WAIT, 

218 OperationType.CALLBACK, 

219 OperationType.CHAINED_INVOKE, 

220 ] 

221 and operation.status == OperationStatus.STARTED 

222 ): 

223 return True 

224 return False 

225 

226 def record_invocation_completion( 

227 self, start_timestamp: datetime, end_timestamp: datetime, request_id: str 

228 ) -> None: 

229 """Record an invocation completion event.""" 

230 self.invocation_completions.append( 

231 InvocationCompletedDetails( 

232 start_timestamp=start_timestamp, 

233 end_timestamp=end_timestamp, 

234 request_id=request_id, 

235 ) 

236 ) 

237 

238 def complete_success(self, result: str | None) -> None: 

239 """Complete execution successfully (DecisionType.COMPLETE_WORKFLOW_EXECUTION).""" 

240 self.result = DurableExecutionInvocationOutput( 

241 status=InvocationStatus.SUCCEEDED, result=result 

242 ) 

243 self.is_complete = True 

244 self.close_status = ExecutionStatus.SUCCEEDED 

245 self._end_execution(OperationStatus.SUCCEEDED) 

246 

247 def complete_fail(self, error: ErrorObject) -> None: 

248 """Complete execution with failure (DecisionType.FAIL_WORKFLOW_EXECUTION).""" 

249 self.result = DurableExecutionInvocationOutput( 

250 status=InvocationStatus.FAILED, error=error 

251 ) 

252 self.is_complete = True 

253 self.close_status = ExecutionStatus.FAILED 

254 self._end_execution(OperationStatus.FAILED) 

255 

256 def complete_timeout(self, error: ErrorObject) -> None: 

257 """Complete execution with timeout.""" 

258 self.result = DurableExecutionInvocationOutput( 

259 status=InvocationStatus.FAILED, error=error 

260 ) 

261 self.is_complete = True 

262 self.close_status = ExecutionStatus.TIMED_OUT 

263 self._end_execution(OperationStatus.TIMED_OUT) 

264 

265 def complete_stopped(self, error: ErrorObject) -> None: 

266 """Complete execution as terminated (TerminateWorkflowExecutionV2Request).""" 

267 self.result = DurableExecutionInvocationOutput( 

268 status=InvocationStatus.FAILED, error=error 

269 ) 

270 self.is_complete = True 

271 self.close_status = ExecutionStatus.STOPPED 

272 self._end_execution(OperationStatus.STOPPED) 

273 

274 def find_operation(self, operation_id: str) -> tuple[int, Operation]: 

275 """Find operation by ID, return index and operation.""" 

276 for i, operation in enumerate(self.operations): 

277 if operation.operation_id == operation_id: 

278 return i, operation 

279 msg: str = f"Attempting to update state of an Operation [{operation_id}] that doesn't exist" 

280 raise IllegalStateException(msg) 

281 

282 def find_callback_operation(self, callback_id: str) -> tuple[int, Operation]: 

283 """Find callback operation by callback_id, return index and operation.""" 

284 for i, operation in enumerate(self.operations): 

285 if ( 

286 operation.operation_type == OperationType.CALLBACK 

287 and operation.callback_details 

288 and operation.callback_details.callback_id == callback_id 

289 ): 

290 return i, operation 

291 msg: str = f"Callback operation with callback_id [{callback_id}] not found" 

292 raise IllegalStateException(msg) 

293 

294 def complete_wait(self, operation_id: str) -> Operation: 

295 """Complete WAIT operation when timer fires.""" 

296 index, operation = self.find_operation(operation_id) 

297 

298 # Validate 

299 if operation.status != OperationStatus.STARTED: 

300 msg_wait_not_started: str = f"Attempting to transition a Wait Operation[{operation_id}] to SUCCEEDED when it's not STARTED" 

301 raise IllegalStateException(msg_wait_not_started) 

302 if operation.operation_type != OperationType.WAIT: 

303 msg_not_wait: str = ( 

304 f"Expected WAIT operation, got {operation.operation_type}" 

305 ) 

306 raise IllegalStateException(msg_not_wait) 

307 

308 self._token_sequence += 1 

309 self.operations[index] = replace( 

310 operation, 

311 status=OperationStatus.SUCCEEDED, 

312 end_timestamp=datetime.now(timezone.utc), 

313 ) 

314 return self.operations[index] 

315 

316 def complete_retry(self, operation_id: str) -> Operation: 

317 """Complete STEP retry when timer fires.""" 

318 index, operation = self.find_operation(operation_id) 

319 

320 # Validate 

321 if operation.status != OperationStatus.PENDING: 

322 msg_step_not_pending: str = f"Attempting to transition a Step Operation[{operation_id}] to READY when it's not PENDING" 

323 raise IllegalStateException(msg_step_not_pending) 

324 if operation.operation_type != OperationType.STEP: 

325 msg_not_step: str = ( 

326 f"Expected STEP operation, got {operation.operation_type}" 

327 ) 

328 raise IllegalStateException(msg_not_step) 

329 

330 self._token_sequence += 1 

331 new_step_details = None 

332 if operation.step_details: 

333 new_step_details = replace( 

334 operation.step_details, next_attempt_timestamp=None 

335 ) 

336 

337 updated_operation = replace( 

338 operation, status=OperationStatus.READY, step_details=new_step_details 

339 ) 

340 self.operations[index] = updated_operation 

341 return updated_operation 

342 

343 def complete_callback_success( 

344 self, callback_id: str, result: bytes | None = None 

345 ) -> Operation: 

346 """Complete CALLBACK operation with success.""" 

347 index, operation = self.find_callback_operation(callback_id) 

348 if operation.status != OperationStatus.STARTED: 

349 msg: str = f"Callback operation [{callback_id}] is not in STARTED state" 

350 raise IllegalStateException(msg) 

351 

352 updated_callback_details = None 

353 if operation.callback_details: 

354 updated_callback_details = replace( 

355 operation.callback_details, 

356 result=result.decode() if result else None, 

357 ) 

358 

359 self.operations[index] = replace( 

360 operation, 

361 status=OperationStatus.SUCCEEDED, 

362 end_timestamp=datetime.now(timezone.utc), 

363 callback_details=updated_callback_details, 

364 ) 

365 return self.operations[index] 

366 

367 def complete_callback_failure( 

368 self, callback_id: str, error: ErrorObject 

369 ) -> Operation: 

370 """Complete CALLBACK operation with failure.""" 

371 index, operation = self.find_callback_operation(callback_id) 

372 

373 if operation.status != OperationStatus.STARTED: 

374 msg: str = f"Callback operation [{callback_id}] is not in STARTED state" 

375 raise IllegalStateException(msg) 

376 

377 updated_callback_details = None 

378 if operation.callback_details: 

379 updated_callback_details = replace(operation.callback_details, error=error) 

380 

381 self.operations[index] = replace( 

382 operation, 

383 status=OperationStatus.FAILED, 

384 end_timestamp=datetime.now(timezone.utc), 

385 callback_details=updated_callback_details, 

386 ) 

387 return self.operations[index] 

388 

389 def complete_callback_timeout( 

390 self, callback_id: str, error: ErrorObject 

391 ) -> Operation: 

392 """Complete CALLBACK operation with timeout.""" 

393 index, operation = self.find_callback_operation(callback_id) 

394 

395 if operation.status != OperationStatus.STARTED: 

396 msg: str = f"Callback operation [{callback_id}] is not in STARTED state" 

397 raise IllegalStateException(msg) 

398 

399 self._token_sequence += 1 

400 updated_callback_details = None 

401 if operation.callback_details: 

402 updated_callback_details = replace(operation.callback_details, error=error) 

403 

404 self.operations[index] = replace( 

405 operation, 

406 status=OperationStatus.TIMED_OUT, 

407 end_timestamp=datetime.now(timezone.utc), 

408 callback_details=updated_callback_details, 

409 ) 

410 return self.operations[index] 

411 

412 def _end_execution(self, status: OperationStatus) -> None: 

413 """Set the end_timestamp on the main EXECUTION operation when execution completes.""" 

414 execution_op: Operation = self.get_operation_execution_started() 

415 if execution_op.operation_type == OperationType.EXECUTION: 

416 self.operations[0] = replace( 

417 execution_op, 

418 status=status, 

419 end_timestamp=datetime.now(timezone.utc), 

420 )