aws-durable-execution-sdk-python 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 (36) hide show
  1. aws_durable_execution_sdk_python/.gitignore +0 -0
  2. aws_durable_execution_sdk_python/__about__.py +4 -0
  3. aws_durable_execution_sdk_python/__init__.py +34 -0
  4. aws_durable_execution_sdk_python/botocore/data/lambdainternal/2015-03-31/service-2.json +7864 -0
  5. aws_durable_execution_sdk_python/concurrency/__init__.py +0 -0
  6. aws_durable_execution_sdk_python/concurrency/executor.py +436 -0
  7. aws_durable_execution_sdk_python/concurrency/models.py +469 -0
  8. aws_durable_execution_sdk_python/config.py +499 -0
  9. aws_durable_execution_sdk_python/context.py +551 -0
  10. aws_durable_execution_sdk_python/exceptions.py +374 -0
  11. aws_durable_execution_sdk_python/execution.py +428 -0
  12. aws_durable_execution_sdk_python/identifier.py +14 -0
  13. aws_durable_execution_sdk_python/lambda_service.py +1034 -0
  14. aws_durable_execution_sdk_python/logger.py +131 -0
  15. aws_durable_execution_sdk_python/operation/__init__.py +1 -0
  16. aws_durable_execution_sdk_python/operation/callback.py +123 -0
  17. aws_durable_execution_sdk_python/operation/child.py +162 -0
  18. aws_durable_execution_sdk_python/operation/invoke.py +119 -0
  19. aws_durable_execution_sdk_python/operation/map.py +137 -0
  20. aws_durable_execution_sdk_python/operation/parallel.py +122 -0
  21. aws_durable_execution_sdk_python/operation/step.py +269 -0
  22. aws_durable_execution_sdk_python/operation/wait.py +53 -0
  23. aws_durable_execution_sdk_python/operation/wait_for_condition.py +235 -0
  24. aws_durable_execution_sdk_python/py.typed +1 -0
  25. aws_durable_execution_sdk_python/retries.py +174 -0
  26. aws_durable_execution_sdk_python/serdes.py +502 -0
  27. aws_durable_execution_sdk_python/state.py +790 -0
  28. aws_durable_execution_sdk_python/suspend.py +84 -0
  29. aws_durable_execution_sdk_python/threading.py +222 -0
  30. aws_durable_execution_sdk_python/types.py +180 -0
  31. aws_durable_execution_sdk_python/waits.py +130 -0
  32. aws_durable_execution_sdk_python-1.0.0.dist-info/METADATA +679 -0
  33. aws_durable_execution_sdk_python-1.0.0.dist-info/RECORD +36 -0
  34. aws_durable_execution_sdk_python-1.0.0.dist-info/WHEEL +4 -0
  35. aws_durable_execution_sdk_python-1.0.0.dist-info/licenses/LICENSE +175 -0
  36. aws_durable_execution_sdk_python-1.0.0.dist-info/licenses/NOTICE +1 -0
@@ -0,0 +1,1034 @@
1
+ from __future__ import annotations
2
+
3
+ import datetime
4
+ import logging
5
+ import os
6
+ from dataclasses import dataclass, field
7
+ from enum import Enum
8
+ from pathlib import Path
9
+ from typing import TYPE_CHECKING, Any, Protocol, TypeAlias
10
+
11
+ import boto3 # type: ignore
12
+ from botocore.config import Config # type: ignore
13
+
14
+ from aws_durable_execution_sdk_python.exceptions import (
15
+ CallableRuntimeError,
16
+ CheckpointError,
17
+ GetExecutionStateError,
18
+ )
19
+
20
+ if TYPE_CHECKING:
21
+ from collections.abc import MutableMapping
22
+
23
+ from aws_durable_execution_sdk_python.identifier import OperationIdentifier
24
+
25
+ # Replace with `type` it when dropping support to Python 3.11
26
+ ReplayChildren: TypeAlias = bool
27
+ OperationPayload: TypeAlias = str
28
+ TimeoutSeconds: TypeAlias = int
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+
33
+ # region model
34
+ class OperationAction(Enum):
35
+ START = "START"
36
+ SUCCEED = "SUCCEED"
37
+ FAIL = "FAIL"
38
+ RETRY = "RETRY"
39
+ CANCEL = "CANCEL"
40
+
41
+
42
+ class OperationStatus(Enum):
43
+ STARTED = "STARTED"
44
+ PENDING = "PENDING"
45
+ READY = "READY"
46
+ SUCCEEDED = "SUCCEEDED"
47
+ FAILED = "FAILED"
48
+ CANCELLED = "CANCELLED"
49
+ TIMED_OUT = "TIMED_OUT"
50
+ STOPPED = "STOPPED"
51
+
52
+
53
+ class OperationType(Enum):
54
+ EXECUTION = "EXECUTION"
55
+ CONTEXT = "CONTEXT"
56
+ STEP = "STEP"
57
+ WAIT = "WAIT"
58
+ CALLBACK = "CALLBACK"
59
+ CHAINED_INVOKE = "CHAINED_INVOKE"
60
+
61
+
62
+ class CallbackTimeoutType(Enum):
63
+ TIMEOUT = "Callback.Timeout"
64
+ HEARTBEAT = "Callback.Heartbeat"
65
+
66
+
67
+ class ChainedInvokeFailedToStartType(Enum):
68
+ FAILED_TO_START = "ChainedInvoke.FailedToStart"
69
+
70
+
71
+ class ChainedInvokeTimeoutType(Enum):
72
+ TIMEOUT = "ChainedInvoke.Timeout"
73
+
74
+
75
+ class ChainedInvokeStopType(Enum):
76
+ STOPPED = "ChainedInvoke.Stopped"
77
+
78
+
79
+ class OperationSubType(Enum):
80
+ STEP = "Step"
81
+ WAIT = "Wait"
82
+ CALLBACK = "Callback"
83
+ RUN_IN_CHILD_CONTEXT = "RunInChildContext"
84
+ MAP = "Map"
85
+ MAP_ITERATION = "MapIteration"
86
+ PARALLEL = "Parallel"
87
+ PARALLEL_BRANCH = "ParallelBranch"
88
+ WAIT_FOR_CALLBACK = "WaitForCallback"
89
+ WAIT_FOR_CONDITION = "WaitForCondition"
90
+ CHAINED_INVOKE = "ChainedInvoke"
91
+
92
+
93
+ @dataclass(frozen=True)
94
+ class ExecutionDetails:
95
+ input_payload: str | None = None
96
+
97
+ @classmethod
98
+ def from_dict(cls, data: MutableMapping[str, Any]) -> ExecutionDetails:
99
+ return cls(input_payload=data.get("InputPayload"))
100
+
101
+
102
+ @dataclass(frozen=True)
103
+ class ContextDetails:
104
+ replay_children: ReplayChildren = False
105
+ result: OperationPayload | None = None
106
+ error: ErrorObject | None = None
107
+
108
+ @classmethod
109
+ def from_dict(cls, data: MutableMapping[str, Any]) -> ContextDetails:
110
+ error_raw = data.get("Error")
111
+ return cls(
112
+ replay_children=data.get("ReplayChildren", False),
113
+ result=data.get("Result"),
114
+ error=ErrorObject.from_dict(error_raw) if error_raw else None,
115
+ )
116
+
117
+
118
+ @dataclass(frozen=True)
119
+ class ErrorObject:
120
+ message: str | None
121
+ type: str | None
122
+ data: str | None
123
+ stack_trace: list[str] | None
124
+
125
+ @classmethod
126
+ def from_dict(cls, data: MutableMapping[str, Any]) -> ErrorObject:
127
+ return cls(
128
+ message=data.get("ErrorMessage"),
129
+ type=data.get("ErrorType"),
130
+ data=data.get("ErrorData"),
131
+ stack_trace=data.get("StackTrace"),
132
+ )
133
+
134
+ @classmethod
135
+ def from_exception(cls, exception: Exception) -> ErrorObject:
136
+ return cls(
137
+ message=str(exception),
138
+ type=type(exception).__name__,
139
+ data=None,
140
+ stack_trace=None,
141
+ )
142
+
143
+ @classmethod
144
+ def from_message(cls, message: str) -> ErrorObject:
145
+ return cls(
146
+ message=message,
147
+ type=None,
148
+ data=None,
149
+ stack_trace=None,
150
+ )
151
+
152
+ def to_dict(self) -> MutableMapping[str, Any]:
153
+ result: MutableMapping[str, Any] = {}
154
+ if self.message is not None:
155
+ result["ErrorMessage"] = self.message
156
+ if self.type is not None:
157
+ result["ErrorType"] = self.type
158
+ if self.data is not None:
159
+ result["ErrorData"] = self.data
160
+ if self.stack_trace is not None:
161
+ result["StackTrace"] = self.stack_trace
162
+ return result
163
+
164
+ def to_callable_runtime_error(self) -> CallableRuntimeError:
165
+ return CallableRuntimeError(
166
+ message=self.message,
167
+ error_type=self.type,
168
+ data=self.data,
169
+ stack_trace=self.stack_trace,
170
+ )
171
+
172
+
173
+ @dataclass(frozen=True)
174
+ class StepDetails:
175
+ attempt: int = 0
176
+ next_attempt_timestamp: datetime.datetime | None = None
177
+ result: OperationPayload | None = None
178
+ error: ErrorObject | None = None
179
+
180
+ @classmethod
181
+ def from_dict(cls, data: MutableMapping[str, Any]) -> StepDetails:
182
+ error_raw = data.get("Error")
183
+ return cls(
184
+ attempt=data.get("Attempt", 0),
185
+ next_attempt_timestamp=data.get("NextAttemptTimestamp"),
186
+ result=data.get("Result"),
187
+ error=ErrorObject.from_dict(error_raw) if error_raw else None,
188
+ )
189
+
190
+
191
+ @dataclass(frozen=True)
192
+ class WaitDetails:
193
+ scheduled_end_timestamp: datetime.datetime | None = None
194
+
195
+ @classmethod
196
+ def from_dict(cls, data: MutableMapping[str, Any]) -> WaitDetails:
197
+ return cls(scheduled_end_timestamp=data.get("ScheduledEndTimestamp"))
198
+
199
+
200
+ @dataclass(frozen=True)
201
+ class CallbackDetails:
202
+ callback_id: str
203
+ result: str | None = None
204
+ error: ErrorObject | None = None
205
+
206
+ @classmethod
207
+ def from_dict(cls, data: MutableMapping[str, Any]) -> CallbackDetails:
208
+ error_raw = data.get("Error")
209
+ return cls(
210
+ callback_id=data["CallbackId"],
211
+ result=data.get("Result"),
212
+ error=ErrorObject.from_dict(error_raw) if error_raw else None,
213
+ )
214
+
215
+
216
+ @dataclass(frozen=True)
217
+ class ChainedInvokeDetails:
218
+ result: str | None = None
219
+ error: ErrorObject | None = None
220
+
221
+ @classmethod
222
+ def from_dict(cls, data: MutableMapping[str, Any]) -> ChainedInvokeDetails:
223
+ error_raw = data.get("Error")
224
+ return cls(
225
+ result=data.get("Result"),
226
+ error=ErrorObject.from_dict(error_raw) if error_raw else None,
227
+ )
228
+
229
+
230
+ @dataclass(frozen=True)
231
+ class StepOptions:
232
+ next_attempt_delay_seconds: int = 0
233
+
234
+ @classmethod
235
+ def from_dict(cls, data: MutableMapping[str, Any]) -> StepOptions:
236
+ return cls(next_attempt_delay_seconds=data.get("NextAttemptDelaySeconds", 0))
237
+
238
+ def to_dict(self) -> MutableMapping[str, Any]:
239
+ return {
240
+ "NextAttemptDelaySeconds": self.next_attempt_delay_seconds,
241
+ }
242
+
243
+
244
+ @dataclass(frozen=True)
245
+ class WaitOptions:
246
+ """
247
+ Wait Options provides details regarding suspension.
248
+
249
+ As of 2025/10/27:
250
+
251
+ - `wait_seconds` accepts values between 1, and 31622400
252
+ - When wait_second seconds does not exist,then we default to 1
253
+
254
+ """
255
+
256
+ wait_seconds: int = 1
257
+
258
+ @classmethod
259
+ def from_dict(cls, data: MutableMapping[str, Any]) -> WaitOptions:
260
+ return cls(wait_seconds=data.get("WaitSeconds", 1))
261
+
262
+ def to_dict(self) -> MutableMapping[str, Any]:
263
+ return {"WaitSeconds": self.wait_seconds}
264
+
265
+
266
+ @dataclass(frozen=True)
267
+ class CallbackOptions:
268
+ """
269
+ Callback options provides details about the callback, wrt timeout
270
+ and heartbeat checks.
271
+
272
+ As of 2025/10/27:
273
+ - When timeout_seconds == 0, then the callback has no timeout
274
+ - When heartbeat_timeout_seconds == 0, then the callback has no timeout
275
+
276
+ - When timeout_seconds is not present, then default is 0
277
+ - When heartbeat_timeout_seconds, then default is 0
278
+
279
+ """
280
+
281
+ timeout_seconds: TimeoutSeconds = 0
282
+ heartbeat_timeout_seconds: int = 0
283
+
284
+ @classmethod
285
+ def from_dict(cls, data: MutableMapping[str, Any]) -> CallbackOptions:
286
+ return cls(
287
+ timeout_seconds=data.get("TimeoutSeconds", 0),
288
+ heartbeat_timeout_seconds=data.get("HeartbeatTimeoutSeconds", 0),
289
+ )
290
+
291
+ def to_dict(self) -> MutableMapping[str, Any]:
292
+ return {
293
+ "TimeoutSeconds": self.timeout_seconds,
294
+ "HeartbeatTimeoutSeconds": self.heartbeat_timeout_seconds,
295
+ }
296
+
297
+
298
+ @dataclass(frozen=True)
299
+ class ChainedInvokeOptions:
300
+ """
301
+ As of 2025/10/27:
302
+ - Chained invoke options only contains a function name
303
+ """
304
+
305
+ function_name: str
306
+ tenant_id: str | None = None
307
+
308
+ @classmethod
309
+ def from_dict(cls, data: MutableMapping[str, Any]) -> ChainedInvokeOptions:
310
+ return cls(
311
+ function_name=data["FunctionName"],
312
+ tenant_id=data.get("TenantId"),
313
+ )
314
+
315
+ def to_dict(self) -> MutableMapping[str, Any]:
316
+ result: MutableMapping[str, Any] = {
317
+ "FunctionName": self.function_name,
318
+ }
319
+ if self.tenant_id is not None:
320
+ result["TenantId"] = self.tenant_id
321
+
322
+ return result
323
+
324
+
325
+ @dataclass(frozen=True)
326
+ class ContextOptions:
327
+ replay_children: ReplayChildren = False
328
+
329
+ @classmethod
330
+ def from_dict(cls, data: MutableMapping[str, Any]) -> ContextOptions:
331
+ return cls(replay_children=data.get("ReplayChildren", False))
332
+
333
+ def to_dict(self) -> MutableMapping[str, Any]:
334
+ return {"ReplayChildren": self.replay_children}
335
+
336
+
337
+ @dataclass(frozen=True)
338
+ class OperationUpdate:
339
+ """Update an Operation. Use this to create a checkpoint.
340
+
341
+ See the various create_ factory class methods to instantiate me.
342
+ """
343
+
344
+ operation_id: str
345
+ operation_type: OperationType
346
+ action: OperationAction
347
+ parent_id: str | None = None
348
+ name: str | None = None
349
+ sub_type: OperationSubType | None = None
350
+ payload: str | None = None
351
+ error: ErrorObject | None = None
352
+ context_options: ContextOptions | None = None
353
+ step_options: StepOptions | None = None
354
+ wait_options: WaitOptions | None = None
355
+ callback_options: CallbackOptions | None = None
356
+ chained_invoke_options: ChainedInvokeOptions | None = None
357
+
358
+ def to_dict(self) -> MutableMapping[str, Any]:
359
+ result: MutableMapping[str, Any] = {
360
+ "Id": self.operation_id,
361
+ "Type": self.operation_type.value,
362
+ "Action": self.action.value,
363
+ }
364
+
365
+ if self.parent_id:
366
+ result["ParentId"] = self.parent_id
367
+ if self.name:
368
+ result["Name"] = self.name
369
+ if self.sub_type:
370
+ result["SubType"] = self.sub_type.value
371
+ if self.payload:
372
+ result["Payload"] = self.payload
373
+ if self.error:
374
+ result["Error"] = self.error.to_dict()
375
+ if self.context_options:
376
+ result["ContextOptions"] = self.context_options.to_dict()
377
+ if self.step_options:
378
+ result["StepOptions"] = self.step_options.to_dict()
379
+ if self.wait_options:
380
+ result["WaitOptions"] = self.wait_options.to_dict()
381
+ if self.callback_options:
382
+ result["CallbackOptions"] = self.callback_options.to_dict()
383
+ if self.chained_invoke_options:
384
+ result["ChainedInvokeOptions"] = self.chained_invoke_options.to_dict()
385
+
386
+ return result
387
+
388
+ @classmethod
389
+ def from_dict(cls, data: MutableMapping[str, Any]) -> OperationUpdate:
390
+ """Create OperationUpdate from dictionary data."""
391
+ error = ErrorObject.from_dict(data["Error"]) if data.get("Error") else None
392
+
393
+ context_options = None
394
+ if context_data := data.get("ContextOptions"):
395
+ context_options = ContextOptions.from_dict(context_data)
396
+
397
+ step_options = None
398
+ if step_data := data.get("StepOptions"):
399
+ step_options = StepOptions.from_dict(step_data)
400
+
401
+ wait_options = None
402
+ if wait_data := data.get("WaitOptions"):
403
+ wait_options = WaitOptions.from_dict(wait_data)
404
+
405
+ callback_options = None
406
+ if callback_data := data.get("CallbackOptions"):
407
+ callback_options = CallbackOptions.from_dict(callback_data)
408
+
409
+ chained_invoke_options = None
410
+ if invoke_data := data.get("ChainedInvokeOptions"):
411
+ chained_invoke_options = ChainedInvokeOptions.from_dict(invoke_data)
412
+
413
+ return cls(
414
+ operation_id=data["Id"],
415
+ operation_type=OperationType(data["Type"]),
416
+ action=OperationAction(data["Action"]),
417
+ parent_id=data.get("ParentId"),
418
+ name=data.get("Name"),
419
+ sub_type=OperationSubType(data["SubType"]) if data.get("SubType") else None,
420
+ payload=data.get("Payload"),
421
+ error=error,
422
+ context_options=context_options,
423
+ step_options=step_options,
424
+ wait_options=wait_options,
425
+ callback_options=callback_options,
426
+ chained_invoke_options=chained_invoke_options,
427
+ )
428
+
429
+ @classmethod
430
+ def create_callback(
431
+ cls, identifier: OperationIdentifier, callback_options: CallbackOptions
432
+ ) -> OperationUpdate:
433
+ """Create an instance of OperationUpdate for type:CALLBACK, action:START"""
434
+ return cls(
435
+ operation_id=identifier.operation_id,
436
+ parent_id=identifier.parent_id,
437
+ operation_type=OperationType.CALLBACK,
438
+ sub_type=OperationSubType.CALLBACK,
439
+ action=OperationAction.START,
440
+ name=identifier.name,
441
+ callback_options=callback_options,
442
+ )
443
+
444
+ # region context
445
+ @classmethod
446
+ def create_context_start(
447
+ cls, identifier: OperationIdentifier, sub_type: OperationSubType
448
+ ) -> OperationUpdate:
449
+ """Create an instance of OperationUpdate for type: CONTEXT, action: START."""
450
+ return cls(
451
+ operation_id=identifier.operation_id,
452
+ parent_id=identifier.parent_id,
453
+ operation_type=OperationType.CONTEXT,
454
+ sub_type=sub_type,
455
+ action=OperationAction.START,
456
+ name=identifier.name,
457
+ )
458
+
459
+ @classmethod
460
+ def create_context_succeed(
461
+ cls,
462
+ identifier: OperationIdentifier,
463
+ payload: str,
464
+ sub_type: OperationSubType,
465
+ context_options: ContextOptions | None = None,
466
+ ) -> OperationUpdate:
467
+ """Create an instance of OperationUpdate for type: CONTEXT, action: SUCCEED."""
468
+ return cls(
469
+ operation_id=identifier.operation_id,
470
+ parent_id=identifier.parent_id,
471
+ operation_type=OperationType.CONTEXT,
472
+ sub_type=sub_type,
473
+ action=OperationAction.SUCCEED,
474
+ name=identifier.name,
475
+ payload=payload,
476
+ context_options=context_options,
477
+ )
478
+
479
+ @classmethod
480
+ def create_context_fail(
481
+ cls,
482
+ identifier: OperationIdentifier,
483
+ error: ErrorObject,
484
+ sub_type: OperationSubType,
485
+ ) -> OperationUpdate:
486
+ """Create an instance of OperationUpdate for type: CONTEXT, action: FAIL."""
487
+ return cls(
488
+ operation_id=identifier.operation_id,
489
+ parent_id=identifier.parent_id,
490
+ operation_type=OperationType.CONTEXT,
491
+ sub_type=sub_type,
492
+ action=OperationAction.FAIL,
493
+ name=identifier.name,
494
+ error=error,
495
+ )
496
+
497
+ # endregion context
498
+
499
+ # region execution
500
+ @classmethod
501
+ def create_execution_succeed(cls, payload: str) -> OperationUpdate:
502
+ """Create an instance of OperationUpdate for type: EXECUTION, action: SUCCEED."""
503
+ return cls(
504
+ operation_id=f"execution-result-{int(datetime.datetime.now(tz=datetime.UTC).timestamp() * 1000)}",
505
+ operation_type=OperationType.EXECUTION,
506
+ action=OperationAction.SUCCEED,
507
+ payload=payload,
508
+ )
509
+
510
+ @classmethod
511
+ def create_execution_fail(cls, error: ErrorObject) -> OperationUpdate:
512
+ """Create an instance of OperationUpdate for type: EXECUTION, action: FAIL."""
513
+ return cls(
514
+ operation_id=f"execution-result-{int(datetime.datetime.now(tz=datetime.UTC).timestamp() * 1000)}",
515
+ operation_type=OperationType.EXECUTION,
516
+ action=OperationAction.FAIL,
517
+ error=error,
518
+ )
519
+
520
+ # endregion execution
521
+
522
+ # region step
523
+ @classmethod
524
+ def create_step_succeed(
525
+ cls, identifier: OperationIdentifier, payload: str
526
+ ) -> OperationUpdate:
527
+ """Create an instance of OperationUpdate for type: STEP, action: SUCCEED."""
528
+ return cls(
529
+ operation_id=identifier.operation_id,
530
+ parent_id=identifier.parent_id,
531
+ operation_type=OperationType.STEP,
532
+ sub_type=OperationSubType.STEP,
533
+ action=OperationAction.SUCCEED,
534
+ name=identifier.name,
535
+ payload=payload,
536
+ )
537
+
538
+ @classmethod
539
+ def create_step_fail(
540
+ cls, identifier: OperationIdentifier, error: ErrorObject
541
+ ) -> OperationUpdate:
542
+ """Create an instance of OperationUpdate for type: STEP, action: FAIL."""
543
+ return cls(
544
+ operation_id=identifier.operation_id,
545
+ parent_id=identifier.parent_id,
546
+ operation_type=OperationType.STEP,
547
+ sub_type=OperationSubType.STEP,
548
+ action=OperationAction.FAIL,
549
+ name=identifier.name,
550
+ error=error,
551
+ )
552
+
553
+ @classmethod
554
+ def create_step_start(cls, identifier: OperationIdentifier) -> OperationUpdate:
555
+ """Create an instance of OperationUpdate for type: STEP, action: START."""
556
+ return cls(
557
+ operation_id=identifier.operation_id,
558
+ parent_id=identifier.parent_id,
559
+ operation_type=OperationType.STEP,
560
+ sub_type=OperationSubType.STEP,
561
+ action=OperationAction.START,
562
+ name=identifier.name,
563
+ )
564
+
565
+ @classmethod
566
+ def create_step_retry(
567
+ cls,
568
+ identifier: OperationIdentifier,
569
+ error: ErrorObject,
570
+ next_attempt_delay_seconds: int,
571
+ ) -> OperationUpdate:
572
+ """Create an instance of OperationUpdate for type: STEP, action: RETRY."""
573
+ return cls(
574
+ operation_id=identifier.operation_id,
575
+ parent_id=identifier.parent_id,
576
+ operation_type=OperationType.STEP,
577
+ sub_type=OperationSubType.STEP,
578
+ action=OperationAction.RETRY,
579
+ name=identifier.name,
580
+ error=error,
581
+ step_options=StepOptions(
582
+ next_attempt_delay_seconds=next_attempt_delay_seconds
583
+ ),
584
+ )
585
+
586
+ # endregion step
587
+
588
+ # region invoke
589
+ @classmethod
590
+ def create_invoke_start(
591
+ cls,
592
+ identifier: OperationIdentifier,
593
+ payload: str,
594
+ chained_invoke_options: ChainedInvokeOptions,
595
+ ) -> OperationUpdate:
596
+ """Create an instance of OperationUpdate for type: INVOKE, action: START."""
597
+ return cls(
598
+ operation_id=identifier.operation_id,
599
+ parent_id=identifier.parent_id,
600
+ operation_type=OperationType.CHAINED_INVOKE,
601
+ sub_type=OperationSubType.CHAINED_INVOKE,
602
+ action=OperationAction.START,
603
+ name=identifier.name,
604
+ payload=payload,
605
+ chained_invoke_options=chained_invoke_options,
606
+ )
607
+
608
+ # endregion invoke
609
+
610
+ # region wait for condition
611
+ @classmethod
612
+ def create_wait_for_condition_start(
613
+ cls, identifier: OperationIdentifier
614
+ ) -> OperationUpdate:
615
+ """Create an instance of OperationUpdate for type: STEP, action: START."""
616
+ return cls(
617
+ operation_id=identifier.operation_id,
618
+ parent_id=identifier.parent_id,
619
+ operation_type=OperationType.STEP,
620
+ sub_type=OperationSubType.WAIT_FOR_CONDITION,
621
+ action=OperationAction.START,
622
+ name=identifier.name,
623
+ )
624
+
625
+ @classmethod
626
+ def create_wait_for_condition_succeed(
627
+ cls, identifier: OperationIdentifier, payload: str
628
+ ) -> OperationUpdate:
629
+ """Create an instance of OperationUpdate for type: STEP, action: SUCCEED."""
630
+ return cls(
631
+ operation_id=identifier.operation_id,
632
+ parent_id=identifier.parent_id,
633
+ operation_type=OperationType.STEP,
634
+ sub_type=OperationSubType.WAIT_FOR_CONDITION,
635
+ action=OperationAction.SUCCEED,
636
+ name=identifier.name,
637
+ payload=payload,
638
+ )
639
+
640
+ @classmethod
641
+ def create_wait_for_condition_retry(
642
+ cls,
643
+ identifier: OperationIdentifier,
644
+ payload: str,
645
+ next_attempt_delay_seconds: int,
646
+ ) -> OperationUpdate:
647
+ """Create an instance of OperationUpdate for type: STEP, action: RETRY."""
648
+ return cls(
649
+ operation_id=identifier.operation_id,
650
+ parent_id=identifier.parent_id,
651
+ operation_type=OperationType.STEP,
652
+ sub_type=OperationSubType.WAIT_FOR_CONDITION,
653
+ action=OperationAction.RETRY,
654
+ name=identifier.name,
655
+ payload=payload,
656
+ step_options=StepOptions(
657
+ next_attempt_delay_seconds=next_attempt_delay_seconds
658
+ ),
659
+ )
660
+
661
+ @classmethod
662
+ def create_wait_for_condition_fail(
663
+ cls, identifier: OperationIdentifier, error: ErrorObject
664
+ ) -> OperationUpdate:
665
+ """Create an instance of OperationUpdate for type: STEP, action: FAIL."""
666
+ return cls(
667
+ operation_id=identifier.operation_id,
668
+ parent_id=identifier.parent_id,
669
+ operation_type=OperationType.STEP,
670
+ sub_type=OperationSubType.WAIT_FOR_CONDITION,
671
+ action=OperationAction.FAIL,
672
+ name=identifier.name,
673
+ error=error,
674
+ )
675
+
676
+ # endregion wait for condition
677
+
678
+ # region wait
679
+ @classmethod
680
+ def create_wait_start(
681
+ cls, identifier: OperationIdentifier, wait_options: WaitOptions
682
+ ) -> OperationUpdate:
683
+ """Create an instance of OperationUpdate for type: WAIT, action: START."""
684
+ return cls(
685
+ operation_id=identifier.operation_id,
686
+ parent_id=identifier.parent_id,
687
+ operation_type=OperationType.WAIT,
688
+ sub_type=OperationSubType.WAIT,
689
+ action=OperationAction.START,
690
+ name=identifier.name,
691
+ wait_options=wait_options,
692
+ )
693
+
694
+ # endregion wait
695
+
696
+
697
+ @dataclass(frozen=True)
698
+ class Operation:
699
+ """Represent the Operation type for GetDurableExecutionState and CheckpointDurableExecution."""
700
+
701
+ operation_id: str
702
+ operation_type: OperationType
703
+ status: OperationStatus
704
+ parent_id: str | None = None
705
+ name: str | None = None
706
+ start_timestamp: datetime.datetime | None = None
707
+ end_timestamp: datetime.datetime | None = None
708
+ sub_type: OperationSubType | None = None
709
+ execution_details: ExecutionDetails | None = None
710
+ context_details: ContextDetails | None = None
711
+ step_details: StepDetails | None = None
712
+ wait_details: WaitDetails | None = None
713
+ callback_details: CallbackDetails | None = None
714
+ chained_invoke_details: ChainedInvokeDetails | None = None
715
+
716
+ @classmethod
717
+ def from_dict(cls, data: MutableMapping[str, Any]) -> Operation:
718
+ """Create an Operation instance from a dictionary with the original Smithy model field names.
719
+
720
+ Args:
721
+ data: Dictionary with camelCase keys matching the Smithy model
722
+
723
+ Returns:
724
+ An Operation instance with snake_case attributes
725
+ """
726
+ operation_type = OperationType(data.get("Type"))
727
+ operation_status = OperationStatus(data.get("Status"))
728
+
729
+ sub_type = None
730
+ if sub_type_input := data.get("SubType"):
731
+ sub_type = OperationSubType(sub_type_input)
732
+
733
+ execution_details = None
734
+ if execution_details_input := data.get("ExecutionDetails"):
735
+ execution_details = ExecutionDetails.from_dict(execution_details_input)
736
+
737
+ context_details = None
738
+ if context_details_input := data.get("ContextDetails"):
739
+ context_details = ContextDetails.from_dict(context_details_input)
740
+
741
+ step_details = None
742
+ if step_details_input := data.get("StepDetails"):
743
+ step_details = StepDetails.from_dict(step_details_input)
744
+
745
+ wait_details = None
746
+ if wait_details_input := data.get("WaitDetails"):
747
+ wait_details = WaitDetails.from_dict(wait_details_input)
748
+
749
+ callback_details = None
750
+ if callback_details_input := data.get("CallbackDetails"):
751
+ callback_details = CallbackDetails.from_dict(callback_details_input)
752
+
753
+ chained_invoke_details = None
754
+ if chained_invoke_details := data.get("chained_invoke_details"):
755
+ chained_invoke_details = ChainedInvokeDetails.from_dict(
756
+ chained_invoke_details
757
+ )
758
+
759
+ return cls(
760
+ operation_id=data["Id"],
761
+ operation_type=operation_type,
762
+ status=operation_status,
763
+ parent_id=data.get("ParentId"),
764
+ name=data.get("Name"),
765
+ start_timestamp=data.get("StartTimestamp"),
766
+ end_timestamp=data.get("EndTimestamp"),
767
+ sub_type=sub_type,
768
+ execution_details=execution_details,
769
+ context_details=context_details,
770
+ step_details=step_details,
771
+ wait_details=wait_details,
772
+ callback_details=callback_details,
773
+ chained_invoke_details=chained_invoke_details,
774
+ )
775
+
776
+ def to_dict(self) -> MutableMapping[str, Any]:
777
+ result: MutableMapping[str, Any] = {
778
+ "Id": self.operation_id,
779
+ "Type": self.operation_type.value,
780
+ "Status": self.status.value,
781
+ }
782
+ if self.parent_id:
783
+ result["ParentId"] = self.parent_id
784
+ if self.name:
785
+ result["Name"] = self.name
786
+ if self.start_timestamp:
787
+ result["StartTimestamp"] = self.start_timestamp
788
+ if self.end_timestamp:
789
+ result["EndTimestamp"] = self.end_timestamp
790
+ if self.sub_type:
791
+ result["SubType"] = self.sub_type.value
792
+ if self.execution_details:
793
+ result["ExecutionDetails"] = {
794
+ "InputPayload": self.execution_details.input_payload
795
+ }
796
+ if self.context_details:
797
+ result["ContextDetails"] = {"Result": self.context_details.result}
798
+ if self.step_details:
799
+ step_dict: MutableMapping[str, Any] = {"Attempt": self.step_details.attempt}
800
+ if self.step_details.next_attempt_timestamp:
801
+ step_dict["NextAttemptTimestamp"] = (
802
+ self.step_details.next_attempt_timestamp
803
+ )
804
+ if self.step_details.result:
805
+ step_dict["Result"] = self.step_details.result
806
+ if self.step_details.error:
807
+ step_dict["Error"] = self.step_details.error.to_dict()
808
+ result["StepDetails"] = step_dict
809
+ if self.wait_details:
810
+ result["WaitDetails"] = {
811
+ "ScheduledEndTimestamp": self.wait_details.scheduled_end_timestamp
812
+ }
813
+ if self.callback_details:
814
+ callback_dict: MutableMapping[str, Any] = {
815
+ "CallbackId": self.callback_details.callback_id
816
+ }
817
+ if self.callback_details.result:
818
+ callback_dict["Result"] = self.callback_details.result
819
+ if self.callback_details.error:
820
+ callback_dict["Error"] = self.callback_details.error.to_dict()
821
+ result["CallbackDetails"] = callback_dict
822
+ if self.chained_invoke_details:
823
+ invoke_dict: MutableMapping[str, Any] = {}
824
+ if self.chained_invoke_details.result:
825
+ invoke_dict["Result"] = self.chained_invoke_details.result
826
+ if self.chained_invoke_details.error:
827
+ invoke_dict["Error"] = self.chained_invoke_details.error.to_dict()
828
+ result["ChainedInvokeDetails"] = invoke_dict
829
+ return result
830
+
831
+
832
+ @dataclass(frozen=True)
833
+ class CheckpointUpdatedExecutionState:
834
+ """Representation of the CheckpointUpdatedExecutionState structure of the DEX API."""
835
+
836
+ operations: list[Operation] = field(default_factory=list)
837
+ next_marker: str | None = None
838
+
839
+ @classmethod
840
+ def from_dict(
841
+ cls, data: MutableMapping[str, Any]
842
+ ) -> CheckpointUpdatedExecutionState:
843
+ """Create an instance from a dictionary with the original Smithy model field names.
844
+
845
+ Args:
846
+ data: Dictionary with camelCase keys matching the Smithy model
847
+
848
+ Returns:
849
+ Instance of the current class.
850
+ """
851
+ operations = []
852
+ if input_operations := data.get("Operations"):
853
+ operations = [Operation.from_dict(op) for op in input_operations]
854
+
855
+ return cls(operations=operations, next_marker=data.get("NextMarker"))
856
+
857
+
858
+ @dataclass(frozen=True)
859
+ class CheckpointOutput:
860
+ """Representation of the CheckpointDurableExecutionOutput structure of the DEX CheckpointDurableExecution API."""
861
+
862
+ checkpoint_token: str
863
+ new_execution_state: CheckpointUpdatedExecutionState
864
+
865
+ @classmethod
866
+ def from_dict(cls, data: MutableMapping[str, Any]) -> CheckpointOutput:
867
+ """Create an instance from a dictionary with the original Smithy model field names.
868
+
869
+ Args:
870
+ data: Dictionary with camelCase keys matching the Smithy model
871
+
872
+ Returns:
873
+ A CheckpointDurableExecutionOutput instance.
874
+ """
875
+ new_execution_state = None
876
+ if input_execution_state := data.get("NewExecutionState"):
877
+ new_execution_state = CheckpointUpdatedExecutionState.from_dict(
878
+ input_execution_state
879
+ )
880
+ else:
881
+ # Provide an empty default if not present
882
+ new_execution_state = CheckpointUpdatedExecutionState()
883
+
884
+ return cls(
885
+ # TODO: maybe should throw if empty?
886
+ checkpoint_token=data.get("CheckpointToken", ""),
887
+ new_execution_state=new_execution_state,
888
+ )
889
+
890
+
891
+ @dataclass(frozen=True)
892
+ class StateOutput:
893
+ """Representation of the GetDurableExecutionStateOutput structure of the DEX GetDurableExecutionState API."""
894
+
895
+ operations: list[Operation] = field(default_factory=list)
896
+ next_marker: str | None = None
897
+
898
+ @classmethod
899
+ def from_dict(cls, data: MutableMapping[str, Any]) -> StateOutput:
900
+ """Create a GetDurableExecutionStateOutput instance from a dictionary with the original Smithy model field names.
901
+
902
+ Args:
903
+ data: Dictionary with camelCase keys matching the Smithy model
904
+
905
+ Returns:
906
+ A GetDurableExecutionStateOutput instance.
907
+ """
908
+ operations = []
909
+ if input_operations := data.get("Operations"):
910
+ operations = [Operation.from_dict(op) for op in input_operations]
911
+
912
+ return cls(operations=operations, next_marker=data.get("NextMarker"))
913
+
914
+
915
+ # endregion model
916
+
917
+
918
+ # region client
919
+ class DurableServiceClient(Protocol):
920
+ """Durable Service clients must implement this interface."""
921
+
922
+ def checkpoint(
923
+ self,
924
+ durable_execution_arn: str,
925
+ checkpoint_token: str,
926
+ updates: list[OperationUpdate],
927
+ client_token: str | None,
928
+ ) -> CheckpointOutput: ... # pragma: no cover
929
+
930
+ def get_execution_state(
931
+ self,
932
+ durable_execution_arn: str,
933
+ checkpoint_token: str,
934
+ next_marker: str,
935
+ max_items: int = 1000,
936
+ ) -> StateOutput: ... # pragma: no cover
937
+
938
+
939
+ class LambdaClient(DurableServiceClient):
940
+ """Persist durable operations to the Lambda Durable Function APIs."""
941
+
942
+ def __init__(self, client: Any) -> None:
943
+ self.client = client
944
+
945
+ @staticmethod
946
+ def load_preview_botocore_models() -> None:
947
+ """
948
+ Load boto3 models from the Python path for custom preview client.
949
+ """
950
+ os.environ["AWS_DATA_PATH"] = str(
951
+ Path(__file__).parent.joinpath("botocore", "data")
952
+ )
953
+
954
+ @staticmethod
955
+ def initialize_from_env() -> LambdaClient:
956
+ LambdaClient.load_preview_botocore_models()
957
+
958
+ """
959
+ TODO - we can remove this when were using the actual lambda client,
960
+ but we need this with the preview model because boto won't match against lambdainternal.
961
+ """
962
+ endpoint_url = os.getenv("AWS_ENDPOINT_URL_LAMBDA", None)
963
+ if not endpoint_url:
964
+ client = boto3.client(
965
+ "lambdainternal",
966
+ config=Config(
967
+ connect_timeout=5,
968
+ read_timeout=50,
969
+ ),
970
+ )
971
+ else:
972
+ client = boto3.client(
973
+ "lambdainternal",
974
+ endpoint_url=endpoint_url,
975
+ config=Config(
976
+ connect_timeout=5,
977
+ read_timeout=50,
978
+ ),
979
+ )
980
+
981
+ return LambdaClient(client=client)
982
+
983
+ def checkpoint(
984
+ self,
985
+ durable_execution_arn: str,
986
+ checkpoint_token: str,
987
+ updates: list[OperationUpdate],
988
+ client_token: str | None,
989
+ ) -> CheckpointOutput:
990
+ try:
991
+ params = {
992
+ "DurableExecutionArn": durable_execution_arn,
993
+ "CheckpointToken": checkpoint_token,
994
+ "Updates": [o.to_dict() for o in updates],
995
+ }
996
+ if client_token is not None:
997
+ params["ClientToken"] = client_token
998
+
999
+ result: MutableMapping[str, Any] = self.client.checkpoint_durable_execution(
1000
+ **params
1001
+ )
1002
+
1003
+ return CheckpointOutput.from_dict(result)
1004
+ except Exception as e:
1005
+ checkpoint_error = CheckpointError.from_exception(e)
1006
+ logger.exception(
1007
+ "Failed to checkpoint.", extra=checkpoint_error.build_logger_extras()
1008
+ )
1009
+ raise checkpoint_error from None
1010
+
1011
+ def get_execution_state(
1012
+ self,
1013
+ durable_execution_arn: str,
1014
+ checkpoint_token: str,
1015
+ next_marker: str,
1016
+ max_items: int = 1000,
1017
+ ) -> StateOutput:
1018
+ try:
1019
+ result: MutableMapping[str, Any] = self.client.get_durable_execution_state(
1020
+ DurableExecutionArn=durable_execution_arn,
1021
+ CheckpointToken=checkpoint_token,
1022
+ Marker=next_marker,
1023
+ MaxItems=max_items,
1024
+ )
1025
+ return StateOutput.from_dict(result)
1026
+ except Exception as e:
1027
+ error = GetExecutionStateError.from_exception(e)
1028
+ logger.exception(
1029
+ "Failed to get execution state.", extra=error.build_logger_extras()
1030
+ )
1031
+ raise error from None
1032
+
1033
+
1034
+ # endregion client