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,436 @@
1
+ """Concurrent executor for parallel and map operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import heapq
6
+ import logging
7
+ import threading
8
+ import time
9
+ from abc import ABC, abstractmethod
10
+ from concurrent.futures import Future, ThreadPoolExecutor
11
+ from typing import TYPE_CHECKING, Generic, Self, TypeVar
12
+
13
+ from aws_durable_execution_sdk_python.concurrency.models import (
14
+ BatchItem,
15
+ BatchItemStatus,
16
+ BatchResult,
17
+ BranchStatus,
18
+ Executable,
19
+ ExecutableWithState,
20
+ ExecutionCounters,
21
+ SuspendResult,
22
+ )
23
+ from aws_durable_execution_sdk_python.config import ChildConfig
24
+ from aws_durable_execution_sdk_python.exceptions import (
25
+ SuspendExecution,
26
+ TimedSuspendExecution,
27
+ )
28
+ from aws_durable_execution_sdk_python.identifier import OperationIdentifier
29
+ from aws_durable_execution_sdk_python.lambda_service import ErrorObject
30
+ from aws_durable_execution_sdk_python.operation.child import child_handler
31
+
32
+ if TYPE_CHECKING:
33
+ from collections.abc import Callable
34
+
35
+ from aws_durable_execution_sdk_python.config import CompletionConfig
36
+ from aws_durable_execution_sdk_python.context import DurableContext
37
+ from aws_durable_execution_sdk_python.lambda_service import OperationSubType
38
+ from aws_durable_execution_sdk_python.serdes import SerDes
39
+ from aws_durable_execution_sdk_python.state import ExecutionState
40
+ from aws_durable_execution_sdk_python.types import SummaryGenerator
41
+
42
+
43
+ logger = logging.getLogger(__name__)
44
+
45
+ T = TypeVar("T")
46
+ R = TypeVar("R")
47
+
48
+ CallableType = TypeVar("CallableType")
49
+ ResultType = TypeVar("ResultType")
50
+
51
+
52
+ # region concurrency logic
53
+ class TimerScheduler:
54
+ """Manage timed suspend tasks with a background timer thread."""
55
+
56
+ def __init__(
57
+ self, resubmit_callback: Callable[[ExecutableWithState], None]
58
+ ) -> None:
59
+ self.resubmit_callback = resubmit_callback
60
+ self._pending_resumes: list[tuple[float, ExecutableWithState]] = []
61
+ self._lock = threading.Lock()
62
+ self._shutdown = threading.Event()
63
+ self._timer_thread = threading.Thread(target=self._timer_loop, daemon=True)
64
+ self._timer_thread.start()
65
+
66
+ def __enter__(self) -> Self:
67
+ return self
68
+
69
+ def __exit__(self, exc_type, exc_val, exc_tb) -> None:
70
+ self.shutdown()
71
+
72
+ def schedule_resume(
73
+ self, exe_state: ExecutableWithState, resume_time: float
74
+ ) -> None:
75
+ """Schedule a task to resume at the specified time."""
76
+ with self._lock:
77
+ heapq.heappush(self._pending_resumes, (resume_time, exe_state))
78
+
79
+ def shutdown(self) -> None:
80
+ """Shutdown the timer thread and cancel all pending resumes."""
81
+ self._shutdown.set()
82
+ self._timer_thread.join(timeout=1.0)
83
+ with self._lock:
84
+ self._pending_resumes.clear()
85
+
86
+ def _timer_loop(self) -> None:
87
+ """Background thread that processes timed resumes."""
88
+ while not self._shutdown.is_set():
89
+ next_resume_time = None
90
+
91
+ with self._lock:
92
+ if self._pending_resumes:
93
+ next_resume_time = self._pending_resumes[0][0]
94
+
95
+ if next_resume_time is None:
96
+ # No pending resumes, wait a bit and check again
97
+ self._shutdown.wait(timeout=0.1)
98
+ continue
99
+
100
+ current_time = time.time()
101
+ if current_time >= next_resume_time:
102
+ # Time to resume
103
+ with self._lock:
104
+ # no branch cover because hard to test reliably - this is a double-safety check if heap mutated
105
+ # since the first peek on next_resume_time further up
106
+ if ( # pragma: no branch
107
+ self._pending_resumes
108
+ and self._pending_resumes[0][0] <= current_time
109
+ ):
110
+ _, exe_state = heapq.heappop(self._pending_resumes)
111
+ if exe_state.can_resume:
112
+ exe_state.reset_to_pending()
113
+ self.resubmit_callback(exe_state)
114
+ else:
115
+ # Wait until next resume time
116
+ wait_time = min(next_resume_time - current_time, 0.1)
117
+ self._shutdown.wait(timeout=wait_time)
118
+
119
+
120
+ class ConcurrentExecutor(ABC, Generic[CallableType, ResultType]):
121
+ """Execute durable operations concurrently. This contains the execution logic for Map and Parallel."""
122
+
123
+ def __init__(
124
+ self,
125
+ executables: list[Executable[CallableType]],
126
+ max_concurrency: int | None,
127
+ completion_config: CompletionConfig,
128
+ sub_type_top: OperationSubType,
129
+ sub_type_iteration: OperationSubType,
130
+ name_prefix: str,
131
+ serdes: SerDes | None,
132
+ item_serdes: SerDes | None = None,
133
+ summary_generator: SummaryGenerator | None = None,
134
+ ):
135
+ """Initialize ConcurrentExecutor.
136
+
137
+ Args:
138
+ summary_generator: Optional function to generate compact summaries for large results.
139
+ When the serialized result exceeds 256KB, this generator creates a JSON summary
140
+ instead of checkpointing the full result. Used by map/parallel operations to
141
+ handle large BatchResult payloads efficiently. Matches TypeScript behavior in
142
+ run-in-child-context-handler.ts.
143
+ """
144
+ self.executables = executables
145
+ self.max_concurrency = max_concurrency
146
+ self.completion_config = completion_config
147
+ self.sub_type_top = sub_type_top
148
+ self.sub_type_iteration = sub_type_iteration
149
+ self.name_prefix = name_prefix
150
+ self.summary_generator = summary_generator
151
+
152
+ # Event-driven state tracking for when the executor is done
153
+ self._completion_event = threading.Event()
154
+ self._suspend_exception: SuspendExecution | None = None
155
+
156
+ # ExecutionCounters will keep track of completion criteria and on-going counters
157
+ min_successful = self.completion_config.min_successful or len(self.executables)
158
+ tolerated_failure_count = self.completion_config.tolerated_failure_count
159
+ tolerated_failure_percentage = (
160
+ self.completion_config.tolerated_failure_percentage
161
+ )
162
+
163
+ self.counters: ExecutionCounters = ExecutionCounters(
164
+ len(executables),
165
+ min_successful,
166
+ tolerated_failure_count,
167
+ tolerated_failure_percentage,
168
+ )
169
+ self.executables_with_state: list[ExecutableWithState] = []
170
+ self.serdes = serdes
171
+ self.item_serdes = item_serdes
172
+
173
+ @abstractmethod
174
+ def execute_item(
175
+ self, child_context: DurableContext, executable: Executable[CallableType]
176
+ ) -> ResultType:
177
+ """Execute a single executable in a child context and return the result."""
178
+ raise NotImplementedError
179
+
180
+ def execute(
181
+ self, execution_state: ExecutionState, executor_context: DurableContext
182
+ ) -> BatchResult[ResultType]:
183
+ """Execute items concurrently with event-driven state management."""
184
+ logger.debug(
185
+ "▶️ Executing concurrent operation, items: %d", len(self.executables)
186
+ )
187
+
188
+ max_workers = self.max_concurrency or len(self.executables)
189
+
190
+ self.executables_with_state = [
191
+ ExecutableWithState(executable=exe) for exe in self.executables
192
+ ]
193
+ self._completion_event.clear()
194
+ self._suspend_exception = None
195
+
196
+ def resubmitter(executable_with_state: ExecutableWithState) -> None:
197
+ """Resubmit a timed suspended task."""
198
+ execution_state.create_checkpoint()
199
+ submit_task(executable_with_state)
200
+
201
+ with (
202
+ TimerScheduler(resubmitter) as scheduler,
203
+ ThreadPoolExecutor(max_workers=max_workers) as thread_executor,
204
+ ):
205
+
206
+ def submit_task(executable_with_state: ExecutableWithState) -> Future:
207
+ """Submit task to the thread executor and mark its state as started."""
208
+ future = thread_executor.submit(
209
+ self._execute_item_in_child_context,
210
+ executor_context,
211
+ executable_with_state.executable,
212
+ )
213
+ executable_with_state.run(future)
214
+
215
+ def on_done(future: Future) -> None:
216
+ self._on_task_complete(executable_with_state, future, scheduler)
217
+
218
+ future.add_done_callback(on_done)
219
+ return future
220
+
221
+ # Submit initial tasks
222
+ futures = [
223
+ submit_task(exe_state) for exe_state in self.executables_with_state
224
+ ]
225
+
226
+ # Wait for completion
227
+ self._completion_event.wait()
228
+
229
+ # Cancel remaining futures so
230
+ # that we don't wait for them to join.
231
+ for future in futures:
232
+ future.cancel()
233
+
234
+ # Suspend execution if everything done and at least one of the tasks raised a suspend exception.
235
+ if self._suspend_exception:
236
+ raise self._suspend_exception
237
+
238
+ # Build final result
239
+ return self._create_result()
240
+
241
+ def should_execution_suspend(self) -> SuspendResult:
242
+ """Check if execution should suspend."""
243
+ earliest_timestamp: float = float("inf")
244
+ indefinite_suspend_task: (
245
+ ExecutableWithState[CallableType, ResultType] | None
246
+ ) = None
247
+
248
+ for exe_state in self.executables_with_state:
249
+ if exe_state.status in {BranchStatus.PENDING, BranchStatus.RUNNING}:
250
+ # Exit here! Still have tasks that can make progress, don't suspend.
251
+ return SuspendResult.do_not_suspend()
252
+ if exe_state.status is BranchStatus.SUSPENDED_WITH_TIMEOUT:
253
+ if (
254
+ exe_state.suspend_until
255
+ and exe_state.suspend_until < earliest_timestamp
256
+ ):
257
+ earliest_timestamp = exe_state.suspend_until
258
+ elif exe_state.status is BranchStatus.SUSPENDED:
259
+ indefinite_suspend_task = exe_state
260
+
261
+ # All tasks are in final states and at least one of them is a suspend.
262
+ if earliest_timestamp != float("inf"):
263
+ return SuspendResult.suspend(
264
+ TimedSuspendExecution(
265
+ "All concurrent work complete or suspended pending retry.",
266
+ earliest_timestamp,
267
+ )
268
+ )
269
+ if indefinite_suspend_task:
270
+ return SuspendResult.suspend(
271
+ SuspendExecution(
272
+ "All concurrent work complete or suspended and pending external callback."
273
+ )
274
+ )
275
+
276
+ return SuspendResult.do_not_suspend()
277
+
278
+ def _on_task_complete(
279
+ self,
280
+ exe_state: ExecutableWithState,
281
+ future: Future,
282
+ scheduler: TimerScheduler,
283
+ ) -> None:
284
+ """Handle task completion, suspension, or failure."""
285
+
286
+ if future.cancelled():
287
+ exe_state.suspend()
288
+ return
289
+
290
+ try:
291
+ result = future.result()
292
+ exe_state.complete(result)
293
+ self.counters.complete_task()
294
+ except TimedSuspendExecution as tse:
295
+ exe_state.suspend_with_timeout(tse.scheduled_timestamp)
296
+ scheduler.schedule_resume(exe_state, tse.scheduled_timestamp)
297
+ except SuspendExecution:
298
+ exe_state.suspend()
299
+ # For indefinite suspend, don't schedule resume
300
+ except Exception as e: # noqa: BLE001
301
+ exe_state.fail(e)
302
+ self.counters.fail_task()
303
+
304
+ # Check if execution should complete or suspend
305
+ if self.counters.should_complete():
306
+ self._completion_event.set()
307
+ else:
308
+ suspend_result = self.should_execution_suspend()
309
+ if suspend_result.should_suspend:
310
+ self._suspend_exception = suspend_result.exception
311
+ self._completion_event.set()
312
+
313
+ def _create_result(self) -> BatchResult[ResultType]:
314
+ """
315
+ Build the final BatchResult.
316
+
317
+ When this function executes, we've terminated the upper/parent context for whatever reason.
318
+ It follows that our items can be only in 3 states, Completed, Failed and Started (in all of the possible forms).
319
+ We tag each branch based on its observed value at the time of completion of the parent / upper context, and pass the
320
+ results to BatchResult.
321
+
322
+ Any inference wrt completion reason is left up to BatchResult, keeping the logic inference isolated.
323
+ """
324
+ batch_items: list[BatchItem[ResultType]] = []
325
+ for executable in self.executables_with_state:
326
+ match executable.status:
327
+ case BranchStatus.COMPLETED:
328
+ batch_items.append(
329
+ BatchItem(
330
+ executable.index,
331
+ BatchItemStatus.SUCCEEDED,
332
+ executable.result,
333
+ )
334
+ )
335
+ case BranchStatus.FAILED:
336
+ batch_items.append(
337
+ BatchItem(
338
+ executable.index,
339
+ BatchItemStatus.FAILED,
340
+ error=ErrorObject.from_exception(executable.error),
341
+ )
342
+ )
343
+ case (
344
+ BranchStatus.PENDING
345
+ | BranchStatus.RUNNING
346
+ | BranchStatus.SUSPENDED
347
+ | BranchStatus.SUSPENDED_WITH_TIMEOUT
348
+ ):
349
+ batch_items.append(
350
+ BatchItem(executable.index, BatchItemStatus.STARTED)
351
+ )
352
+
353
+ return BatchResult.from_items(batch_items, self.completion_config)
354
+
355
+ def _execute_item_in_child_context(
356
+ self,
357
+ executor_context: DurableContext,
358
+ executable: Executable[CallableType],
359
+ ) -> ResultType:
360
+ """
361
+ Execute a single item in a derived child context.
362
+
363
+ instead of relying on `executor_context.run_in_child_context`
364
+ we generate an operation_id for the child, and then call `child_handler`
365
+ directly. This avoids the hidden mutation of the context's internal counter.
366
+ we can do this because we explicitly control the generation of step_id and do it
367
+ using executable.index.
368
+
369
+
370
+ invariant: `operation_id` for a given executable is deterministic,
371
+ and execution order invariant.
372
+ """
373
+
374
+ operation_id = executor_context._create_step_id_for_logical_step( # noqa: SLF001
375
+ executable.index
376
+ )
377
+ name = f"{self.name_prefix}{executable.index}"
378
+ child_context = executor_context.create_child_context(operation_id)
379
+ operation_identifier = OperationIdentifier(
380
+ operation_id,
381
+ executor_context._parent_id, # noqa: SLF001
382
+ name,
383
+ )
384
+
385
+ def run_in_child_handler():
386
+ return self.execute_item(child_context, executable)
387
+
388
+ result: ResultType = child_handler(
389
+ run_in_child_handler,
390
+ child_context.state,
391
+ operation_identifier=operation_identifier,
392
+ config=ChildConfig(
393
+ serdes=self.item_serdes or self.serdes,
394
+ sub_type=self.sub_type_iteration,
395
+ summary_generator=self.summary_generator,
396
+ ),
397
+ )
398
+ child_context.state.track_replay(operation_id=operation_id)
399
+ return result
400
+
401
+ def replay(self, execution_state: ExecutionState, executor_context: DurableContext):
402
+ """
403
+ Replay rather than re-run children.
404
+
405
+ if we are here, then we are in replay_children.
406
+ This will pre-generate all the operation ids for the children and collect the checkpointed
407
+ results.
408
+ """
409
+ items: list[BatchItem[ResultType]] = []
410
+ for executable in self.executables:
411
+ operation_id = executor_context._create_step_id_for_logical_step( # noqa: SLF001
412
+ executable.index
413
+ )
414
+ checkpoint = execution_state.get_checkpoint_result(operation_id)
415
+
416
+ result: ResultType | None = None
417
+ error = None
418
+ status: BatchItemStatus
419
+ if checkpoint.is_succeeded():
420
+ status = BatchItemStatus.SUCCEEDED
421
+ result = self._execute_item_in_child_context(
422
+ executor_context, executable
423
+ )
424
+
425
+ elif checkpoint.is_failed():
426
+ error = checkpoint.error
427
+ status = BatchItemStatus.FAILED
428
+ else:
429
+ status = BatchItemStatus.STARTED
430
+
431
+ batch_item = BatchItem(executable.index, status, result=result, error=error)
432
+ items.append(batch_item)
433
+ return BatchResult.from_items(items, self.completion_config)
434
+
435
+
436
+ # endregion concurrency logic