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.
- aws_durable_execution_sdk_python/__about__.py +1 -1
- aws_durable_execution_sdk_python/concurrency/executor.py +65 -23
- aws_durable_execution_sdk_python/context.py +31 -13
- aws_durable_execution_sdk_python/exceptions.py +2 -2
- aws_durable_execution_sdk_python/execution.py +230 -125
- aws_durable_execution_sdk_python/identifier.py +4 -4
- aws_durable_execution_sdk_python/lambda_service.py +10 -27
- aws_durable_execution_sdk_python/operation/child.py +30 -0
- aws_durable_execution_sdk_python/operation/invoke.py +6 -0
- aws_durable_execution_sdk_python/plugin.py +169 -35
- aws_durable_execution_sdk_python/plugin_discovery.py +146 -2
- aws_durable_execution_sdk_python/state.py +444 -181
- aws_durable_execution_sdk_python-2.1.0.dist-info/METADATA +261 -0
- {aws_durable_execution_sdk_python-2.0.0.dist-info → aws_durable_execution_sdk_python-2.1.0.dist-info}/RECORD +15 -15
- {aws_durable_execution_sdk_python-2.0.0.dist-info → aws_durable_execution_sdk_python-2.1.0.dist-info}/WHEEL +1 -1
- aws_durable_execution_sdk_python-2.0.0.dist-info/METADATA +0 -156
|
@@ -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
|
-
|
|
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
|
-
|
|
283
|
-
|
|
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 =
|
|
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
|
-
|
|
333
|
-
#
|
|
334
|
-
#
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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
|
-
"
|
|
341
|
-
|
|
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
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
#
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
result=
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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
|
-
"
|
|
409
|
-
extra=e.build_logger_extras(), # type: ignore[attr-defined]
|
|
418
|
+
"Execution error. Must fail execution without retry."
|
|
410
419
|
)
|
|
411
|
-
|
|
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
|
-
|
|
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
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
+
result = DurableExecutionInvocationOutput(
|
|
432
|
+
status=InvocationStatus.FAILED,
|
|
433
|
+
error=ErrorObject.from_exception(e),
|
|
434
|
+
).to_dict()
|
|
431
435
|
|
|
432
|
-
|
|
436
|
+
serialized_result = json.dumps(result)
|
|
433
437
|
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
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
|
-
|
|
444
|
-
|
|
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
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
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
|
-
"""
|
|
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
|
|
43
|
+
operation_type: OperationType = field(kw_only=True)
|
|
44
44
|
|
|
45
45
|
@property
|
|
46
46
|
def type(self) -> OperationType:
|
|
47
|
-
return self.operation_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).
|