aws-durable-execution-sdk-python 2.0.0__py3-none-any.whl → 2.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.
@@ -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
 
@@ -188,9 +189,9 @@ def durable_execution(
188
189
 
189
190
  logger.debug("Starting durable execution handler...")
190
191
 
191
- plugin_executor = PluginExecutor(load_configured_plugins(plugins))
192
+ configured_plugins = load_configured_plugins(plugins)
193
+ plugin_executor = PluginExecutor(configured_plugins)
192
194
 
193
- @plugin_executor.handle_durable_output
194
195
  def wrapper(event: Any, context: LambdaContext) -> MutableMapping[str, Any]:
195
196
  invocation_input: DurableExecutionInvocationInput
196
197
  service_client: DurableServiceClient
@@ -279,88 +280,75 @@ def durable_execution(
279
280
  ),
280
281
  )
281
282
 
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)
283
+ def invoke_handler() -> MutableMapping[str, Any]:
284
+ output: MutableMapping[str, Any]
311
285
 
312
- # Thread 2: Execute user function
313
286
  logger.debug(
314
287
  "%s entering user-space...", invocation_input.durable_execution_arn
315
288
  )
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
289
  try:
324
290
  # Background checkpointing errors will propagate through CompletionEvent.wait() as BackgroundThreadError
325
- result = user_future.result()
291
+ result = func(input_event, durable_context)
326
292
 
327
293
  # done with userland
328
294
  logger.debug(
329
295
  "%s exiting user-space...",
330
296
  invocation_input.durable_execution_arn,
331
297
  )
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
- ):
298
+
299
+ # The handler may finish while background checkpoint work is
300
+ # still running for async operations it did not await, such as
301
+ # early-completing map or parallel branches. If one of those
302
+ # checkpoint responses omitted the next checkpoint token, this
303
+ # invocation can no longer record anything else with the
304
+ # service, including the handler's final result.
305
+ #
306
+ # In that case, return PENDING instead of SUCCEEDED. The next
307
+ # invocation will replay from the last durable checkpoint and
308
+ # produce the result again. This check catches revocation
309
+ # already observed before we build the terminal response; the
310
+ # post-with check below catches revocation discovered during
311
+ # cleanup of in-flight checkpoint work.
312
+ if execution_state.is_checkpoint_token_revoked:
339
313
  logger.debug(
340
- "Response size (%s bytes) exceeds Lambda limit (%s) bytes). Checkpointing result.",
341
- len(serialized_result),
342
- LAMBDA_RESPONSE_SIZE_LIMIT,
343
- )
344
- success_operation = OperationUpdate.create_execution_succeed(
345
- payload=serialized_result
314
+ "Checkpoint token revoked; ending invocation with "
315
+ "PENDING instead of SUCCEEDED."
346
316
  )
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
317
+ output = DurableExecutionInvocationOutput.create_pending().to_dict()
318
+ else:
319
+ serialized_result = json.dumps(result)
320
+ # large response handling here. Remember if checkpointing to complete, NOT to include
321
+ # payload in response
322
+ if (
323
+ serialized_result
324
+ and len(serialized_result) > LAMBDA_RESPONSE_SIZE_LIMIT
325
+ ):
326
+ logger.debug(
327
+ "Response size (%s bytes) exceeds Lambda limit (%s) bytes). Checkpointing result.",
328
+ len(serialized_result),
329
+ LAMBDA_RESPONSE_SIZE_LIMIT,
354
330
  )
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()
331
+ success_operation = OperationUpdate.create_execution_succeed(
332
+ payload=serialized_result
333
+ )
334
+ # Checkpoint large result with blocking (is_sync=True, default).
335
+ # Must ensure the result is persisted before returning to Lambda.
336
+ # Large results exceed Lambda response limits and must be stored durably
337
+ # before the execution completes.
338
+ try:
339
+ execution_state.create_checkpoint(
340
+ success_operation, is_sync=True
341
+ )
342
+ except CheckpointError as e:
343
+ output = handle_checkpoint_error(e).to_dict()
344
+ else:
345
+ output = DurableExecutionInvocationOutput.create_succeeded(
346
+ result=""
347
+ ).to_dict()
348
+ else:
349
+ output = DurableExecutionInvocationOutput.create_succeeded(
350
+ result=serialized_result
351
+ ).to_dict()
364
352
 
365
353
  except BackgroundThreadError as bg_error:
366
354
  # Background checkpoint system failed - propagated through CompletionEvent
@@ -378,85 +366,202 @@ def durable_execution(
378
366
  "without retry.",
379
367
  extra=bg_error.source_exception.build_logger_extras(),
380
368
  )
381
- return DurableExecutionInvocationOutput(
369
+ output = DurableExecutionInvocationOutput(
382
370
  status=InvocationStatus.FAILED,
383
371
  error=ErrorObject.from_exception(bg_error.source_exception),
384
372
  ).to_dict()
373
+ else:
374
+ raise bg_error.source_exception from bg_error
385
375
  else:
386
376
  logger.exception("Checkpoint processing failed")
387
- raise bg_error.source_exception from bg_error
377
+ raise bg_error.source_exception from bg_error
388
378
 
389
379
  except SuspendExecution:
390
380
  # User code suspended - stop background checkpointing thread
391
381
  logger.debug("Suspending execution...")
392
- return DurableExecutionInvocationOutput(
393
- status=InvocationStatus.PENDING
394
- ).to_dict()
382
+ output = DurableExecutionInvocationOutput.create_pending().to_dict()
395
383
 
396
384
  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()
385
+ # Checkpoint system is broken - stop background thread and exit immediately.
386
+ if execution_state.is_checkpoint_token_revoked:
387
+ output = DurableExecutionInvocationOutput.create_pending().to_dict()
388
+ else:
389
+ logger.exception(
390
+ "Checkpoint system failed",
391
+ extra=e.build_logger_extras(),
392
+ )
393
+ output = handle_checkpoint_error(e).to_dict()
403
394
  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():
395
+ if execution_state.is_checkpoint_token_revoked:
396
+ output = DurableExecutionInvocationOutput.create_pending().to_dict()
397
+ else:
398
+ # Non-retryable Durable API errors (e.g., customer configuration issues,
399
+ # 4xx client errors) will never succeed on retry — fail the execution immediately.
400
+ if not e.is_retryable():
401
+ logger.exception(
402
+ "Non-retryable Durable API error. Must fail execution without retry.",
403
+ extra=e.build_logger_extras(), # type: ignore[attr-defined]
404
+ )
405
+ output = DurableExecutionInvocationOutput(
406
+ status=InvocationStatus.FAILED,
407
+ error=ErrorObject.from_exception(e),
408
+ ).to_dict()
409
+ else:
410
+ logger.exception("Invocation error. Must terminate.")
411
+ # Throw the error to trigger Lambda retry
412
+ raise
413
+ except ExecutionError as e:
414
+ if execution_state.is_checkpoint_token_revoked:
415
+ output = DurableExecutionInvocationOutput.create_pending().to_dict()
416
+ else:
407
417
  logger.exception(
408
- "Non-retryable Durable API error. Must fail execution without retry.",
409
- extra=e.build_logger_extras(), # type: ignore[attr-defined]
418
+ "Execution error. Must fail execution without retry."
410
419
  )
411
- return DurableExecutionInvocationOutput(
420
+ output = DurableExecutionInvocationOutput(
412
421
  status=InvocationStatus.FAILED,
413
422
  error=ErrorObject.from_exception(e),
414
423
  ).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
424
  except Exception as e:
425
425
  # all user-space errors go here
426
- logger.exception("Execution failed")
426
+ if execution_state.is_checkpoint_token_revoked:
427
+ output = DurableExecutionInvocationOutput.create_pending().to_dict()
428
+ else:
429
+ logger.exception("Execution failed")
427
430
 
428
- result = DurableExecutionInvocationOutput(
429
- status=InvocationStatus.FAILED, error=ErrorObject.from_exception(e)
430
- ).to_dict()
431
+ result = DurableExecutionInvocationOutput(
432
+ status=InvocationStatus.FAILED,
433
+ error=ErrorObject.from_exception(e),
434
+ ).to_dict()
431
435
 
432
- serialized_result = json.dumps(result)
436
+ serialized_result = json.dumps(result)
433
437
 
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,
438
+ if (
439
+ serialized_result
440
+ and len(serialized_result) > LAMBDA_RESPONSE_SIZE_LIMIT
441
+ ):
442
+ logger.debug(
443
+ "Response size (%s bytes) exceeds Lambda limit (%s) bytes). Checkpointing result.",
444
+ len(serialized_result),
445
+ LAMBDA_RESPONSE_SIZE_LIMIT,
446
+ )
447
+ failed_operation = OperationUpdate.create_execution_fail(
448
+ error=ErrorObject.from_exception(e)
449
+ )
450
+
451
+ # Checkpoint large result with blocking (is_sync=True, default).
452
+ # Must ensure the result is persisted before returning to Lambda.
453
+ # Large results exceed Lambda response limits and must be stored durably
454
+ # before the execution completes.
455
+ try:
456
+ execution_state.create_checkpoint_sync(failed_operation)
457
+ except SuspendExecution:
458
+ # Revocation can occur after the check at the top of this handler and
459
+ # before this checkpoint. In that case create_checkpoint_sync raises
460
+ # SuspendExecution. So return PENDING instead of FAILED.
461
+ output = DurableExecutionInvocationOutput.create_pending().to_dict()
462
+ except CheckpointError as checkpoint_error:
463
+ output = handle_checkpoint_error(checkpoint_error).to_dict()
464
+ else:
465
+ output = DurableExecutionInvocationOutput(
466
+ status=InvocationStatus.FAILED
467
+ ).to_dict()
468
+ else:
469
+ output = result
470
+
471
+ return output
472
+
473
+ def invoke_worker() -> MutableMapping[str, Any]:
474
+ # This task owns Start, user cleanup, output preparation, resource
475
+ # cleanup and End. The caller owns the executor that runs this task.
476
+ with plugin_executor.run():
477
+ checkpoint_future: Future[None] | None = None
478
+
479
+ def wait_for_checkpoint() -> None:
480
+ if checkpoint_future is not None:
481
+ # Match the former executor join: observed checkpoint
482
+ # errors retain their existing classification paths.
483
+ wait((checkpoint_future,))
484
+
485
+ try:
486
+ with contextlib.ExitStack() as resources:
487
+ # LIFO cleanup: branches join with checkpointing alive;
488
+ # then close stops checkpointing and the wait completes.
489
+ resources.callback(wait_for_checkpoint)
490
+ resources.callback(
491
+ plugin_executor._run_in_invocation_context,
492
+ execution_state.close,
493
+ )
494
+ execution_operation = execution_state.get_execution_operation()
495
+
496
+ # execute the plugins
497
+ plugin_executor.on_invocation_start(
498
+ execution_arn=invocation_input.durable_execution_arn,
499
+ lambda_context=context,
500
+ execution_start_time=(
501
+ execution_operation.start_timestamp
502
+ if execution_operation is not None
503
+ else None
504
+ ),
505
+ is_first_invocation=not has_prior_operations,
506
+ execution_input=input_event,
507
+ # Read the map through a callable rather than snapshotting it
508
+ # here: the invocation-end hook needs the state as of the end of
509
+ # the invocation, and neither hook pays for the conversion until
510
+ # a plugin actually reads it.
511
+ operations_provider=lambda: execution_state.operations,
512
+ updated_operation_ids=invocation_input.updated_operation_ids,
513
+ )
514
+ # No checkpoint work starts until all Start hooks finish.
515
+ checkpoint_future = executor.submit(
516
+ execution_state.checkpoint_batches_forever
517
+ )
518
+ output = plugin_executor._run_in_invocation_context(
519
+ invoke_handler
520
+ )
521
+ # The ExitStack has now closed execution_state and joined the
522
+ # checkpoint thread. So any revocation observed while draining
523
+ # in-flight checkpoint work is visible here. A terminal status
524
+ # built before that point can no longer be recorded with the
525
+ # service. So report PENDING instead; the next invocation
526
+ # replays from the last durable checkpoint.
527
+ if (
528
+ output.get("Status")
529
+ in {
530
+ InvocationStatus.SUCCEEDED.value,
531
+ InvocationStatus.FAILED.value,
532
+ }
533
+ and execution_state.is_checkpoint_token_revoked
534
+ ):
535
+ logger.debug(
536
+ "Checkpoint token revoked during invocation cleanup; ending "
537
+ "invocation with PENDING instead of terminal status."
538
+ )
539
+ output = (
540
+ DurableExecutionInvocationOutput.create_pending().to_dict()
541
+ )
542
+ plugin_executor.on_invocation_end(
543
+ DurableExecutionInvocationOutput.from_dict(output)
442
544
  )
443
- failed_operation = OperationUpdate.create_execution_fail(
444
- error=ErrorObject.from_exception(e)
545
+ return output
546
+ except Exception as error:
547
+ plugin_executor.on_invocation_end(
548
+ DurableExecutionInvocationOutput.create_retry(
549
+ ErrorObject.from_exception(error)
550
+ )
445
551
  )
446
-
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.
451
- 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
552
+ raise
553
+
554
+ with ThreadPoolExecutor(
555
+ max_workers=2, thread_name_prefix="dex-handler"
556
+ ) as executor:
557
+ if configured_plugins:
558
+ invocation_future = executor.submit(
559
+ contextvars.copy_context().run, invoke_worker
560
+ )
561
+ else:
562
+ # Preserve the original fresh-worker context without plugins.
563
+ invocation_future = executor.submit(invoke_worker)
564
+ return invocation_future.result()
460
565
 
461
566
  return wrapper
462
567
 
@@ -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,
@@ -34,17 +34,17 @@ class OperationIdNamespace:
34
34
 
35
35
  @dataclass(frozen=True)
36
36
  class OperationIdentifier:
37
- """Container for operation id, type, subtype, hierarchy, and name."""
37
+ """Operation identity with an explicit type independent of its subtype."""
38
38
 
39
39
  operation_id: str
40
40
  sub_type: OperationSubType
41
41
  parent_id: str | None = None
42
42
  name: str | None = None
43
- operation_type: OperationType | None = None
43
+ operation_type: OperationType = field(kw_only=True)
44
44
 
45
45
  @property
46
46
  def type(self) -> OperationType:
47
- return self.operation_type or OperationType.from_sub_type(self.sub_type)
47
+ return self.operation_type
48
48
 
49
49
  def validate_checkpoint(self, checkpoint: Operation | None) -> None:
50
50
  """Ensure replay history belongs to this operation before it is consumed."""
@@ -75,29 +75,6 @@ class OperationType(Enum):
75
75
  CALLBACK = "CALLBACK"
76
76
  CHAINED_INVOKE = "CHAINED_INVOKE"
77
77
 
78
- @classmethod
79
- def from_sub_type(cls, sub_type: OperationSubType) -> OperationType:
80
- match sub_type:
81
- case OperationSubType.STEP | OperationSubType.WAIT_FOR_CONDITION:
82
- return OperationType.STEP
83
- case OperationSubType.WAIT:
84
- return OperationType.WAIT
85
- case OperationSubType.CHAINED_INVOKE:
86
- return OperationType.CHAINED_INVOKE
87
- case OperationSubType.CALLBACK:
88
- return OperationType.CALLBACK
89
- case (
90
- OperationSubType.WAIT_FOR_CALLBACK
91
- | OperationSubType.RUN_IN_CHILD_CONTEXT
92
- | OperationSubType.MAP
93
- | OperationSubType.MAP_ITERATION
94
- | OperationSubType.PARALLEL
95
- | OperationSubType.PARALLEL_BRANCH
96
- ):
97
- return OperationType.CONTEXT
98
- case _:
99
- raise ValueError(f"Unknown operation sub-type {sub_type}")
100
-
101
78
 
102
79
  class CallbackTimeoutType(Enum):
103
80
  TIMEOUT = "Callback.Timeout"
@@ -181,6 +158,11 @@ class DurableExecutionInvocationOutput:
181
158
  """Create a failed invocation output."""
182
159
  return cls(status=InvocationStatus.RETRY, error=error)
183
160
 
161
+ @classmethod
162
+ def create_pending(cls) -> DurableExecutionInvocationOutput:
163
+ """Create a pending invocation output without a result or error."""
164
+ return cls(status=InvocationStatus.PENDING)
165
+
184
166
 
185
167
  @dataclass(frozen=True)
186
168
  class ExecutionDetails:
@@ -453,19 +435,18 @@ class CallbackOptions:
453
435
 
454
436
  @dataclass(frozen=True)
455
437
  class ChainedInvokeOptions:
456
- """
457
- As of 2025/10/27:
458
- - Chained invoke options only contains a function name
459
- """
438
+ """Target and optional per-operation trace context for a chained invocation."""
460
439
 
461
440
  function_name: str
462
441
  tenant_id: str | None = None
442
+ x_amzn_trace_id: str | None = None
463
443
 
464
444
  @classmethod
465
445
  def from_dict(cls, data: MutableMapping[str, Any]) -> ChainedInvokeOptions:
466
446
  return cls(
467
447
  function_name=data["FunctionName"],
468
448
  tenant_id=data.get("TenantId"),
449
+ x_amzn_trace_id=data.get("XAmznTraceId"),
469
450
  )
470
451
 
471
452
  def to_dict(self) -> MutableMapping[str, Any]:
@@ -474,6 +455,8 @@ class ChainedInvokeOptions:
474
455
  }
475
456
  if self.tenant_id is not None:
476
457
  result["TenantId"] = self.tenant_id
458
+ if self.x_amzn_trace_id is not None:
459
+ result["XAmznTraceId"] = self.x_amzn_trace_id
477
460
 
478
461
  return result
479
462
 
@@ -10,11 +10,13 @@ from aws_durable_execution_sdk_python.exceptions import (
10
10
  ChildContextError,
11
11
  ExecutionError,
12
12
  InvocationError,
13
+ NonDeterministicExecutionError,
13
14
  SuspendExecution,
14
15
  )
15
16
  from aws_durable_execution_sdk_python.lambda_service import (
16
17
  ContextOptions,
17
18
  ErrorObject,
19
+ Operation,
18
20
  OperationStatus,
19
21
  OperationSubType,
20
22
  OperationUpdate,
@@ -71,6 +73,28 @@ class ChildOperationExecutor(OperationExecutor[T]):
71
73
  self.is_virtual: bool = config.is_virtual
72
74
  self.sub_type = config.sub_type or OperationSubType.RUN_IN_CHILD_CONTEXT
73
75
 
76
+ @staticmethod
77
+ def validate_virtual_checkpoint(
78
+ operation_identifier: OperationIdentifier,
79
+ checkpoint: Operation | None,
80
+ ) -> None:
81
+ """Reject a checkpoint at the operation id of a virtual child context.
82
+
83
+ A virtual child never checkpoints itself, so a checkpoint at its id was
84
+ written by a non-virtual child in an earlier invocation. Consuming it
85
+ would skip the virtual body and leave its inner operations unvalidated.
86
+ """
87
+ if not isinstance(checkpoint, Operation):
88
+ return
89
+ msg = (
90
+ "Non-deterministic child context at "
91
+ f"id={operation_identifier.operation_id!r}: checkpoint contains a "
92
+ "non-virtual child context but current child context is virtual"
93
+ )
94
+ raise NonDeterministicExecutionError(
95
+ msg, step_id=operation_identifier.operation_id
96
+ )
97
+
74
98
  def check_result_status(self) -> CheckResult[T]:
75
99
  """Check operation status and create START checkpoint if needed.
76
100
 
@@ -82,8 +106,14 @@ class ChildOperationExecutor(OperationExecutor[T]):
82
106
 
83
107
  Raises:
84
108
  ChildContextError: For FAILED operations
109
+ NonDeterministicExecutionError: For a virtual child whose operation
110
+ id already has a checkpoint
85
111
  """
86
112
  checkpointed_result = self._get_checkpoint_result()
113
+ if self.is_virtual:
114
+ self.validate_virtual_checkpoint(
115
+ self.operation_identifier, checkpointed_result.operation
116
+ )
87
117
 
88
118
  # Terminal success without replay_children - deserialize and return
89
119
  if (
@@ -123,12 +123,18 @@ class InvokeOperationExecutor(OperationExecutor[R]):
123
123
  operation_id=self.operation_identifier.operation_id,
124
124
  durable_execution_arn=self.state.durable_execution_arn,
125
125
  )
126
+ # Only a new START contributes outbound context. Replayed pending or
127
+ # terminal invokes keep their checkpointed identity and outcome.
128
+ propagation = self.state.provide_propagation_metadata(
129
+ self.operation_identifier, self.function_name
130
+ )
126
131
  start_operation: OperationUpdate = OperationUpdate.create_invoke_start(
127
132
  identifier=self.operation_identifier,
128
133
  payload=serialized_payload,
129
134
  chained_invoke_options=ChainedInvokeOptions(
130
135
  function_name=self.function_name,
131
136
  tenant_id=self.config.tenant_id,
137
+ x_amzn_trace_id=propagation.x_amzn_trace_id,
132
138
  ),
133
139
  )
134
140
  # Checkpoint invoke START with blocking (is_sync=True).