aws-durable-execution-sdk-python 1.0.0__py3-none-any.whl → 1.1.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.
- aws_durable_execution_sdk_python/__about__.py +1 -1
- aws_durable_execution_sdk_python/__init__.py +2 -0
- aws_durable_execution_sdk_python/concurrency/executor.py +45 -30
- aws_durable_execution_sdk_python/context.py +85 -22
- aws_durable_execution_sdk_python/exceptions.py +29 -0
- aws_durable_execution_sdk_python/execution.py +1 -1
- aws_durable_execution_sdk_python/lambda_service.py +8 -37
- aws_durable_execution_sdk_python/operation/base.py +187 -0
- aws_durable_execution_sdk_python/operation/callback.py +105 -46
- aws_durable_execution_sdk_python/operation/child.py +231 -116
- aws_durable_execution_sdk_python/operation/invoke.py +133 -80
- aws_durable_execution_sdk_python/operation/step.py +290 -201
- aws_durable_execution_sdk_python/operation/wait.py +86 -28
- aws_durable_execution_sdk_python/operation/wait_for_condition.py +206 -159
- aws_durable_execution_sdk_python/state.py +8 -1
- {aws_durable_execution_sdk_python-1.0.0.dist-info → aws_durable_execution_sdk_python-1.1.0.dist-info}/METADATA +5 -3
- {aws_durable_execution_sdk_python-1.0.0.dist-info → aws_durable_execution_sdk_python-1.1.0.dist-info}/RECORD +20 -20
- aws_durable_execution_sdk_python/botocore/data/lambdainternal/2015-03-31/service-2.json +0 -7864
- {aws_durable_execution_sdk_python-1.0.0.dist-info → aws_durable_execution_sdk_python-1.1.0.dist-info}/WHEEL +0 -0
- {aws_durable_execution_sdk_python-1.0.0.dist-info → aws_durable_execution_sdk_python-1.1.0.dist-info}/licenses/LICENSE +0 -0
- {aws_durable_execution_sdk_python-1.0.0.dist-info → aws_durable_execution_sdk_python-1.1.0.dist-info}/licenses/NOTICE +0 -0
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
from aws_durable_execution_sdk_python.context import (
|
|
6
6
|
DurableContext,
|
|
7
7
|
durable_step,
|
|
8
|
+
durable_wait_for_callback,
|
|
8
9
|
durable_with_child_context,
|
|
9
10
|
)
|
|
10
11
|
|
|
@@ -30,5 +31,6 @@ __all__ = [
|
|
|
30
31
|
"ValidationError",
|
|
31
32
|
"durable_execution",
|
|
32
33
|
"durable_step",
|
|
34
|
+
"durable_wait_for_callback",
|
|
33
35
|
"durable_with_child_context",
|
|
34
36
|
]
|
|
@@ -22,6 +22,7 @@ from aws_durable_execution_sdk_python.concurrency.models import (
|
|
|
22
22
|
)
|
|
23
23
|
from aws_durable_execution_sdk_python.config import ChildConfig
|
|
24
24
|
from aws_durable_execution_sdk_python.exceptions import (
|
|
25
|
+
OrphanedChildException,
|
|
25
26
|
SuspendExecution,
|
|
26
27
|
TimedSuspendExecution,
|
|
27
28
|
)
|
|
@@ -198,42 +199,47 @@ class ConcurrentExecutor(ABC, Generic[CallableType, ResultType]):
|
|
|
198
199
|
execution_state.create_checkpoint()
|
|
199
200
|
submit_task(executable_with_state)
|
|
200
201
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
executable_with_state.run(future)
|
|
202
|
+
thread_executor = ThreadPoolExecutor(max_workers=max_workers)
|
|
203
|
+
try:
|
|
204
|
+
with TimerScheduler(resubmitter) as scheduler:
|
|
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
214
|
|
|
215
|
-
|
|
216
|
-
|
|
215
|
+
def on_done(future: Future) -> None:
|
|
216
|
+
self._on_task_complete(executable_with_state, future, scheduler)
|
|
217
217
|
|
|
218
|
-
|
|
219
|
-
|
|
218
|
+
future.add_done_callback(on_done)
|
|
219
|
+
return future
|
|
220
220
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
221
|
+
# Submit initial tasks
|
|
222
|
+
futures = [
|
|
223
|
+
submit_task(exe_state) for exe_state in self.executables_with_state
|
|
224
|
+
]
|
|
225
225
|
|
|
226
|
-
|
|
227
|
-
|
|
226
|
+
# Wait for completion
|
|
227
|
+
self._completion_event.wait()
|
|
228
228
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
future.cancel()
|
|
229
|
+
# Cancel futures that haven't started yet
|
|
230
|
+
for future in futures:
|
|
231
|
+
future.cancel()
|
|
233
232
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
233
|
+
# Suspend execution if everything done and at least one of the tasks raised a suspend exception.
|
|
234
|
+
if self._suspend_exception:
|
|
235
|
+
raise self._suspend_exception
|
|
236
|
+
|
|
237
|
+
finally:
|
|
238
|
+
# Shutdown without waiting for running threads for early return when
|
|
239
|
+
# completion criteria are met (e.g., min_successful).
|
|
240
|
+
# Running threads will continue in background but they raise OrphanedChildException
|
|
241
|
+
# on the next attempt to checkpoint.
|
|
242
|
+
thread_executor.shutdown(wait=False, cancel_futures=True)
|
|
237
243
|
|
|
238
244
|
# Build final result
|
|
239
245
|
return self._create_result()
|
|
@@ -291,6 +297,15 @@ class ConcurrentExecutor(ABC, Generic[CallableType, ResultType]):
|
|
|
291
297
|
result = future.result()
|
|
292
298
|
exe_state.complete(result)
|
|
293
299
|
self.counters.complete_task()
|
|
300
|
+
except OrphanedChildException:
|
|
301
|
+
# Parent already completed and returned.
|
|
302
|
+
# State is already RUNNING, which _create_result() marked as STARTED
|
|
303
|
+
# Just log and exit - no state change needed
|
|
304
|
+
logger.debug(
|
|
305
|
+
"Terminating orphaned branch %s without error because parent has completed already",
|
|
306
|
+
exe_state.index,
|
|
307
|
+
)
|
|
308
|
+
return
|
|
294
309
|
except TimedSuspendExecution as tse:
|
|
295
310
|
exe_state.suspend_with_timeout(tse.scheduled_timestamp)
|
|
296
311
|
scheduler.schedule_resume(exe_state, tse.scheduled_timestamp)
|
|
@@ -24,17 +24,17 @@ from aws_durable_execution_sdk_python.identifier import OperationIdentifier
|
|
|
24
24
|
from aws_durable_execution_sdk_python.lambda_service import OperationSubType
|
|
25
25
|
from aws_durable_execution_sdk_python.logger import Logger, LogInfo
|
|
26
26
|
from aws_durable_execution_sdk_python.operation.callback import (
|
|
27
|
-
|
|
27
|
+
CallbackOperationExecutor,
|
|
28
28
|
wait_for_callback_handler,
|
|
29
29
|
)
|
|
30
30
|
from aws_durable_execution_sdk_python.operation.child import child_handler
|
|
31
|
-
from aws_durable_execution_sdk_python.operation.invoke import
|
|
31
|
+
from aws_durable_execution_sdk_python.operation.invoke import InvokeOperationExecutor
|
|
32
32
|
from aws_durable_execution_sdk_python.operation.map import map_handler
|
|
33
33
|
from aws_durable_execution_sdk_python.operation.parallel import parallel_handler
|
|
34
|
-
from aws_durable_execution_sdk_python.operation.step import
|
|
35
|
-
from aws_durable_execution_sdk_python.operation.wait import
|
|
34
|
+
from aws_durable_execution_sdk_python.operation.step import StepOperationExecutor
|
|
35
|
+
from aws_durable_execution_sdk_python.operation.wait import WaitOperationExecutor
|
|
36
36
|
from aws_durable_execution_sdk_python.operation.wait_for_condition import (
|
|
37
|
-
|
|
37
|
+
WaitForConditionOperationExecutor,
|
|
38
38
|
)
|
|
39
39
|
from aws_durable_execution_sdk_python.serdes import (
|
|
40
40
|
PassThroughSerDes,
|
|
@@ -104,6 +104,52 @@ def durable_with_child_context(
|
|
|
104
104
|
return wrapper
|
|
105
105
|
|
|
106
106
|
|
|
107
|
+
def durable_wait_for_callback(
|
|
108
|
+
func: Callable[Concatenate[str, WaitForCallbackContext, Params], T],
|
|
109
|
+
) -> Callable[Params, Callable[[str, WaitForCallbackContext], T]]:
|
|
110
|
+
"""Wrap your callable into a wait_for_callback submitter function.
|
|
111
|
+
|
|
112
|
+
This decorator allows you to define a submitter function with additional
|
|
113
|
+
parameters that will be bound when called.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
func: A callable that takes callback_id, context, and additional parameters
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
A wrapper function that binds the additional parameters and returns
|
|
120
|
+
a submitter function compatible with wait_for_callback
|
|
121
|
+
|
|
122
|
+
Example:
|
|
123
|
+
@durable_wait_for_callback
|
|
124
|
+
def submit_to_external_system(
|
|
125
|
+
callback_id: str,
|
|
126
|
+
context: WaitForCallbackContext,
|
|
127
|
+
task_name: str,
|
|
128
|
+
priority: int
|
|
129
|
+
):
|
|
130
|
+
context.logger.info(f"Submitting {task_name} with callback {callback_id}")
|
|
131
|
+
external_api.submit_task(
|
|
132
|
+
task_name=task_name,
|
|
133
|
+
priority=priority,
|
|
134
|
+
callback_id=callback_id
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
# Usage in durable handler:
|
|
138
|
+
result = context.wait_for_callback(
|
|
139
|
+
submit_to_external_system("my_task", priority=5)
|
|
140
|
+
)
|
|
141
|
+
"""
|
|
142
|
+
|
|
143
|
+
def wrapper(*args, **kwargs):
|
|
144
|
+
def submitter_with_arguments(callback_id: str, context: WaitForCallbackContext):
|
|
145
|
+
return func(callback_id, context, *args, **kwargs)
|
|
146
|
+
|
|
147
|
+
submitter_with_arguments._original_name = func.__name__ # noqa: SLF001
|
|
148
|
+
return submitter_with_arguments
|
|
149
|
+
|
|
150
|
+
return wrapper
|
|
151
|
+
|
|
152
|
+
|
|
107
153
|
class Callback(Generic[T], CallbackProtocol[T]): # noqa: PYI059
|
|
108
154
|
"""A future that will block on result() until callback_id returns."""
|
|
109
155
|
|
|
@@ -136,7 +182,7 @@ class Callback(Generic[T], CallbackProtocol[T]): # noqa: PYI059
|
|
|
136
182
|
|
|
137
183
|
if not checkpointed_result.is_existent():
|
|
138
184
|
msg = "Callback operation must exist"
|
|
139
|
-
raise CallbackError(msg)
|
|
185
|
+
raise CallbackError(message=msg, callback_id=self.callback_id)
|
|
140
186
|
|
|
141
187
|
if (
|
|
142
188
|
checkpointed_result.is_failed()
|
|
@@ -144,7 +190,12 @@ class Callback(Generic[T], CallbackProtocol[T]): # noqa: PYI059
|
|
|
144
190
|
or checkpointed_result.is_timed_out()
|
|
145
191
|
or checkpointed_result.is_stopped()
|
|
146
192
|
):
|
|
147
|
-
|
|
193
|
+
msg = (
|
|
194
|
+
checkpointed_result.error.message
|
|
195
|
+
if checkpointed_result.error and checkpointed_result.error.message
|
|
196
|
+
else "Callback failed"
|
|
197
|
+
)
|
|
198
|
+
raise CallbackError(message=msg, callback_id=self.callback_id)
|
|
148
199
|
|
|
149
200
|
if checkpointed_result.is_succeeded():
|
|
150
201
|
if checkpointed_result.result is None:
|
|
@@ -272,13 +323,14 @@ class DurableContext(DurableContextProtocol):
|
|
|
272
323
|
if not config:
|
|
273
324
|
config = CallbackConfig()
|
|
274
325
|
operation_id: str = self._create_step_id()
|
|
275
|
-
|
|
326
|
+
executor: CallbackOperationExecutor = CallbackOperationExecutor(
|
|
276
327
|
state=self.state,
|
|
277
328
|
operation_identifier=OperationIdentifier(
|
|
278
329
|
operation_id=operation_id, parent_id=self._parent_id, name=name
|
|
279
330
|
),
|
|
280
331
|
config=config,
|
|
281
332
|
)
|
|
333
|
+
callback_id: str = executor.process()
|
|
282
334
|
result: Callback = Callback(
|
|
283
335
|
callback_id=callback_id,
|
|
284
336
|
operation_id=operation_id,
|
|
@@ -306,8 +358,10 @@ class DurableContext(DurableContextProtocol):
|
|
|
306
358
|
Returns:
|
|
307
359
|
The result of the invoked function
|
|
308
360
|
"""
|
|
361
|
+
if not config:
|
|
362
|
+
config = InvokeConfig[P, R]()
|
|
309
363
|
operation_id = self._create_step_id()
|
|
310
|
-
|
|
364
|
+
executor: InvokeOperationExecutor[R] = InvokeOperationExecutor(
|
|
311
365
|
function_name=function_name,
|
|
312
366
|
payload=payload,
|
|
313
367
|
state=self.state,
|
|
@@ -318,6 +372,7 @@ class DurableContext(DurableContextProtocol):
|
|
|
318
372
|
),
|
|
319
373
|
config=config,
|
|
320
374
|
)
|
|
375
|
+
result: R = executor.process()
|
|
321
376
|
self.state.track_replay(operation_id=operation_id)
|
|
322
377
|
return result
|
|
323
378
|
|
|
@@ -454,8 +509,10 @@ class DurableContext(DurableContextProtocol):
|
|
|
454
509
|
) -> T:
|
|
455
510
|
step_name = self._resolve_step_name(name, func)
|
|
456
511
|
logger.debug("Step name: %s", step_name)
|
|
512
|
+
if not config:
|
|
513
|
+
config = StepConfig()
|
|
457
514
|
operation_id = self._create_step_id()
|
|
458
|
-
|
|
515
|
+
executor: StepOperationExecutor[T] = StepOperationExecutor(
|
|
459
516
|
func=func,
|
|
460
517
|
config=config,
|
|
461
518
|
state=self.state,
|
|
@@ -466,6 +523,7 @@ class DurableContext(DurableContextProtocol):
|
|
|
466
523
|
),
|
|
467
524
|
context_logger=self.logger,
|
|
468
525
|
)
|
|
526
|
+
result: T = executor.process()
|
|
469
527
|
self.state.track_replay(operation_id=operation_id)
|
|
470
528
|
return result
|
|
471
529
|
|
|
@@ -481,8 +539,9 @@ class DurableContext(DurableContextProtocol):
|
|
|
481
539
|
msg = "duration must be at least 1 second"
|
|
482
540
|
raise ValidationError(msg)
|
|
483
541
|
operation_id = self._create_step_id()
|
|
484
|
-
|
|
485
|
-
|
|
542
|
+
wait_seconds = duration.seconds
|
|
543
|
+
executor: WaitOperationExecutor = WaitOperationExecutor(
|
|
544
|
+
seconds=wait_seconds,
|
|
486
545
|
state=self.state,
|
|
487
546
|
operation_identifier=OperationIdentifier(
|
|
488
547
|
operation_id=operation_id,
|
|
@@ -490,6 +549,7 @@ class DurableContext(DurableContextProtocol):
|
|
|
490
549
|
name=name,
|
|
491
550
|
),
|
|
492
551
|
)
|
|
552
|
+
executor.process()
|
|
493
553
|
self.state.track_replay(operation_id=operation_id)
|
|
494
554
|
|
|
495
555
|
def wait_for_callback(
|
|
@@ -533,17 +593,20 @@ class DurableContext(DurableContextProtocol):
|
|
|
533
593
|
raise ValidationError(msg)
|
|
534
594
|
|
|
535
595
|
operation_id = self._create_step_id()
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
596
|
+
executor: WaitForConditionOperationExecutor[T] = (
|
|
597
|
+
WaitForConditionOperationExecutor(
|
|
598
|
+
check=check,
|
|
599
|
+
config=config,
|
|
600
|
+
state=self.state,
|
|
601
|
+
operation_identifier=OperationIdentifier(
|
|
602
|
+
operation_id=operation_id,
|
|
603
|
+
parent_id=self._parent_id,
|
|
604
|
+
name=name,
|
|
605
|
+
),
|
|
606
|
+
context_logger=self.logger,
|
|
607
|
+
)
|
|
546
608
|
)
|
|
609
|
+
result: T = executor.process()
|
|
547
610
|
self.state.track_replay(operation_id=operation_id)
|
|
548
611
|
return result
|
|
549
612
|
|
|
@@ -372,3 +372,32 @@ class CallableRuntimeErrorSerializableDetails:
|
|
|
372
372
|
|
|
373
373
|
class SerDesError(DurableExecutionsError):
|
|
374
374
|
"""Raised when serialization fails."""
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
class OrphanedChildException(BaseException):
|
|
378
|
+
"""Raised when a child operation attempts to checkpoint after its parent context has completed.
|
|
379
|
+
|
|
380
|
+
This exception inherits from BaseException (not Exception) so that user-space doesn't
|
|
381
|
+
accidentally catch it with broad exception handlers like 'except Exception'.
|
|
382
|
+
|
|
383
|
+
This exception will happen when a parallel branch or map item tries to create a checkpoint
|
|
384
|
+
after its parent context (i.e the parallel/map operation) has already completed due to meeting
|
|
385
|
+
completion criteria (e.g., min_successful reached, failure tolerance exceeded).
|
|
386
|
+
|
|
387
|
+
Although you cannot cancel running futures in user-space, this will at least terminate the
|
|
388
|
+
child operation on the next checkpoint attempt, preventing subsequent operations in the
|
|
389
|
+
child scope from executing.
|
|
390
|
+
|
|
391
|
+
Attributes:
|
|
392
|
+
operation_id: Operation ID of the orphaned child
|
|
393
|
+
"""
|
|
394
|
+
|
|
395
|
+
def __init__(self, message: str, operation_id: str):
|
|
396
|
+
"""Initialize OrphanedChildException.
|
|
397
|
+
|
|
398
|
+
Args:
|
|
399
|
+
message: Human-readable error message
|
|
400
|
+
operation_id: Operation ID of the orphaned child (required)
|
|
401
|
+
"""
|
|
402
|
+
super().__init__(message)
|
|
403
|
+
self.operation_id = operation_id
|
|
@@ -2,10 +2,8 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import datetime
|
|
4
4
|
import logging
|
|
5
|
-
import os
|
|
6
5
|
from dataclasses import dataclass, field
|
|
7
6
|
from enum import Enum
|
|
8
|
-
from pathlib import Path
|
|
9
7
|
from typing import TYPE_CHECKING, Any, Protocol, TypeAlias
|
|
10
8
|
|
|
11
9
|
import boto3 # type: ignore
|
|
@@ -751,7 +749,7 @@ class Operation:
|
|
|
751
749
|
callback_details = CallbackDetails.from_dict(callback_details_input)
|
|
752
750
|
|
|
753
751
|
chained_invoke_details = None
|
|
754
|
-
if chained_invoke_details := data.get("
|
|
752
|
+
if chained_invoke_details := data.get("ChainedInvokeDetails"):
|
|
755
753
|
chained_invoke_details = ChainedInvokeDetails.from_dict(
|
|
756
754
|
chained_invoke_details
|
|
757
755
|
)
|
|
@@ -943,41 +941,14 @@ class LambdaClient(DurableServiceClient):
|
|
|
943
941
|
self.client = client
|
|
944
942
|
|
|
945
943
|
@staticmethod
|
|
946
|
-
def
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
944
|
+
def initialize_client() -> LambdaClient:
|
|
945
|
+
client = boto3.client(
|
|
946
|
+
"lambda",
|
|
947
|
+
config=Config(
|
|
948
|
+
connect_timeout=5,
|
|
949
|
+
read_timeout=50,
|
|
950
|
+
),
|
|
952
951
|
)
|
|
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
952
|
return LambdaClient(client=client)
|
|
982
953
|
|
|
983
954
|
def checkpoint(
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Base classes for operation executors with checkpoint response handling."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import TYPE_CHECKING, Generic, TypeVar
|
|
8
|
+
|
|
9
|
+
from aws_durable_execution_sdk_python.exceptions import InvalidStateError
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from aws_durable_execution_sdk_python.state import CheckpointedResult
|
|
13
|
+
|
|
14
|
+
T = TypeVar("T")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class CheckResult(Generic[T]):
|
|
19
|
+
"""Result of checking operation checkpoint status.
|
|
20
|
+
|
|
21
|
+
Encapsulates the outcome of checking an operation's status and determines
|
|
22
|
+
the next action in the operation execution flow.
|
|
23
|
+
|
|
24
|
+
IMPORTANT: Do not construct directly. Use factory methods:
|
|
25
|
+
- create_is_ready_to_execute(checkpoint) - operation ready to execute
|
|
26
|
+
- create_started() - checkpoint created, check status again
|
|
27
|
+
- create_completed(result) - terminal result available
|
|
28
|
+
|
|
29
|
+
Attributes:
|
|
30
|
+
is_ready_to_execute: True if the operation is ready to execute its logic
|
|
31
|
+
has_checkpointed_result: True if a terminal result is already available
|
|
32
|
+
checkpointed_result: Checkpoint data for execute()
|
|
33
|
+
deserialized_result: Final result when operation completed
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
is_ready_to_execute: bool
|
|
37
|
+
has_checkpointed_result: bool
|
|
38
|
+
checkpointed_result: CheckpointedResult | None = None
|
|
39
|
+
deserialized_result: T | None = None
|
|
40
|
+
|
|
41
|
+
@classmethod
|
|
42
|
+
def create_is_ready_to_execute(
|
|
43
|
+
cls, checkpoint: CheckpointedResult
|
|
44
|
+
) -> CheckResult[T]:
|
|
45
|
+
"""Create a CheckResult indicating the operation is ready to execute.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
checkpoint: The checkpoint data to pass to execute()
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
CheckResult with is_ready_to_execute=True
|
|
52
|
+
"""
|
|
53
|
+
return cls(
|
|
54
|
+
is_ready_to_execute=True,
|
|
55
|
+
has_checkpointed_result=False,
|
|
56
|
+
checkpointed_result=checkpoint,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
@classmethod
|
|
60
|
+
def create_started(cls) -> CheckResult[T]:
|
|
61
|
+
"""Create a CheckResult signaling that a checkpoint was created.
|
|
62
|
+
|
|
63
|
+
Signals that process() should verify checkpoint status again to detect
|
|
64
|
+
if the operation completed already during checkpoint creation.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
CheckResult indicating process() should check status again
|
|
68
|
+
"""
|
|
69
|
+
return cls(is_ready_to_execute=False, has_checkpointed_result=False)
|
|
70
|
+
|
|
71
|
+
@classmethod
|
|
72
|
+
def create_completed(cls, result: T) -> CheckResult[T]:
|
|
73
|
+
"""Create a CheckResult with a terminal result already deserialized.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
result: The final deserialized result
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
CheckResult with has_checkpointed_result=True and deserialized_result set
|
|
80
|
+
"""
|
|
81
|
+
return cls(
|
|
82
|
+
is_ready_to_execute=False,
|
|
83
|
+
has_checkpointed_result=True,
|
|
84
|
+
deserialized_result=result,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class OperationExecutor(ABC, Generic[T]):
|
|
89
|
+
"""Base class for durable operations with checkpoint response handling.
|
|
90
|
+
|
|
91
|
+
Provides a framework for implementing operations that check status after
|
|
92
|
+
creating START checkpoints to handle synchronous completion, avoiding
|
|
93
|
+
unnecessary execution or suspension.
|
|
94
|
+
|
|
95
|
+
The common pattern:
|
|
96
|
+
1. Check operation status
|
|
97
|
+
2. Create START checkpoint if needed
|
|
98
|
+
3. Check status again (detects synchronous completion)
|
|
99
|
+
4. Execute operation logic when ready
|
|
100
|
+
|
|
101
|
+
Subclasses must implement:
|
|
102
|
+
- check_result_status(): Check status, create checkpoint if needed, return next action
|
|
103
|
+
- execute(): Execute the operation logic with checkpoint data
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
@abstractmethod
|
|
107
|
+
def check_result_status(self) -> CheckResult[T]:
|
|
108
|
+
"""Check operation status and create START checkpoint if needed.
|
|
109
|
+
|
|
110
|
+
Called twice by process() when creating synchronous checkpoints: once before
|
|
111
|
+
and once after, to detect if the operation completed immediately.
|
|
112
|
+
|
|
113
|
+
This method should:
|
|
114
|
+
1. Get the current checkpoint result
|
|
115
|
+
2. Check for terminal statuses (SUCCEEDED, FAILED, etc.) and handle them
|
|
116
|
+
3. Check for pending statuses and suspend if needed
|
|
117
|
+
4. Create a START checkpoint if the operation hasn't started
|
|
118
|
+
5. Return a CheckResult indicating the next action
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
CheckResult indicating whether to:
|
|
122
|
+
- Return a terminal result (has_checkpointed_result=True)
|
|
123
|
+
- Execute operation logic (is_ready_to_execute=True)
|
|
124
|
+
- Check status again (neither flag set - checkpoint was just created)
|
|
125
|
+
|
|
126
|
+
Raises:
|
|
127
|
+
Operation-specific exceptions for terminal failure states
|
|
128
|
+
SuspendExecution for pending states
|
|
129
|
+
"""
|
|
130
|
+
... # pragma: no cover
|
|
131
|
+
|
|
132
|
+
@abstractmethod
|
|
133
|
+
def execute(self, checkpointed_result: CheckpointedResult) -> T:
|
|
134
|
+
"""Execute operation logic with checkpoint data.
|
|
135
|
+
|
|
136
|
+
This method is called when the operation is ready to execute its core logic.
|
|
137
|
+
It receives the checkpoint data that was returned by check_result_status().
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
checkpointed_result: The checkpoint data containing operation state
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
The result of executing the operation
|
|
144
|
+
|
|
145
|
+
Raises:
|
|
146
|
+
May raise operation-specific errors during execution
|
|
147
|
+
"""
|
|
148
|
+
... # pragma: no cover
|
|
149
|
+
|
|
150
|
+
def process(self) -> T:
|
|
151
|
+
"""Process operation with checkpoint response handling.
|
|
152
|
+
|
|
153
|
+
Orchestrates the double-check pattern:
|
|
154
|
+
1. Check status (handles replay and existing checkpoints)
|
|
155
|
+
2. If checkpoint was just created, check status again (detects synchronous completion)
|
|
156
|
+
3. Return terminal result if available
|
|
157
|
+
4. Execute operation logic if ready
|
|
158
|
+
5. Raise error for invalid states
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
The final result of the operation
|
|
162
|
+
|
|
163
|
+
Raises:
|
|
164
|
+
InvalidStateError: If the check result is in an invalid state
|
|
165
|
+
May raise operation-specific errors from check_result_status() or execute()
|
|
166
|
+
"""
|
|
167
|
+
# Check 1: Entry (handles replay and existing checkpoints)
|
|
168
|
+
result = self.check_result_status()
|
|
169
|
+
|
|
170
|
+
# If checkpoint was created, verify checkpoint response for immediate status change
|
|
171
|
+
if not result.is_ready_to_execute and not result.has_checkpointed_result:
|
|
172
|
+
result = self.check_result_status()
|
|
173
|
+
|
|
174
|
+
# Return terminal result if available (can be None for operations that return None)
|
|
175
|
+
if result.has_checkpointed_result:
|
|
176
|
+
return result.deserialized_result # type: ignore[return-value]
|
|
177
|
+
|
|
178
|
+
# Execute operation logic
|
|
179
|
+
if result.is_ready_to_execute:
|
|
180
|
+
if result.checkpointed_result is None:
|
|
181
|
+
msg = "CheckResult is marked ready to execute but checkpointed result is not set."
|
|
182
|
+
raise InvalidStateError(msg)
|
|
183
|
+
return self.execute(result.checkpointed_result)
|
|
184
|
+
|
|
185
|
+
# Invalid state - neither terminal nor ready to execute
|
|
186
|
+
msg = "Invalid CheckResult state: neither terminal nor ready to execute"
|
|
187
|
+
raise InvalidStateError(msg)
|