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,469 @@
1
+ """Concurrent executor for parallel and map operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import threading
7
+ import time
8
+ from collections import Counter
9
+ from dataclasses import dataclass
10
+ from enum import Enum
11
+ from typing import TYPE_CHECKING, Generic, TypeVar
12
+
13
+ from aws_durable_execution_sdk_python.exceptions import (
14
+ InvalidStateError,
15
+ SuspendExecution,
16
+ )
17
+ from aws_durable_execution_sdk_python.lambda_service import ErrorObject
18
+ from aws_durable_execution_sdk_python.types import BatchResult as BatchResultProtocol
19
+
20
+ if TYPE_CHECKING:
21
+ from concurrent.futures import Future
22
+
23
+ from aws_durable_execution_sdk_python.config import CompletionConfig
24
+
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+ T = TypeVar("T")
29
+ R = TypeVar("R")
30
+
31
+ CallableType = TypeVar("CallableType")
32
+ ResultType = TypeVar("ResultType")
33
+
34
+
35
+ # region Result models
36
+ class BatchItemStatus(Enum):
37
+ SUCCEEDED = "SUCCEEDED"
38
+ FAILED = "FAILED"
39
+ STARTED = "STARTED"
40
+
41
+
42
+ class CompletionReason(Enum):
43
+ ALL_COMPLETED = "ALL_COMPLETED"
44
+ MIN_SUCCESSFUL_REACHED = "MIN_SUCCESSFUL_REACHED"
45
+ FAILURE_TOLERANCE_EXCEEDED = "FAILURE_TOLERANCE_EXCEEDED"
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class SuspendResult:
50
+ should_suspend: bool
51
+ exception: SuspendExecution | None = None
52
+
53
+ @staticmethod
54
+ def do_not_suspend() -> SuspendResult:
55
+ return SuspendResult(should_suspend=False)
56
+
57
+ @staticmethod
58
+ def suspend(exception: SuspendExecution) -> SuspendResult:
59
+ return SuspendResult(should_suspend=True, exception=exception)
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class BatchItem(Generic[R]):
64
+ index: int
65
+ status: BatchItemStatus
66
+ result: R | None = None
67
+ error: ErrorObject | None = None
68
+
69
+ def to_dict(self) -> dict:
70
+ return {
71
+ "index": self.index,
72
+ "status": self.status.value,
73
+ "result": self.result,
74
+ "error": self.error.to_dict() if self.error else None,
75
+ }
76
+
77
+ @classmethod
78
+ def from_dict(cls, data: dict) -> BatchItem[R]:
79
+ return cls(
80
+ index=data["index"],
81
+ status=BatchItemStatus(data["status"]),
82
+ result=data.get("result"),
83
+ error=ErrorObject.from_dict(data["error"]) if data.get("error") else None,
84
+ )
85
+
86
+
87
+ @dataclass(frozen=True)
88
+ class BatchResult(Generic[R], BatchResultProtocol[R]): # noqa: PYI059
89
+ all: list[BatchItem[R]]
90
+ completion_reason: CompletionReason
91
+
92
+ @classmethod
93
+ def from_dict(
94
+ cls, data: dict, completion_config: CompletionConfig | None = None
95
+ ) -> BatchResult[R]:
96
+ batch_items: list[BatchItem[R]] = [
97
+ BatchItem.from_dict(item) for item in data["all"]
98
+ ]
99
+
100
+ completion_reason_value = data.get("completionReason")
101
+ if completion_reason_value is None:
102
+ # Infer completion reason from batch item statuses and completion config
103
+ # This aligns with the TypeScript implementation that uses completion config
104
+ # to accurately reconstruct the completion reason during replay
105
+ result = cls.from_items(batch_items, completion_config)
106
+ logger.warning(
107
+ "Missing completionReason in BatchResult deserialization, "
108
+ "inferred '%s' from batch item statuses. "
109
+ "This may indicate incomplete serialization data.",
110
+ result.completion_reason.value,
111
+ )
112
+ return result
113
+
114
+ completion_reason = CompletionReason(completion_reason_value)
115
+ return cls(batch_items, completion_reason)
116
+
117
+ @classmethod
118
+ def from_items(
119
+ cls,
120
+ items: list[BatchItem[R]],
121
+ completion_config: CompletionConfig | None = None,
122
+ ):
123
+ """
124
+ Infer completion reason based on batch item statuses and completion config.
125
+
126
+ This follows the same logic as the TypeScript implementation:
127
+ - If all items completed: ALL_COMPLETED
128
+ - If minSuccessful threshold met and not all completed: MIN_SUCCESSFUL_REACHED
129
+ - Otherwise: FAILURE_TOLERANCE_EXCEEDED
130
+ """
131
+
132
+ statuses = (item.status for item in items)
133
+ counts = Counter(statuses)
134
+ succeeded_count = counts.get(BatchItemStatus.SUCCEEDED, 0)
135
+ failed_count = counts.get(BatchItemStatus.FAILED, 0)
136
+ started_count = counts.get(BatchItemStatus.STARTED, 0)
137
+
138
+ completed_count = succeeded_count + failed_count
139
+ total_count = started_count + completed_count
140
+
141
+ # If all items completed (no started items), it's ALL_COMPLETED
142
+ if completed_count == total_count:
143
+ completion_reason = CompletionReason.ALL_COMPLETED
144
+ elif ( # If we have completion config and minSuccessful threshold is met
145
+ completion_config
146
+ and (min_successful := completion_config.min_successful) is not None
147
+ and succeeded_count >= min_successful
148
+ ):
149
+ completion_reason = CompletionReason.MIN_SUCCESSFUL_REACHED
150
+ else:
151
+ # Otherwise, assume failure tolerance was exceeded
152
+ completion_reason = CompletionReason.FAILURE_TOLERANCE_EXCEEDED
153
+
154
+ return cls(items, completion_reason)
155
+
156
+ def to_dict(self) -> dict:
157
+ return {
158
+ "all": [item.to_dict() for item in self.all],
159
+ "completionReason": self.completion_reason.value,
160
+ }
161
+
162
+ def succeeded(self) -> list[BatchItem[R]]:
163
+ return [
164
+ item
165
+ for item in self.all
166
+ if item.status is BatchItemStatus.SUCCEEDED and item.result is not None
167
+ ]
168
+
169
+ def failed(self) -> list[BatchItem[R]]:
170
+ return [
171
+ item
172
+ for item in self.all
173
+ if item.status is BatchItemStatus.FAILED and item.error is not None
174
+ ]
175
+
176
+ def started(self) -> list[BatchItem[R]]:
177
+ return [item for item in self.all if item.status is BatchItemStatus.STARTED]
178
+
179
+ @property
180
+ def status(self) -> BatchItemStatus:
181
+ return BatchItemStatus.FAILED if self.has_failure else BatchItemStatus.SUCCEEDED
182
+
183
+ @property
184
+ def has_failure(self) -> bool:
185
+ return any(item.status is BatchItemStatus.FAILED for item in self.all)
186
+
187
+ def throw_if_error(self) -> None:
188
+ first_error = next(
189
+ (item.error for item in self.all if item.status is BatchItemStatus.FAILED),
190
+ None,
191
+ )
192
+ if first_error:
193
+ raise first_error.to_callable_runtime_error()
194
+
195
+ def get_results(self) -> list[R]:
196
+ return [
197
+ item.result
198
+ for item in self.all
199
+ if item.status is BatchItemStatus.SUCCEEDED and item.result is not None
200
+ ]
201
+
202
+ def get_errors(self) -> list[ErrorObject]:
203
+ return [
204
+ item.error
205
+ for item in self.all
206
+ if item.status is BatchItemStatus.FAILED and item.error is not None
207
+ ]
208
+
209
+ @property
210
+ def success_count(self) -> int:
211
+ return sum(1 for item in self.all if item.status is BatchItemStatus.SUCCEEDED)
212
+
213
+ @property
214
+ def failure_count(self) -> int:
215
+ return sum(1 for item in self.all if item.status is BatchItemStatus.FAILED)
216
+
217
+ @property
218
+ def started_count(self) -> int:
219
+ return sum(1 for item in self.all if item.status is BatchItemStatus.STARTED)
220
+
221
+ @property
222
+ def total_count(self) -> int:
223
+ return len(self.all)
224
+
225
+
226
+ # endregion Result models
227
+
228
+
229
+ # region concurrency models
230
+ @dataclass(frozen=True)
231
+ class Executable(Generic[CallableType]):
232
+ index: int
233
+ func: CallableType
234
+
235
+
236
+ class BranchStatus(Enum):
237
+ PENDING = "pending"
238
+ RUNNING = "running"
239
+ COMPLETED = "completed"
240
+ SUSPENDED = "suspended"
241
+ SUSPENDED_WITH_TIMEOUT = "suspended_with_timeout"
242
+ FAILED = "failed"
243
+
244
+
245
+ class ExecutableWithState(Generic[CallableType, ResultType]):
246
+ """Manages the execution state and lifecycle of an executable."""
247
+
248
+ def __init__(self, executable: Executable[CallableType]):
249
+ self.executable = executable
250
+ self._status = BranchStatus.PENDING
251
+ self._future: Future | None = None
252
+ self._suspend_until: float | None = None
253
+ self._result: ResultType = None # type: ignore[assignment]
254
+ self._is_result_set: bool = False
255
+ self._error: Exception | None = None
256
+
257
+ @property
258
+ def future(self) -> Future:
259
+ """Get the future, raising error if not available."""
260
+ if self._future is None:
261
+ msg = f"ExecutableWithState was never started. {self.executable.index}"
262
+ raise InvalidStateError(msg)
263
+ return self._future
264
+
265
+ @property
266
+ def status(self) -> BranchStatus:
267
+ """Get current status."""
268
+ return self._status
269
+
270
+ @property
271
+ def result(self) -> ResultType:
272
+ """Get result if completed."""
273
+ if not self._is_result_set or self._status != BranchStatus.COMPLETED:
274
+ msg = f"result not available in status {self._status}"
275
+ raise InvalidStateError(msg)
276
+ return self._result
277
+
278
+ @property
279
+ def error(self) -> Exception:
280
+ """Get error if failed."""
281
+ if self._error is None or self._status != BranchStatus.FAILED:
282
+ msg = f"error not available in status {self._status}"
283
+ raise InvalidStateError(msg)
284
+ return self._error
285
+
286
+ @property
287
+ def suspend_until(self) -> float | None:
288
+ """Get suspend timestamp."""
289
+ return self._suspend_until
290
+
291
+ @property
292
+ def is_running(self) -> bool:
293
+ """Check if currently running."""
294
+ return self._status is BranchStatus.RUNNING
295
+
296
+ @property
297
+ def can_resume(self) -> bool:
298
+ """Check if can resume from suspension."""
299
+ return self._status is BranchStatus.SUSPENDED or (
300
+ self._status is BranchStatus.SUSPENDED_WITH_TIMEOUT
301
+ and self._suspend_until is not None
302
+ and time.time() >= self._suspend_until
303
+ )
304
+
305
+ @property
306
+ def index(self) -> int:
307
+ return self.executable.index
308
+
309
+ @property
310
+ def callable(self) -> CallableType:
311
+ return self.executable.func
312
+
313
+ # region State transitions
314
+ def run(self, future: Future) -> None:
315
+ """Transition to RUNNING state with a future."""
316
+ if self._status != BranchStatus.PENDING:
317
+ msg = f"Cannot start running from {self._status}"
318
+ raise InvalidStateError(msg)
319
+ self._status = BranchStatus.RUNNING
320
+ self._future = future
321
+
322
+ def suspend(self) -> None:
323
+ """Transition to SUSPENDED state (indefinite)."""
324
+ self._status = BranchStatus.SUSPENDED
325
+ self._suspend_until = None
326
+
327
+ def suspend_with_timeout(self, timestamp: float) -> None:
328
+ """Transition to SUSPENDED_WITH_TIMEOUT state."""
329
+ self._status = BranchStatus.SUSPENDED_WITH_TIMEOUT
330
+ self._suspend_until = timestamp
331
+
332
+ def complete(self, result: ResultType) -> None:
333
+ """Transition to COMPLETED state."""
334
+ self._status = BranchStatus.COMPLETED
335
+ self._result = result
336
+ self._is_result_set = True
337
+
338
+ def fail(self, error: Exception) -> None:
339
+ """Transition to FAILED state."""
340
+ self._status = BranchStatus.FAILED
341
+ self._error = error
342
+
343
+ def reset_to_pending(self) -> None:
344
+ """Reset to PENDING state for resubmission."""
345
+ self._status = BranchStatus.PENDING
346
+ self._future = None
347
+ self._suspend_until = None
348
+
349
+ # endregion State transitions
350
+
351
+
352
+ class ExecutionCounters:
353
+ """Thread-safe counters for tracking execution state."""
354
+
355
+ def __init__(
356
+ self,
357
+ total_tasks: int,
358
+ min_successful: int,
359
+ tolerated_failure_count: int | None,
360
+ tolerated_failure_percentage: float | None,
361
+ ):
362
+ self.total_tasks: int = total_tasks
363
+ self.min_successful: int = min_successful
364
+ self.tolerated_failure_count: int | None = tolerated_failure_count
365
+ self.tolerated_failure_percentage: float | None = tolerated_failure_percentage
366
+ self.success_count: int = 0
367
+ self.failure_count: int = 0
368
+ self._lock = threading.Lock()
369
+
370
+ def complete_task(self) -> None:
371
+ """Task completed successfully."""
372
+ with self._lock:
373
+ self.success_count += 1
374
+
375
+ def fail_task(self) -> None:
376
+ """Task failed."""
377
+ with self._lock:
378
+ self.failure_count += 1
379
+
380
+ def should_continue(self) -> bool:
381
+ """
382
+ Check if we should continue starting new tasks (based on failure tolerance).
383
+ Matches TypeScript shouldContinue() logic.
384
+ """
385
+ with self._lock:
386
+ # If no completion config, only continue if no failures
387
+ if (
388
+ self.tolerated_failure_count is None
389
+ and self.tolerated_failure_percentage is None
390
+ ):
391
+ return self.failure_count == 0
392
+
393
+ # Check failure count tolerance
394
+ if (
395
+ self.tolerated_failure_count is not None
396
+ and self.failure_count > self.tolerated_failure_count
397
+ ):
398
+ return False
399
+
400
+ # Check failure percentage tolerance
401
+ if self.tolerated_failure_percentage is not None and self.total_tasks > 0:
402
+ failure_percentage = (self.failure_count / self.total_tasks) * 100
403
+ if failure_percentage > self.tolerated_failure_percentage:
404
+ return False
405
+
406
+ return True
407
+
408
+ def is_complete(self) -> bool:
409
+ """
410
+ Check if execution should complete (based on completion criteria).
411
+ Matches TypeScript isComplete() logic.
412
+ """
413
+ with self._lock:
414
+ completed_count = self.success_count + self.failure_count
415
+
416
+ # All tasks completed
417
+ if completed_count == self.total_tasks:
418
+ return True
419
+
420
+ # when we breach min successful, we've completed
421
+ return self.success_count >= self.min_successful
422
+
423
+ def should_complete(self) -> bool:
424
+ """
425
+ Check if execution should complete.
426
+ Combines TypeScript shouldContinue() and isComplete() logic.
427
+ """
428
+ return self.is_complete() or not self.should_continue()
429
+
430
+ def is_all_completed(self) -> bool:
431
+ """True if all tasks completed successfully."""
432
+ with self._lock:
433
+ return self.success_count == self.total_tasks
434
+
435
+ def is_min_successful_reached(self) -> bool:
436
+ """True if minimum successful tasks reached."""
437
+ with self._lock:
438
+ return self.success_count >= self.min_successful
439
+
440
+ def is_failure_tolerance_exceeded(self) -> bool:
441
+ """True if failure tolerance was exceeded."""
442
+ with self._lock:
443
+ return self._is_failure_condition_reached(
444
+ tolerated_count=self.tolerated_failure_count,
445
+ tolerated_percentage=self.tolerated_failure_percentage,
446
+ failure_count=self.failure_count,
447
+ )
448
+
449
+ def _is_failure_condition_reached(
450
+ self,
451
+ tolerated_count: int | None,
452
+ tolerated_percentage: float | None,
453
+ failure_count: int,
454
+ ) -> bool:
455
+ """True if failure conditions are reached (no locking - caller must lock)."""
456
+ # Failure count condition
457
+ if tolerated_count is not None and failure_count > tolerated_count:
458
+ return True
459
+
460
+ # Failure percentage condition
461
+ if tolerated_percentage is not None and self.total_tasks > 0:
462
+ failure_percentage = (failure_count / self.total_tasks) * 100
463
+ if failure_percentage > tolerated_percentage:
464
+ return True
465
+
466
+ return False
467
+
468
+
469
+ # endegion concurrency models