async-durable-execution-runner 2.0.0a1__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 (55) hide show
  1. LICENSE +175 -0
  2. NOTICE +8 -0
  3. VERSION.py +5 -0
  4. async_durable_execution_runner/__about__.py +33 -0
  5. async_durable_execution_runner/__init__.py +23 -0
  6. async_durable_execution_runner/checkpoint/__init__.py +1 -0
  7. async_durable_execution_runner/checkpoint/processor.py +101 -0
  8. async_durable_execution_runner/checkpoint/processors/__init__.py +1 -0
  9. async_durable_execution_runner/checkpoint/processors/base.py +199 -0
  10. async_durable_execution_runner/checkpoint/processors/callback.py +89 -0
  11. async_durable_execution_runner/checkpoint/processors/context.py +59 -0
  12. async_durable_execution_runner/checkpoint/processors/execution.py +52 -0
  13. async_durable_execution_runner/checkpoint/processors/step.py +124 -0
  14. async_durable_execution_runner/checkpoint/processors/wait.py +95 -0
  15. async_durable_execution_runner/checkpoint/transformer.py +104 -0
  16. async_durable_execution_runner/checkpoint/validators/__init__.py +1 -0
  17. async_durable_execution_runner/checkpoint/validators/checkpoint.py +242 -0
  18. async_durable_execution_runner/checkpoint/validators/operations/__init__.py +1 -0
  19. async_durable_execution_runner/checkpoint/validators/operations/callback.py +45 -0
  20. async_durable_execution_runner/checkpoint/validators/operations/context.py +73 -0
  21. async_durable_execution_runner/checkpoint/validators/operations/execution.py +47 -0
  22. async_durable_execution_runner/checkpoint/validators/operations/invoke.py +56 -0
  23. async_durable_execution_runner/checkpoint/validators/operations/step.py +106 -0
  24. async_durable_execution_runner/checkpoint/validators/operations/wait.py +54 -0
  25. async_durable_execution_runner/checkpoint/validators/transitions.py +66 -0
  26. async_durable_execution_runner/cli.py +498 -0
  27. async_durable_execution_runner/client.py +50 -0
  28. async_durable_execution_runner/exceptions.py +288 -0
  29. async_durable_execution_runner/execution.py +444 -0
  30. async_durable_execution_runner/executor.py +1234 -0
  31. async_durable_execution_runner/invoker.py +340 -0
  32. async_durable_execution_runner/model.py +3296 -0
  33. async_durable_execution_runner/observer.py +144 -0
  34. async_durable_execution_runner/py.typed +1 -0
  35. async_durable_execution_runner/runner.py +1167 -0
  36. async_durable_execution_runner/scheduler.py +246 -0
  37. async_durable_execution_runner/stores/__init__.py +1 -0
  38. async_durable_execution_runner/stores/base.py +147 -0
  39. async_durable_execution_runner/stores/filesystem.py +79 -0
  40. async_durable_execution_runner/stores/memory.py +38 -0
  41. async_durable_execution_runner/stores/sqlite.py +273 -0
  42. async_durable_execution_runner/token.py +49 -0
  43. async_durable_execution_runner/web/__init__.py +1 -0
  44. async_durable_execution_runner/web/errors.py +8 -0
  45. async_durable_execution_runner/web/handlers.py +813 -0
  46. async_durable_execution_runner/web/models.py +266 -0
  47. async_durable_execution_runner/web/routes.py +692 -0
  48. async_durable_execution_runner/web/serialization.py +235 -0
  49. async_durable_execution_runner/web/server.py +243 -0
  50. async_durable_execution_runner-2.0.0a1.dist-info/METADATA +238 -0
  51. async_durable_execution_runner-2.0.0a1.dist-info/RECORD +55 -0
  52. async_durable_execution_runner-2.0.0a1.dist-info/WHEEL +4 -0
  53. async_durable_execution_runner-2.0.0a1.dist-info/entry_points.txt +2 -0
  54. async_durable_execution_runner-2.0.0a1.dist-info/licenses/LICENSE +175 -0
  55. async_durable_execution_runner-2.0.0a1.dist-info/licenses/NOTICE +1 -0
@@ -0,0 +1,444 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import replace
4
+ from datetime import UTC, datetime
5
+ from enum import Enum
6
+ from threading import Lock
7
+ from typing import Any
8
+ from uuid import uuid4
9
+
10
+ from async_durable_execution.execution import (
11
+ DurableExecutionInvocationOutput,
12
+ InvocationStatus,
13
+ )
14
+ from async_durable_execution.lambda_service import (
15
+ ErrorObject,
16
+ ExecutionDetails,
17
+ Operation,
18
+ OperationStatus,
19
+ OperationType,
20
+ OperationUpdate,
21
+ )
22
+
23
+ from async_durable_execution_runner.exceptions import (
24
+ IllegalStateException,
25
+ InvalidParameterValueException,
26
+ )
27
+
28
+ # Import AWS exceptions
29
+ from async_durable_execution_runner.model import (
30
+ InvocationCompletedDetails,
31
+ StartDurableExecutionInput,
32
+ )
33
+ from async_durable_execution_runner.token import (
34
+ CheckpointToken,
35
+ )
36
+
37
+
38
+ class ExecutionStatus(Enum):
39
+ """Execution status for API responses."""
40
+
41
+ RUNNING = "RUNNING"
42
+ SUCCEEDED = "SUCCEEDED"
43
+ FAILED = "FAILED"
44
+ STOPPED = "STOPPED"
45
+ TIMED_OUT = "TIMED_OUT"
46
+
47
+
48
+ class Execution:
49
+ """Execution state."""
50
+
51
+ def __init__(
52
+ self,
53
+ durable_execution_arn: str,
54
+ start_input: StartDurableExecutionInput,
55
+ operations: list[Operation],
56
+ ):
57
+ self.durable_execution_arn: str = durable_execution_arn
58
+ # operation is frozen, it won't mutate - no need to clone/deep-copy
59
+ self.start_input: StartDurableExecutionInput = start_input
60
+ self.operations: list[Operation] = operations
61
+ self.updates: list[OperationUpdate] = []
62
+ self.invocation_completions: list[InvocationCompletedDetails] = []
63
+ self.used_tokens: set[str] = set()
64
+ # TODO: this will need to persist/rehydrate depending on inmemory vs sqllite store
65
+ self._token_sequence: int = 0
66
+ self._state_lock: Lock = Lock()
67
+ self.is_complete: bool = False
68
+ self.result: DurableExecutionInvocationOutput | None = None
69
+ self.consecutive_failed_invocation_attempts: int = 0
70
+ self.close_status: ExecutionStatus | None = None
71
+
72
+ @property
73
+ def token_sequence(self) -> int:
74
+ """Get current token sequence value."""
75
+ return self._token_sequence
76
+
77
+ def current_status(self) -> ExecutionStatus:
78
+ """Get execution status."""
79
+ if not self.is_complete:
80
+ return ExecutionStatus.RUNNING
81
+
82
+ if not self.close_status:
83
+ msg: str = "close_status must be set when execution is complete"
84
+ raise IllegalStateException(msg)
85
+
86
+ return self.close_status
87
+
88
+ @staticmethod
89
+ def new(input: StartDurableExecutionInput) -> Execution: # noqa: A002
90
+ # make a nicer arn
91
+ # 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-_\.]+
92
+ # Example: arn:aws:lambda:us-east-1:123456789012:durable-execution:myDurableFunction:myDurableExecutionName:ce67da72-3701-4f83-9174-f4189d27b0a5
93
+ return Execution(
94
+ durable_execution_arn=str(uuid4())
95
+ + "/"
96
+ + (input.invocation_id or str(uuid4())),
97
+ start_input=input,
98
+ operations=[],
99
+ )
100
+
101
+ def to_json_dict(self) -> dict[str, Any]:
102
+ """Serialize execution to JSON-serializable dictionary"""
103
+ return {
104
+ "DurableExecutionArn": self.durable_execution_arn,
105
+ "StartInput": self.start_input.to_dict(),
106
+ "Operations": [op.to_json_dict() for op in self.operations],
107
+ "Updates": [update.to_dict() for update in self.updates],
108
+ "InvocationCompletions": [
109
+ completion.to_json_dict() for completion in self.invocation_completions
110
+ ],
111
+ "UsedTokens": list(self.used_tokens),
112
+ "TokenSequence": self._token_sequence,
113
+ "IsComplete": self.is_complete,
114
+ "Result": self.result.to_dict() if self.result else None,
115
+ "ConsecutiveFailedInvocationAttempts": self.consecutive_failed_invocation_attempts,
116
+ "CloseStatus": self.close_status.value if self.close_status else None,
117
+ }
118
+
119
+ @classmethod
120
+ def from_json_dict(cls, data: dict[str, Any]) -> Execution:
121
+ """Deserialize execution from dictionary."""
122
+ # Reconstruct start_input
123
+ start_input = StartDurableExecutionInput.from_dict(data["StartInput"])
124
+
125
+ # Reconstruct operations
126
+ operations = [
127
+ Operation.from_json_dict(op_data) for op_data in data["Operations"]
128
+ ]
129
+
130
+ # Create execution
131
+ execution = cls(
132
+ durable_execution_arn=data["DurableExecutionArn"],
133
+ start_input=start_input,
134
+ operations=operations,
135
+ )
136
+
137
+ # Set additional fields
138
+ execution.updates = [
139
+ OperationUpdate.from_dict(update_data) for update_data in data["Updates"]
140
+ ]
141
+ execution.invocation_completions = [
142
+ InvocationCompletedDetails.from_json_dict(item)
143
+ for item in data.get("InvocationCompletions", [])
144
+ ]
145
+ execution.used_tokens = set(data["UsedTokens"])
146
+ execution._token_sequence = data["TokenSequence"] # noqa: SLF001
147
+ execution.is_complete = data["IsComplete"]
148
+ execution.result = (
149
+ DurableExecutionInvocationOutput.from_dict(data["Result"])
150
+ if data["Result"]
151
+ else None
152
+ )
153
+ execution.consecutive_failed_invocation_attempts = data[
154
+ "ConsecutiveFailedInvocationAttempts"
155
+ ]
156
+ close_status_str = data.get("CloseStatus")
157
+ execution.close_status = (
158
+ ExecutionStatus(close_status_str) if close_status_str else None
159
+ )
160
+
161
+ return execution
162
+
163
+ def start(self) -> None:
164
+ if self.start_input.invocation_id is None:
165
+ msg: str = "invocation_id is required"
166
+ raise InvalidParameterValueException(msg)
167
+ with self._state_lock:
168
+ self.operations.append(
169
+ Operation(
170
+ operation_id=self.start_input.invocation_id,
171
+ parent_id=None,
172
+ name=self.start_input.execution_name,
173
+ start_timestamp=datetime.now(UTC),
174
+ operation_type=OperationType.EXECUTION,
175
+ status=OperationStatus.STARTED,
176
+ execution_details=ExecutionDetails(
177
+ input_payload=self.start_input.get_normalized_input()
178
+ ),
179
+ )
180
+ )
181
+
182
+ def get_operation_execution_started(self) -> Operation:
183
+ if not self.operations:
184
+ msg: str = "execution not started."
185
+
186
+ raise IllegalStateException(msg)
187
+
188
+ return self.operations[0]
189
+
190
+ def get_new_checkpoint_token(self) -> str:
191
+ """Generate a new checkpoint token with incremented sequence"""
192
+ with self._state_lock:
193
+ self._token_sequence += 1
194
+ new_token_sequence = self._token_sequence
195
+ token = CheckpointToken(
196
+ execution_arn=self.durable_execution_arn,
197
+ token_sequence=new_token_sequence,
198
+ )
199
+ token_str = token.to_str()
200
+ self.used_tokens.add(token_str)
201
+ return token_str
202
+
203
+ def get_navigable_operations(self) -> list[Operation]:
204
+ """Get list of operations, but exclude child operations where the parent has already completed."""
205
+ return self.operations
206
+
207
+ def get_assertable_operations(self) -> list[Operation]:
208
+ """Get list of operations, but exclude the EXECUTION operations"""
209
+ # TODO: this excludes EXECUTION at start, but can there be an EXECUTION at the end if there was a checkpoint with large payload?
210
+ return self.operations[1:]
211
+
212
+ def has_pending_operations(self, execution: Execution) -> bool:
213
+ """True if execution has pending operations."""
214
+
215
+ for operation in execution.operations:
216
+ if (
217
+ operation.operation_type == OperationType.STEP
218
+ and operation.status == OperationStatus.PENDING
219
+ ) or (
220
+ operation.operation_type
221
+ in [
222
+ OperationType.WAIT,
223
+ OperationType.CALLBACK,
224
+ OperationType.CHAINED_INVOKE,
225
+ ]
226
+ and operation.status == OperationStatus.STARTED
227
+ ):
228
+ return True
229
+ return False
230
+
231
+ def record_invocation_completion(
232
+ self, start_timestamp: datetime, end_timestamp: datetime, request_id: str
233
+ ) -> None:
234
+ """Record an invocation completion event."""
235
+ self.invocation_completions.append(
236
+ InvocationCompletedDetails(
237
+ start_timestamp=start_timestamp,
238
+ end_timestamp=end_timestamp,
239
+ request_id=request_id,
240
+ )
241
+ )
242
+
243
+ def complete_success(self, result: str | None) -> None:
244
+ """Complete execution successfully (DecisionType.COMPLETE_WORKFLOW_EXECUTION)."""
245
+ self.result = DurableExecutionInvocationOutput(
246
+ status=InvocationStatus.SUCCEEDED, result=result
247
+ )
248
+ self.is_complete = True
249
+ self.close_status = ExecutionStatus.SUCCEEDED
250
+ self._end_execution(OperationStatus.SUCCEEDED)
251
+
252
+ def complete_fail(self, error: ErrorObject) -> None:
253
+ """Complete execution with failure (DecisionType.FAIL_WORKFLOW_EXECUTION)."""
254
+ self.result = DurableExecutionInvocationOutput(
255
+ status=InvocationStatus.FAILED, error=error
256
+ )
257
+ self.is_complete = True
258
+ self.close_status = ExecutionStatus.FAILED
259
+ self._end_execution(OperationStatus.FAILED)
260
+
261
+ def complete_timeout(self, error: ErrorObject) -> None:
262
+ """Complete execution with timeout."""
263
+ self.result = DurableExecutionInvocationOutput(
264
+ status=InvocationStatus.FAILED, error=error
265
+ )
266
+ self.is_complete = True
267
+ self.close_status = ExecutionStatus.TIMED_OUT
268
+ self._end_execution(OperationStatus.TIMED_OUT)
269
+
270
+ def complete_stopped(self, error: ErrorObject) -> None:
271
+ """Complete execution as terminated (TerminateWorkflowExecutionV2Request)."""
272
+ self.result = DurableExecutionInvocationOutput(
273
+ status=InvocationStatus.FAILED, error=error
274
+ )
275
+ self.is_complete = True
276
+ self.close_status = ExecutionStatus.STOPPED
277
+ self._end_execution(OperationStatus.STOPPED)
278
+
279
+ def find_operation(self, operation_id: str) -> tuple[int, Operation]:
280
+ """Find operation by ID, return index and operation."""
281
+ for i, operation in enumerate(self.operations):
282
+ if operation.operation_id == operation_id:
283
+ return i, operation
284
+ msg: str = f"Attempting to update state of an Operation [{operation_id}] that doesn't exist"
285
+ raise IllegalStateException(msg)
286
+
287
+ def find_callback_operation(self, callback_id: str) -> tuple[int, Operation]:
288
+ """Find callback operation by callback_id, return index and operation."""
289
+ for i, operation in enumerate(self.operations):
290
+ if (
291
+ operation.operation_type == OperationType.CALLBACK
292
+ and operation.callback_details
293
+ and operation.callback_details.callback_id == callback_id
294
+ ):
295
+ return i, operation
296
+ msg: str = f"Callback operation with callback_id [{callback_id}] not found"
297
+ raise IllegalStateException(msg)
298
+
299
+ def complete_wait(self, operation_id: str) -> Operation:
300
+ """Complete WAIT operation when timer fires."""
301
+ index, operation = self.find_operation(operation_id)
302
+
303
+ # Validate
304
+ if operation.status != OperationStatus.STARTED:
305
+ msg_wait_not_started: str = f"Attempting to transition a Wait Operation[{operation_id}] to SUCCEEDED when it's not STARTED"
306
+ raise IllegalStateException(msg_wait_not_started)
307
+ if operation.operation_type != OperationType.WAIT:
308
+ msg_not_wait: str = (
309
+ f"Expected WAIT operation, got {operation.operation_type}"
310
+ )
311
+ raise IllegalStateException(msg_not_wait)
312
+
313
+ # Thread-safe increment sequence and operation update
314
+ with self._state_lock:
315
+ self._token_sequence += 1
316
+ # Build and assign updated operation
317
+ self.operations[index] = replace(
318
+ operation,
319
+ status=OperationStatus.SUCCEEDED,
320
+ end_timestamp=datetime.now(UTC),
321
+ )
322
+ return self.operations[index]
323
+
324
+ def complete_retry(self, operation_id: str) -> Operation:
325
+ """Complete STEP retry when timer fires."""
326
+ index, operation = self.find_operation(operation_id)
327
+
328
+ # Validate
329
+ if operation.status != OperationStatus.PENDING:
330
+ msg_step_not_pending: str = f"Attempting to transition a Step Operation[{operation_id}] to READY when it's not PENDING"
331
+ raise IllegalStateException(msg_step_not_pending)
332
+ if operation.operation_type != OperationType.STEP:
333
+ msg_not_step: str = (
334
+ f"Expected STEP operation, got {operation.operation_type}"
335
+ )
336
+ raise IllegalStateException(msg_not_step)
337
+
338
+ # Thread-safe increment sequence and operation update
339
+ with self._state_lock:
340
+ self._token_sequence += 1
341
+ # Build updated step_details with cleared next_attempt_timestamp
342
+ new_step_details = None
343
+ if operation.step_details:
344
+ new_step_details = replace(
345
+ operation.step_details, next_attempt_timestamp=None
346
+ )
347
+
348
+ # Build updated operation
349
+ updated_operation = replace(
350
+ operation, status=OperationStatus.READY, step_details=new_step_details
351
+ )
352
+
353
+ # Assign
354
+ self.operations[index] = updated_operation
355
+ return updated_operation
356
+
357
+ def complete_callback_success(
358
+ self, callback_id: str, result: bytes | None = None
359
+ ) -> Operation:
360
+ """Complete CALLBACK operation with success."""
361
+ index, operation = self.find_callback_operation(callback_id)
362
+ if operation.status != OperationStatus.STARTED:
363
+ msg: str = f"Callback operation [{callback_id}] is not in STARTED state"
364
+ raise IllegalStateException(msg)
365
+
366
+ with self._state_lock:
367
+ self._token_sequence += 1
368
+ updated_callback_details = None
369
+ if operation.callback_details:
370
+ updated_callback_details = replace(
371
+ operation.callback_details,
372
+ result=result.decode() if result else None,
373
+ )
374
+
375
+ self.operations[index] = replace(
376
+ operation,
377
+ status=OperationStatus.SUCCEEDED,
378
+ end_timestamp=datetime.now(UTC),
379
+ callback_details=updated_callback_details,
380
+ )
381
+ return self.operations[index]
382
+
383
+ def complete_callback_failure(
384
+ self, callback_id: str, error: ErrorObject
385
+ ) -> Operation:
386
+ """Complete CALLBACK operation with failure."""
387
+ index, operation = self.find_callback_operation(callback_id)
388
+
389
+ if operation.status != OperationStatus.STARTED:
390
+ msg: str = f"Callback operation [{callback_id}] is not in STARTED state"
391
+ raise IllegalStateException(msg)
392
+
393
+ with self._state_lock:
394
+ self._token_sequence += 1
395
+ updated_callback_details = None
396
+ if operation.callback_details:
397
+ updated_callback_details = replace(
398
+ operation.callback_details, error=error
399
+ )
400
+
401
+ self.operations[index] = replace(
402
+ operation,
403
+ status=OperationStatus.FAILED,
404
+ end_timestamp=datetime.now(UTC),
405
+ callback_details=updated_callback_details,
406
+ )
407
+ return self.operations[index]
408
+
409
+ def complete_callback_timeout(
410
+ self, callback_id: str, error: ErrorObject
411
+ ) -> Operation:
412
+ """Complete CALLBACK operation with timeout."""
413
+ index, operation = self.find_callback_operation(callback_id)
414
+
415
+ if operation.status != OperationStatus.STARTED:
416
+ msg: str = f"Callback operation [{callback_id}] is not in STARTED state"
417
+ raise IllegalStateException(msg)
418
+
419
+ with self._state_lock:
420
+ self._token_sequence += 1
421
+ updated_callback_details = None
422
+ if operation.callback_details:
423
+ updated_callback_details = replace(
424
+ operation.callback_details, error=error
425
+ )
426
+
427
+ self.operations[index] = replace(
428
+ operation,
429
+ status=OperationStatus.TIMED_OUT,
430
+ end_timestamp=datetime.now(UTC),
431
+ callback_details=updated_callback_details,
432
+ )
433
+ return self.operations[index]
434
+
435
+ def _end_execution(self, status: OperationStatus) -> None:
436
+ """Set the end_timestamp on the main EXECUTION operation when execution completes."""
437
+ execution_op: Operation = self.get_operation_execution_started()
438
+ if execution_op.operation_type == OperationType.EXECUTION:
439
+ with self._state_lock:
440
+ self.operations[0] = replace(
441
+ execution_op,
442
+ status=status,
443
+ end_timestamp=datetime.now(UTC),
444
+ )