aws-durable-execution-sdk-python 2.0.1__py3-none-any.whl → 3.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.
@@ -1,10 +1,11 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import contextlib
4
+ import contextvars
4
5
  import functools
5
6
  import json
6
7
  import logging
7
- from concurrent.futures import ThreadPoolExecutor
8
+ from concurrent.futures import Future, ThreadPoolExecutor, wait
8
9
  from dataclasses import dataclass, field
9
10
  from typing import TYPE_CHECKING, Any
10
11
 
@@ -27,7 +28,7 @@ from aws_durable_execution_sdk_python.lambda_service import (
27
28
  OperationUpdate,
28
29
  )
29
30
  from aws_durable_execution_sdk_python.plugin import (
30
- DurableInstrumentationPlugin,
31
+ DurableInstrumentationPluginFactory,
31
32
  PluginExecutor,
32
33
  )
33
34
  from aws_durable_execution_sdk_python.plugin_discovery import (
@@ -169,7 +170,7 @@ def durable_execution(
169
170
  func: Callable[[Any, DurableContext], Any] | None = None,
170
171
  *,
171
172
  boto3_client: Boto3LambdaClient | None = None,
172
- plugins: list[DurableInstrumentationPlugin] | None = None,
173
+ plugins: list[DurableInstrumentationPluginFactory] | None = None,
173
174
  ) -> Callable[[Any, LambdaContext], Any]:
174
175
  """
175
176
  Decorator to create a durable execution handler.
@@ -177,7 +178,10 @@ def durable_execution(
177
178
  Args:
178
179
  func: The user function to decorate
179
180
  boto3_client: Optional boto3 Lambda client to use
180
- plugins: Optional list of instrumentation plugins to use
181
+ plugins: Optional list of instrumentation plugin factories. Each
182
+ factory's ``create_plugin`` is called once per invocation with that
183
+ invocation's ``InvocationStartInfo``, and the instance it returns
184
+ serves only that invocation.
181
185
  """
182
186
  # Decorator called with parameters
183
187
  if func is None:
@@ -188,10 +192,13 @@ def durable_execution(
188
192
 
189
193
  logger.debug("Starting durable execution handler...")
190
194
 
191
- plugin_executor = PluginExecutor(load_configured_plugins(plugins))
195
+ configured_plugins = load_configured_plugins(plugins)
192
196
 
193
- @plugin_executor.handle_durable_output
197
+ @functools.wraps(func)
194
198
  def wrapper(event: Any, context: LambdaContext) -> MutableMapping[str, Any]:
199
+ # Only the factory list outlives an invocation. The worker below owns
200
+ # this fresh executor and all plugin lifecycle hooks.
201
+ plugin_executor = PluginExecutor(configured_plugins)
195
202
  invocation_input: DurableExecutionInvocationInput
196
203
  service_client: DurableServiceClient
197
204
 
@@ -279,88 +286,75 @@ def durable_execution(
279
286
  ),
280
287
  )
281
288
 
282
- # Use ThreadPoolExecutor for concurrent execution of user code and background checkpoint processing
283
- with (
284
- ThreadPoolExecutor(
285
- max_workers=2, thread_name_prefix="dex-handler"
286
- ) as executor,
287
- contextlib.closing(execution_state) as execution_state,
288
- ):
289
- execution_operation = execution_state.get_execution_operation()
290
-
291
- # execute the plugins
292
- plugin_executor.on_invocation_start(
293
- execution_arn=invocation_input.durable_execution_arn,
294
- lambda_context=context,
295
- execution_start_time=(
296
- execution_operation.start_timestamp
297
- if execution_operation is not None
298
- else None
299
- ),
300
- is_first_invocation=not has_prior_operations,
301
- execution_input=input_event,
302
- # Read the map through a callable rather than snapshotting it
303
- # here: the invocation-end hook needs the state as of the end of
304
- # the invocation, and neither hook pays for the conversion until
305
- # a plugin actually reads it.
306
- operations_provider=lambda: execution_state.operations,
307
- updated_operation_ids=invocation_input.updated_operation_ids,
308
- )
309
- # Thread 1: Run background checkpoint processing
310
- executor.submit(execution_state.checkpoint_batches_forever)
289
+ def invoke_handler() -> MutableMapping[str, Any]:
290
+ output: MutableMapping[str, Any]
311
291
 
312
- # Thread 2: Execute user function
313
292
  logger.debug(
314
293
  "%s entering user-space...", invocation_input.durable_execution_arn
315
294
  )
316
- user_future = executor.submit(func, input_event, durable_context)
317
-
318
- logger.debug(
319
- "%s waiting for user code completion...",
320
- invocation_input.durable_execution_arn,
321
- )
322
-
323
295
  try:
324
296
  # Background checkpointing errors will propagate through CompletionEvent.wait() as BackgroundThreadError
325
- result = user_future.result()
297
+ result = func(input_event, durable_context)
326
298
 
327
299
  # done with userland
328
300
  logger.debug(
329
301
  "%s exiting user-space...",
330
302
  invocation_input.durable_execution_arn,
331
303
  )
332
- serialized_result = json.dumps(result)
333
- # large response handling here. Remember if checkpointing to complete, NOT to include
334
- # payload in response
335
- if (
336
- serialized_result
337
- and len(serialized_result) > LAMBDA_RESPONSE_SIZE_LIMIT
338
- ):
304
+
305
+ # The handler may finish while background checkpoint work is
306
+ # still running for async operations it did not await, such as
307
+ # early-completing map or parallel branches. If one of those
308
+ # checkpoint responses omitted the next checkpoint token, this
309
+ # invocation can no longer record anything else with the
310
+ # service, including the handler's final result.
311
+ #
312
+ # In that case, return PENDING instead of SUCCEEDED. The next
313
+ # invocation will replay from the last durable checkpoint and
314
+ # produce the result again. This check catches revocation
315
+ # already observed before we build the terminal response; the
316
+ # post-with check below catches revocation discovered during
317
+ # cleanup of in-flight checkpoint work.
318
+ if execution_state.is_checkpoint_token_revoked:
339
319
  logger.debug(
340
- "Response size (%s bytes) exceeds Lambda limit (%s) bytes). Checkpointing result.",
341
- len(serialized_result),
342
- LAMBDA_RESPONSE_SIZE_LIMIT,
320
+ "Checkpoint token revoked; ending invocation with "
321
+ "PENDING instead of SUCCEEDED."
343
322
  )
344
- success_operation = OperationUpdate.create_execution_succeed(
345
- payload=serialized_result
346
- )
347
- # Checkpoint large result with blocking (is_sync=True, default).
348
- # Must ensure the result is persisted before returning to Lambda.
349
- # Large results exceed Lambda response limits and must be stored durably
350
- # before the execution completes.
351
- try:
352
- execution_state.create_checkpoint(
353
- success_operation, is_sync=True
323
+ output = DurableExecutionInvocationOutput.create_pending().to_dict()
324
+ else:
325
+ serialized_result = json.dumps(result)
326
+ # large response handling here. Remember if checkpointing to complete, NOT to include
327
+ # payload in response
328
+ if (
329
+ serialized_result
330
+ and len(serialized_result) > LAMBDA_RESPONSE_SIZE_LIMIT
331
+ ):
332
+ logger.debug(
333
+ "Response size (%s bytes) exceeds Lambda limit (%s) bytes). Checkpointing result.",
334
+ len(serialized_result),
335
+ LAMBDA_RESPONSE_SIZE_LIMIT,
354
336
  )
355
- except CheckpointError as e:
356
- return handle_checkpoint_error(e).to_dict()
357
- return DurableExecutionInvocationOutput.create_succeeded(
358
- result=""
359
- ).to_dict()
360
-
361
- return DurableExecutionInvocationOutput.create_succeeded(
362
- result=serialized_result
363
- ).to_dict()
337
+ success_operation = OperationUpdate.create_execution_succeed(
338
+ payload=serialized_result
339
+ )
340
+ # Checkpoint large result with blocking (is_sync=True, default).
341
+ # Must ensure the result is persisted before returning to Lambda.
342
+ # Large results exceed Lambda response limits and must be stored durably
343
+ # before the execution completes.
344
+ try:
345
+ execution_state.create_checkpoint(
346
+ success_operation, is_sync=True
347
+ )
348
+ except CheckpointError as e:
349
+ output = handle_checkpoint_error(e).to_dict()
350
+ else:
351
+ output = DurableExecutionInvocationOutput.create_succeeded(
352
+ result=""
353
+ ).to_dict()
354
+ else:
355
+ output = DurableExecutionInvocationOutput.create_succeeded(
356
+ result=serialized_result
357
+ ).to_dict()
364
358
 
365
359
  except BackgroundThreadError as bg_error:
366
360
  # Background checkpoint system failed - propagated through CompletionEvent
@@ -378,85 +372,206 @@ def durable_execution(
378
372
  "without retry.",
379
373
  extra=bg_error.source_exception.build_logger_extras(),
380
374
  )
381
- return DurableExecutionInvocationOutput(
375
+ output = DurableExecutionInvocationOutput(
382
376
  status=InvocationStatus.FAILED,
383
377
  error=ErrorObject.from_exception(bg_error.source_exception),
384
378
  ).to_dict()
379
+ else:
380
+ raise bg_error.source_exception from bg_error
385
381
  else:
386
382
  logger.exception("Checkpoint processing failed")
387
- raise bg_error.source_exception from bg_error
383
+ raise bg_error.source_exception from bg_error
388
384
 
389
385
  except SuspendExecution:
390
386
  # User code suspended - stop background checkpointing thread
391
387
  logger.debug("Suspending execution...")
392
- return DurableExecutionInvocationOutput(
393
- status=InvocationStatus.PENDING
394
- ).to_dict()
388
+ output = DurableExecutionInvocationOutput.create_pending().to_dict()
395
389
 
396
390
  except CheckpointError as e:
397
- # Checkpoint system is broken - stop background thread and exit immediately
398
- logger.exception(
399
- "Checkpoint system failed",
400
- extra=e.build_logger_extras(),
401
- )
402
- return handle_checkpoint_error(e).to_dict()
391
+ # Checkpoint system is broken - stop background thread and exit immediately.
392
+ if execution_state.is_checkpoint_token_revoked:
393
+ output = DurableExecutionInvocationOutput.create_pending().to_dict()
394
+ else:
395
+ logger.exception(
396
+ "Checkpoint system failed",
397
+ extra=e.build_logger_extras(),
398
+ )
399
+ output = handle_checkpoint_error(e).to_dict()
403
400
  except InvocationError as e:
404
- # Non-retryable Durable API errors (e.g., customer configuration issues,
405
- # 4xx client errors) will never succeed on retry — fail the execution immediately.
406
- if not e.is_retryable():
401
+ if execution_state.is_checkpoint_token_revoked:
402
+ output = DurableExecutionInvocationOutput.create_pending().to_dict()
403
+ else:
404
+ # Non-retryable Durable API errors (e.g., customer configuration issues,
405
+ # 4xx client errors) will never succeed on retry — fail the execution immediately.
406
+ if not e.is_retryable():
407
+ logger.exception(
408
+ "Non-retryable Durable API error. Must fail execution without retry.",
409
+ extra=e.build_logger_extras(), # type: ignore[attr-defined]
410
+ )
411
+ output = DurableExecutionInvocationOutput(
412
+ status=InvocationStatus.FAILED,
413
+ error=ErrorObject.from_exception(e),
414
+ ).to_dict()
415
+ else:
416
+ logger.exception("Invocation error. Must terminate.")
417
+ # Throw the error to trigger Lambda retry
418
+ raise
419
+ except ExecutionError as e:
420
+ if execution_state.is_checkpoint_token_revoked:
421
+ output = DurableExecutionInvocationOutput.create_pending().to_dict()
422
+ else:
407
423
  logger.exception(
408
- "Non-retryable Durable API error. Must fail execution without retry.",
409
- extra=e.build_logger_extras(), # type: ignore[attr-defined]
424
+ "Execution error. Must fail execution without retry."
410
425
  )
411
- return DurableExecutionInvocationOutput(
426
+ output = DurableExecutionInvocationOutput(
412
427
  status=InvocationStatus.FAILED,
413
428
  error=ErrorObject.from_exception(e),
414
429
  ).to_dict()
415
- logger.exception("Invocation error. Must terminate.")
416
- # Throw the error to trigger Lambda retry
417
- raise
418
- except ExecutionError as e:
419
- logger.exception("Execution error. Must fail execution without retry.")
420
- return DurableExecutionInvocationOutput(
421
- status=InvocationStatus.FAILED,
422
- error=ErrorObject.from_exception(e),
423
- ).to_dict()
424
430
  except Exception as e:
425
431
  # all user-space errors go here
426
- logger.exception("Execution failed")
432
+ if execution_state.is_checkpoint_token_revoked:
433
+ output = DurableExecutionInvocationOutput.create_pending().to_dict()
434
+ else:
435
+ logger.exception("Execution failed")
427
436
 
428
- result = DurableExecutionInvocationOutput(
429
- status=InvocationStatus.FAILED, error=ErrorObject.from_exception(e)
430
- ).to_dict()
437
+ result = DurableExecutionInvocationOutput(
438
+ status=InvocationStatus.FAILED,
439
+ error=ErrorObject.from_exception(e),
440
+ ).to_dict()
431
441
 
432
- serialized_result = json.dumps(result)
442
+ serialized_result = json.dumps(result)
433
443
 
434
- if (
435
- serialized_result
436
- and len(serialized_result) > LAMBDA_RESPONSE_SIZE_LIMIT
437
- ):
438
- logger.debug(
439
- "Response size (%s bytes) exceeds Lambda limit (%s) bytes). Checkpointing result.",
440
- len(serialized_result),
441
- LAMBDA_RESPONSE_SIZE_LIMIT,
442
- )
443
- failed_operation = OperationUpdate.create_execution_fail(
444
- error=ErrorObject.from_exception(e)
445
- )
444
+ if (
445
+ serialized_result
446
+ and len(serialized_result) > LAMBDA_RESPONSE_SIZE_LIMIT
447
+ ):
448
+ logger.debug(
449
+ "Response size (%s bytes) exceeds Lambda limit (%s) bytes). Checkpointing result.",
450
+ len(serialized_result),
451
+ LAMBDA_RESPONSE_SIZE_LIMIT,
452
+ )
453
+ failed_operation = OperationUpdate.create_execution_fail(
454
+ error=ErrorObject.from_exception(e)
455
+ )
446
456
 
447
- # Checkpoint large result with blocking (is_sync=True, default).
448
- # Must ensure the result is persisted before returning to Lambda.
449
- # Large results exceed Lambda response limits and must be stored durably
450
- # before the execution completes.
457
+ # Checkpoint large result with blocking (is_sync=True, default).
458
+ # Must ensure the result is persisted before returning to Lambda.
459
+ # Large results exceed Lambda response limits and must be stored durably
460
+ # before the execution completes.
461
+ try:
462
+ execution_state.create_checkpoint_sync(failed_operation)
463
+ except SuspendExecution:
464
+ # Revocation can occur after the check at the top of this handler and
465
+ # before this checkpoint. In that case create_checkpoint_sync raises
466
+ # SuspendExecution. So return PENDING instead of FAILED.
467
+ output = DurableExecutionInvocationOutput.create_pending().to_dict()
468
+ except CheckpointError as checkpoint_error:
469
+ output = handle_checkpoint_error(checkpoint_error).to_dict()
470
+ else:
471
+ output = DurableExecutionInvocationOutput(
472
+ status=InvocationStatus.FAILED
473
+ ).to_dict()
474
+ else:
475
+ output = result
476
+
477
+ return output
478
+
479
+ def invoke_worker() -> MutableMapping[str, Any]:
480
+ # This task owns Start, user cleanup, output preparation, resource
481
+ # cleanup and End. The caller owns the executor that runs this task.
482
+ with plugin_executor.run():
483
+ checkpoint_future: Future[None] | None = None
484
+
485
+ def wait_for_checkpoint() -> None:
486
+ if checkpoint_future is not None:
487
+ # Match the former executor join: observed checkpoint
488
+ # errors retain their existing classification paths.
489
+ wait((checkpoint_future,))
490
+
491
+ try:
492
+ with contextlib.ExitStack() as resources:
493
+ # LIFO cleanup: branches join with checkpointing alive;
494
+ # then close stops checkpointing and the wait completes.
495
+ resources.callback(wait_for_checkpoint)
496
+ resources.callback(execution_state.close)
497
+ execution_operation = execution_state.get_execution_operation()
498
+
499
+ # execute the plugins
500
+ plugin_executor.on_invocation_start(
501
+ execution_arn=invocation_input.durable_execution_arn,
502
+ lambda_context=context,
503
+ execution_start_time=(
504
+ execution_operation.start_timestamp
505
+ if execution_operation is not None
506
+ else None
507
+ ),
508
+ is_first_invocation=not has_prior_operations,
509
+ execution_input=input_event,
510
+ # Read the map through a callable rather than snapshotting it
511
+ # here: the invocation-end hook needs the state as of the end of
512
+ # the invocation, and neither hook pays for the conversion until
513
+ # a plugin actually reads it.
514
+ operations_provider=lambda: execution_state.operations,
515
+ updated_operation_ids=invocation_input.updated_operation_ids,
516
+ )
517
+ # No checkpoint work starts until all Start hooks finish.
518
+ checkpoint_future = executor.submit(
519
+ execution_state.checkpoint_batches_forever
520
+ )
521
+ output = invoke_handler()
522
+ # The ExitStack has now closed execution_state and joined the
523
+ # checkpoint thread. So any revocation observed while draining
524
+ # in-flight checkpoint work is visible here. A terminal status
525
+ # built before that point can no longer be recorded with the
526
+ # service. So report PENDING instead; the next invocation
527
+ # replays from the last durable checkpoint.
528
+ if (
529
+ output.get("Status")
530
+ in {
531
+ InvocationStatus.SUCCEEDED.value,
532
+ InvocationStatus.FAILED.value,
533
+ }
534
+ and execution_state.is_checkpoint_token_revoked
535
+ ):
536
+ logger.debug(
537
+ "Checkpoint token revoked during invocation cleanup; ending "
538
+ "invocation with PENDING instead of terminal status."
539
+ )
540
+ output = (
541
+ DurableExecutionInvocationOutput.create_pending().to_dict()
542
+ )
543
+ completed = DurableExecutionInvocationOutput.from_dict(output)
544
+ except BaseException as error:
545
+ # Every started plugin gets its End hook, including when
546
+ # cancellation or a thread-control exception escapes. The
547
+ # handler's original failure wins over an End-hook failure.
451
548
  try:
452
- execution_state.create_checkpoint_sync(failed_operation)
453
- except CheckpointError as e:
454
- return handle_checkpoint_error(e).to_dict()
455
- return DurableExecutionInvocationOutput(
456
- status=InvocationStatus.FAILED
457
- ).to_dict()
458
-
459
- return result
549
+ plugin_executor.on_invocation_end(
550
+ DurableExecutionInvocationOutput.create_retry(
551
+ ErrorObject.from_exception(error)
552
+ )
553
+ )
554
+ except BaseException: # noqa: BLE001 - preserve the original failure
555
+ logger.exception(
556
+ "Plugin invocation-end hook failed while the invocation "
557
+ "was already failing; the original failure is raised"
558
+ )
559
+ raise
560
+ # End must run only once; its own failure is not a handler failure.
561
+ plugin_executor.on_invocation_end(completed)
562
+ return output
563
+
564
+ with ThreadPoolExecutor(
565
+ max_workers=2, thread_name_prefix="dex-handler"
566
+ ) as executor:
567
+ if configured_plugins:
568
+ invocation_future = executor.submit(
569
+ contextvars.copy_context().run, invoke_worker
570
+ )
571
+ else:
572
+ # Preserve the original fresh-worker context without plugins.
573
+ invocation_future = executor.submit(invoke_worker)
574
+ return invocation_future.result()
460
575
 
461
576
  return wrapper
462
577
 
@@ -3,7 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import hashlib
6
- from dataclasses import dataclass
6
+ from dataclasses import dataclass, field
7
7
 
8
8
  from aws_durable_execution_sdk_python.exceptions import (
9
9
  NonDeterministicExecutionError,
@@ -11,7 +11,6 @@ from aws_durable_execution_sdk_python.exceptions import (
11
11
  from aws_durable_execution_sdk_python.lambda_service import (
12
12
  Operation,
13
13
  OperationType,
14
- OperationSubType,
15
14
  )
16
15
 
17
16
 
@@ -34,17 +33,22 @@ class OperationIdNamespace:
34
33
 
35
34
  @dataclass(frozen=True)
36
35
  class OperationIdentifier:
37
- """Container for operation id, type, subtype, hierarchy, and name."""
36
+ """Operation identity with an explicit type independent of its subtype.
37
+
38
+ ``sub_type`` is the subtype the operation records: the value of an
39
+ ``OperationSubType`` member for an SDK operation, or the caller-defined
40
+ string a step, a callback, or a child context was configured with.
41
+ """
38
42
 
39
43
  operation_id: str
40
- sub_type: OperationSubType
44
+ sub_type: str
41
45
  parent_id: str | None = None
42
46
  name: str | None = None
43
- operation_type: OperationType | None = None
47
+ operation_type: OperationType = field(kw_only=True)
44
48
 
45
49
  @property
46
50
  def type(self) -> OperationType:
47
- return self.operation_type or OperationType.from_sub_type(self.sub_type)
51
+ return self.operation_type
48
52
 
49
53
  def validate_checkpoint(self, checkpoint: Operation | None) -> None:
50
54
  """Ensure replay history belongs to this operation before it is consumed."""
@@ -61,12 +65,9 @@ class OperationIdentifier:
61
65
  mismatches.append(
62
66
  f"type checkpoint={checkpoint.operation_type.value!r} current={self.type.value!r}"
63
67
  )
64
- if checkpoint.sub_type is not self.sub_type:
65
- checkpoint_sub_type = (
66
- checkpoint.sub_type.value if checkpoint.sub_type is not None else None
67
- )
68
+ if checkpoint.sub_type != self.sub_type:
68
69
  mismatches.append(
69
- f"subtype checkpoint={checkpoint_sub_type!r} current={self.sub_type.value!r}"
70
+ f"subtype checkpoint={checkpoint.sub_type!r} current={self.sub_type!r}"
70
71
  )
71
72
  if checkpoint_name != expected_name:
72
73
  mismatches.append(