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.
Files changed (21) hide show
  1. aws_durable_execution_sdk_python/__about__.py +1 -1
  2. aws_durable_execution_sdk_python/__init__.py +2 -0
  3. aws_durable_execution_sdk_python/concurrency/executor.py +45 -30
  4. aws_durable_execution_sdk_python/context.py +85 -22
  5. aws_durable_execution_sdk_python/exceptions.py +29 -0
  6. aws_durable_execution_sdk_python/execution.py +1 -1
  7. aws_durable_execution_sdk_python/lambda_service.py +8 -37
  8. aws_durable_execution_sdk_python/operation/base.py +187 -0
  9. aws_durable_execution_sdk_python/operation/callback.py +105 -46
  10. aws_durable_execution_sdk_python/operation/child.py +231 -116
  11. aws_durable_execution_sdk_python/operation/invoke.py +133 -80
  12. aws_durable_execution_sdk_python/operation/step.py +290 -201
  13. aws_durable_execution_sdk_python/operation/wait.py +86 -28
  14. aws_durable_execution_sdk_python/operation/wait_for_condition.py +206 -159
  15. aws_durable_execution_sdk_python/state.py +8 -1
  16. {aws_durable_execution_sdk_python-1.0.0.dist-info → aws_durable_execution_sdk_python-1.1.0.dist-info}/METADATA +5 -3
  17. {aws_durable_execution_sdk_python-1.0.0.dist-info → aws_durable_execution_sdk_python-1.1.0.dist-info}/RECORD +20 -20
  18. aws_durable_execution_sdk_python/botocore/data/lambdainternal/2015-03-31/service-2.json +0 -7864
  19. {aws_durable_execution_sdk_python-1.0.0.dist-info → aws_durable_execution_sdk_python-1.1.0.dist-info}/WHEEL +0 -0
  20. {aws_durable_execution_sdk_python-1.0.0.dist-info → aws_durable_execution_sdk_python-1.1.0.dist-info}/licenses/LICENSE +0 -0
  21. {aws_durable_execution_sdk_python-1.0.0.dist-info → aws_durable_execution_sdk_python-1.1.0.dist-info}/licenses/NOTICE +0 -0
@@ -1,4 +1,4 @@
1
1
  # SPDX-FileCopyrightText: 2025-present Amazon.com, Inc. or its affiliates.
2
2
  #
3
3
  # SPDX-License-Identifier: Apache-2.0
4
- __version__ = "1.0.0"
4
+ __version__ = "1.1.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
- 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)
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
- def on_done(future: Future) -> None:
216
- self._on_task_complete(executable_with_state, future, scheduler)
215
+ def on_done(future: Future) -> None:
216
+ self._on_task_complete(executable_with_state, future, scheduler)
217
217
 
218
- future.add_done_callback(on_done)
219
- return future
218
+ future.add_done_callback(on_done)
219
+ return future
220
220
 
221
- # Submit initial tasks
222
- futures = [
223
- submit_task(exe_state) for exe_state in self.executables_with_state
224
- ]
221
+ # Submit initial tasks
222
+ futures = [
223
+ submit_task(exe_state) for exe_state in self.executables_with_state
224
+ ]
225
225
 
226
- # Wait for completion
227
- self._completion_event.wait()
226
+ # Wait for completion
227
+ self._completion_event.wait()
228
228
 
229
- # Cancel remaining futures so
230
- # that we don't wait for them to join.
231
- for future in futures:
232
- future.cancel()
229
+ # Cancel futures that haven't started yet
230
+ for future in futures:
231
+ future.cancel()
233
232
 
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
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
- create_callback_handler,
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 invoke_handler
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 step_handler
35
- from aws_durable_execution_sdk_python.operation.wait import wait_handler
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
- wait_for_condition_handler,
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
- checkpointed_result.raise_callable_error()
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
- callback_id: str = create_callback_handler(
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
- result: R = invoke_handler(
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
- result: T = step_handler(
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
- wait_handler(
485
- seconds=seconds,
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
- result: T = wait_for_condition_handler(
537
- check=check,
538
- config=config,
539
- state=self.state,
540
- operation_identifier=OperationIdentifier(
541
- operation_id=operation_id,
542
- parent_id=self._parent_id,
543
- name=name,
544
- ),
545
- context_logger=self.logger,
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
@@ -237,7 +237,7 @@ def durable_execution(
237
237
  service_client = (
238
238
  LambdaClient(client=boto3_client)
239
239
  if boto3_client is not None
240
- else LambdaClient.initialize_from_env()
240
+ else LambdaClient.initialize_client()
241
241
  )
242
242
 
243
243
  raw_input_payload: str | None = (
@@ -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("chained_invoke_details"):
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 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")
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)