aws-durable-execution-sdk-python 2.0.1__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 +22 -8
- aws_durable_execution_sdk_python/context.py +31 -13
- 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 +141 -58
- aws_durable_execution_sdk_python-2.1.0.dist-info/METADATA +261 -0
- {aws_durable_execution_sdk_python-2.0.1.dist-info → aws_durable_execution_sdk_python-2.1.0.dist-info}/RECORD +14 -14
- {aws_durable_execution_sdk_python-2.0.1.dist-info → aws_durable_execution_sdk_python-2.1.0.dist-info}/WHEEL +1 -1
- aws_durable_execution_sdk_python-2.0.1.dist-info/METADATA +0 -156
|
@@ -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).
|
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import contextlib
|
|
4
|
+
import contextvars
|
|
4
5
|
import copy
|
|
5
6
|
import datetime
|
|
6
|
-
import
|
|
7
|
+
import inspect
|
|
7
8
|
import logging
|
|
8
9
|
from collections.abc import Mapping, Sequence
|
|
9
10
|
from concurrent.futures import ThreadPoolExecutor
|
|
10
11
|
from dataclasses import dataclass, field
|
|
11
12
|
from enum import Enum
|
|
12
|
-
from typing import Any, Callable,
|
|
13
|
+
from typing import Any, Callable, cast
|
|
13
14
|
|
|
14
15
|
from aws_durable_execution_sdk_python.identifier import OperationIdentifier
|
|
15
16
|
from aws_durable_execution_sdk_python.lambda_service import (
|
|
@@ -259,8 +260,8 @@ class InvocationInfo:
|
|
|
259
260
|
)
|
|
260
261
|
"""EXPERIMENTAL: The deserialized execution input, when available.
|
|
261
262
|
|
|
262
|
-
Surfaced to instrumentation plugins that need to record it (e.g.
|
|
263
|
-
|
|
263
|
+
Surfaced to instrumentation plugins that need to record it (e.g. Durable
|
|
264
|
+
Execution Telemetry). Mirrors the JS SDK's ``InvocationInfo.executionInput``.
|
|
264
265
|
|
|
265
266
|
Excluded from ``repr`` on purpose: instrumentation logs hook infos wholesale
|
|
266
267
|
(the bundled OTel plugins at debug level, the plugin example at info), so
|
|
@@ -381,9 +382,41 @@ class InvocationEndInfo(InvocationInfo):
|
|
|
381
382
|
)
|
|
382
383
|
|
|
383
384
|
|
|
385
|
+
@dataclass(frozen=True)
|
|
386
|
+
class PropagationInput:
|
|
387
|
+
"""SDK-owned identity for a new chained-invoke START propagation request.
|
|
388
|
+
|
|
389
|
+
This contract does not contain generated service-model or telemetry types.
|
|
390
|
+
"""
|
|
391
|
+
|
|
392
|
+
execution_arn: str
|
|
393
|
+
operation_id: str
|
|
394
|
+
target_function_name: str
|
|
395
|
+
parent_operation_id: str | None = None
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
@dataclass(frozen=True)
|
|
399
|
+
class PropagationMetadata:
|
|
400
|
+
"""Immutable plugin contribution; members are opaque to the core SDK."""
|
|
401
|
+
|
|
402
|
+
x_amzn_trace_id: str | None = None
|
|
403
|
+
|
|
404
|
+
|
|
384
405
|
class DurableInstrumentationPlugin:
|
|
385
406
|
"""Base class for plugins. Override only the methods you need."""
|
|
386
407
|
|
|
408
|
+
def provide_propagation_metadata(
|
|
409
|
+
self,
|
|
410
|
+
info: PropagationInput,
|
|
411
|
+
) -> PropagationMetadata | None:
|
|
412
|
+
"""Synchronously provide metadata without performing a durable operation.
|
|
413
|
+
|
|
414
|
+
Called before checkpointing a new invoke START, never for its replay.
|
|
415
|
+
Existing plugins can inherit the no-op default. A failed, uncommitted
|
|
416
|
+
START may collect again on a later attempt.
|
|
417
|
+
"""
|
|
418
|
+
return None
|
|
419
|
+
|
|
387
420
|
def on_invocation_start(self, info: InvocationStartInfo) -> None:
|
|
388
421
|
"""Called when an invocation starts. This is called within the thread that runs user function handler.
|
|
389
422
|
|
|
@@ -466,6 +499,92 @@ class PluginExecutor:
|
|
|
466
499
|
self._executor: ThreadPoolExecutor | None = None
|
|
467
500
|
self._invocation_status: InvocationStartInfo | None = None
|
|
468
501
|
self._operations_provider: Callable[[], Mapping[str, Operation]] | None = None
|
|
502
|
+
# Non-None only after a Start hook fails: later setup and invocation
|
|
503
|
+
# work use its pre-hook snapshot. End still uses each hook's token owner.
|
|
504
|
+
self._startup_context: contextvars.Context | None = None
|
|
505
|
+
self._invocation_contexts: list[contextvars.Context | None] = []
|
|
506
|
+
|
|
507
|
+
def provide_propagation_metadata(
|
|
508
|
+
self, info: PropagationInput
|
|
509
|
+
) -> PropagationMetadata:
|
|
510
|
+
"""Collect opaque metadata synchronously in configured plugin order.
|
|
511
|
+
|
|
512
|
+
The first non-blank value wins. Ordinary plugin failures are isolated,
|
|
513
|
+
matching lifecycle dispatch; BaseException cancellation/control signals
|
|
514
|
+
retain the existing propagation policy. The caller attaches the result
|
|
515
|
+
to its operation model before checkpoint serialization.
|
|
516
|
+
"""
|
|
517
|
+
value: str | None = None
|
|
518
|
+
owner: str | None = None
|
|
519
|
+
conflict_count = 0
|
|
520
|
+
for plugin in self._plugins:
|
|
521
|
+
# Read type identity without invoking instance attribute access.
|
|
522
|
+
identity = "unknown plugin"
|
|
523
|
+
try:
|
|
524
|
+
plugin_type = type(plugin)
|
|
525
|
+
identity = type.__getattribute__(plugin_type, "__qualname__")
|
|
526
|
+
module = type.__getattribute__(plugin_type, "__module__")
|
|
527
|
+
if type(module) is str:
|
|
528
|
+
identity = ".".join((module, identity))
|
|
529
|
+
except Exception:
|
|
530
|
+
pass
|
|
531
|
+
try:
|
|
532
|
+
metadata = plugin.provide_propagation_metadata(info)
|
|
533
|
+
if metadata is None:
|
|
534
|
+
continue
|
|
535
|
+
if inspect.iscoroutine(metadata):
|
|
536
|
+
metadata.close()
|
|
537
|
+
raise TypeError("propagation metadata hook must be synchronous")
|
|
538
|
+
if not isinstance(metadata, PropagationMetadata):
|
|
539
|
+
raise TypeError("expected PropagationMetadata or None")
|
|
540
|
+
candidate = metadata.x_amzn_trace_id
|
|
541
|
+
if candidate is not None:
|
|
542
|
+
if not isinstance(candidate, str):
|
|
543
|
+
raise TypeError("x_amzn_trace_id must be a string or None")
|
|
544
|
+
# Treat str subclasses as data, not executable comparison
|
|
545
|
+
# hooks during first-value/conflict aggregation.
|
|
546
|
+
candidate = str.__str__(candidate)
|
|
547
|
+
except Exception:
|
|
548
|
+
self._log_propagation_diagnostic(
|
|
549
|
+
logging.ERROR,
|
|
550
|
+
"Plugin %s propagation metadata failure ignored",
|
|
551
|
+
identity,
|
|
552
|
+
exc_info=True,
|
|
553
|
+
)
|
|
554
|
+
continue
|
|
555
|
+
if candidate is None or not candidate.strip():
|
|
556
|
+
continue
|
|
557
|
+
if value is None:
|
|
558
|
+
value, owner = candidate, identity
|
|
559
|
+
elif candidate != value:
|
|
560
|
+
conflict_count += 1
|
|
561
|
+
self._log_propagation_diagnostic(
|
|
562
|
+
logging.WARNING,
|
|
563
|
+
"Propagation metadata conflict between %s and %s for "
|
|
564
|
+
"x_amzn_trace_id; keeping first value (conflict_count=%d)",
|
|
565
|
+
owner,
|
|
566
|
+
identity,
|
|
567
|
+
conflict_count,
|
|
568
|
+
)
|
|
569
|
+
return PropagationMetadata(x_amzn_trace_id=value)
|
|
570
|
+
|
|
571
|
+
@staticmethod
|
|
572
|
+
def _log_propagation_diagnostic(
|
|
573
|
+
level: int,
|
|
574
|
+
message: str,
|
|
575
|
+
*args: object,
|
|
576
|
+
exc_info: bool = False,
|
|
577
|
+
) -> None:
|
|
578
|
+
"""Keep ordinary diagnostic failures from changing propagation collection."""
|
|
579
|
+
try:
|
|
580
|
+
logger.log(level, message, *args, exc_info=exc_info)
|
|
581
|
+
except Exception:
|
|
582
|
+
pass
|
|
583
|
+
|
|
584
|
+
@property
|
|
585
|
+
def has_plugins(self) -> bool:
|
|
586
|
+
"""Return True when at least one plugin is registered."""
|
|
587
|
+
return bool(self._plugins)
|
|
469
588
|
|
|
470
589
|
@contextlib.contextmanager
|
|
471
590
|
def run(self):
|
|
@@ -479,12 +598,14 @@ class PluginExecutor:
|
|
|
479
598
|
finally:
|
|
480
599
|
self._invocation_status = None
|
|
481
600
|
self._operations_provider = None
|
|
601
|
+
self._startup_context = None
|
|
602
|
+
self._invocation_contexts.clear()
|
|
482
603
|
# Shut down the thread pool, waiting for pending tasks to complete.
|
|
483
604
|
if self._executor:
|
|
484
605
|
self._executor.shutdown(wait=True)
|
|
485
606
|
|
|
486
607
|
@staticmethod
|
|
487
|
-
def _dispatch_plugin(plugin: DurableInstrumentationPlugin, info) ->
|
|
608
|
+
def _dispatch_plugin(plugin: DurableInstrumentationPlugin, info) -> bool:
|
|
488
609
|
"""Invoke the appropriate plugin callback. Runs inside the thread pool."""
|
|
489
610
|
try:
|
|
490
611
|
match info:
|
|
@@ -507,18 +628,54 @@ class PluginExecutor:
|
|
|
507
628
|
except Exception:
|
|
508
629
|
# log and ignore the exception
|
|
509
630
|
logger.exception("Plugin %s exception ignored", plugin.__class__.__name__)
|
|
631
|
+
return False
|
|
632
|
+
return True
|
|
510
633
|
|
|
511
634
|
def execute_plugins(self, info, sync):
|
|
512
635
|
if not self._executor:
|
|
513
636
|
return
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
637
|
+
if sync and isinstance(info, InvocationStartInfo):
|
|
638
|
+
self._startup_context = None
|
|
639
|
+
self._invocation_contexts.clear()
|
|
640
|
+
for index, plugin in enumerate(self._plugins):
|
|
641
|
+
if sync and isinstance(info, InvocationStartInfo):
|
|
642
|
+
owner = self._startup_context
|
|
643
|
+
before = (
|
|
644
|
+
owner.copy() if owner is not None else contextvars.copy_context()
|
|
645
|
+
)
|
|
646
|
+
self._invocation_contexts.append(owner)
|
|
647
|
+
succeeded = (
|
|
648
|
+
owner.run(self._dispatch_plugin, plugin, info)
|
|
649
|
+
if owner is not None
|
|
650
|
+
else self._dispatch_plugin(plugin, info)
|
|
651
|
+
)
|
|
652
|
+
if not succeeded:
|
|
653
|
+
# A failing hook may have left new bindings with no reset token.
|
|
654
|
+
# Continue setup and the handler in the pre-hook snapshot.
|
|
655
|
+
self._startup_context = before
|
|
656
|
+
elif sync:
|
|
657
|
+
# End hooks must reset tokens in the Context that created them,
|
|
658
|
+
# even when a failed start hook moved later setup to a snapshot.
|
|
659
|
+
owner = (
|
|
660
|
+
self._invocation_contexts[index]
|
|
661
|
+
if isinstance(info, InvocationEndInfo)
|
|
662
|
+
and index < len(self._invocation_contexts)
|
|
663
|
+
else None
|
|
664
|
+
)
|
|
665
|
+
if owner is not None:
|
|
666
|
+
owner.run(self._dispatch_plugin, plugin, info)
|
|
667
|
+
else:
|
|
668
|
+
self._dispatch_plugin(plugin, info)
|
|
518
669
|
else:
|
|
519
670
|
# this is called asynchronously, so plugins cannot manipulate thread local objects
|
|
520
671
|
self._executor.submit(self._dispatch_plugin, plugin, info)
|
|
521
672
|
|
|
673
|
+
def _run_in_invocation_context(self, invoke: Callable[[], Any]) -> Any:
|
|
674
|
+
"""Continue in the pre-hook Context only after a failed Start hook."""
|
|
675
|
+
if self._startup_context is not None:
|
|
676
|
+
return self._startup_context.run(invoke)
|
|
677
|
+
return invoke()
|
|
678
|
+
|
|
522
679
|
def _snapshot_operation_infos(
|
|
523
680
|
self,
|
|
524
681
|
operations_provider: Callable[[], Mapping[str, Operation]] | None,
|
|
@@ -763,7 +920,9 @@ class PluginExecutor:
|
|
|
763
920
|
operations: full operation map after the update, when available.
|
|
764
921
|
previous_operations: operation map before the update, when available.
|
|
765
922
|
"""
|
|
766
|
-
|
|
923
|
+
# Only plugins read the info built below, and the change info converts
|
|
924
|
+
# every operation. So without a plugin, return before building any of it.
|
|
925
|
+
if operation_or_operations is None or not self.has_plugins:
|
|
767
926
|
return
|
|
768
927
|
|
|
769
928
|
updated_operations: list[Operation] = (
|
|
@@ -836,28 +995,3 @@ class PluginExecutor:
|
|
|
836
995
|
OperationStatus.CANCELLED,
|
|
837
996
|
OperationStatus.STOPPED,
|
|
838
997
|
]
|
|
839
|
-
|
|
840
|
-
@property
|
|
841
|
-
def handle_durable_output(self):
|
|
842
|
-
def decorator(func: Callable[[Any, LambdaContext], MutableMapping[str, Any]]):
|
|
843
|
-
@functools.wraps(func)
|
|
844
|
-
def wrapper(event: Any, context: LambdaContext):
|
|
845
|
-
with self.run():
|
|
846
|
-
try:
|
|
847
|
-
output = func(event, context)
|
|
848
|
-
|
|
849
|
-
self.on_invocation_end(
|
|
850
|
-
output=DurableExecutionInvocationOutput.from_dict(output),
|
|
851
|
-
)
|
|
852
|
-
return output
|
|
853
|
-
except Exception as e:
|
|
854
|
-
self.on_invocation_end(
|
|
855
|
-
output=DurableExecutionInvocationOutput.create_retry(
|
|
856
|
-
ErrorObject.from_exception(e)
|
|
857
|
-
),
|
|
858
|
-
)
|
|
859
|
-
raise
|
|
860
|
-
|
|
861
|
-
return wrapper
|
|
862
|
-
|
|
863
|
-
return decorator
|
|
@@ -121,6 +121,9 @@ def _create_plugin(
|
|
|
121
121
|
) from error
|
|
122
122
|
|
|
123
123
|
if type(plugin) is not provider.plugin_type:
|
|
124
|
+
# A wrong-type result may already own constructor resources, but it is
|
|
125
|
+
# never appended to the accepted list cleaned up by the outer boundary.
|
|
126
|
+
_notify_registration_result([plugin], registered=False)
|
|
124
127
|
raise PluginLoadError(
|
|
125
128
|
f"Durable instrumentation plugin provider '{plugin_name}' returned "
|
|
126
129
|
f"{_qualified_type_name(plugin)}; expected "
|
|
@@ -130,6 +133,101 @@ def _create_plugin(
|
|
|
130
133
|
return plugin
|
|
131
134
|
|
|
132
135
|
|
|
136
|
+
def _registration_api_owner(plugin_type: type[object]) -> type[object] | None:
|
|
137
|
+
"""Find the first explicit marker in the MRO without metaclass descriptors.
|
|
138
|
+
|
|
139
|
+
A subclass inherits its declaring class's registration contract. Its legacy
|
|
140
|
+
same-named fields/helpers are not part of that contract unless it repeats
|
|
141
|
+
the marker. An explicit unsupported marker shadows any inherited opt-in.
|
|
142
|
+
"""
|
|
143
|
+
mro: tuple[type[object], ...] = type.__dict__["__mro__"].__get__(
|
|
144
|
+
plugin_type, type(plugin_type)
|
|
145
|
+
)
|
|
146
|
+
for declaring_type in mro:
|
|
147
|
+
namespace = type.__dict__["__dict__"].__get__(
|
|
148
|
+
declaring_type, type(declaring_type)
|
|
149
|
+
)
|
|
150
|
+
if "__durable_registration_api__" in namespace:
|
|
151
|
+
version = namespace["__durable_registration_api__"]
|
|
152
|
+
return declaring_type if type(version) is int and version == 1 else None
|
|
153
|
+
return None
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _registration_result_callback(
|
|
157
|
+
plugin: DurableInstrumentationPlugin, owner: type[object]
|
|
158
|
+
) -> object | None:
|
|
159
|
+
"""Bind the opted-in contract without calling a legacy subclass helper."""
|
|
160
|
+
if owner is type(plugin):
|
|
161
|
+
return getattr(plugin, "on_registration_result", None)
|
|
162
|
+
mro: tuple[type[object], ...] = type.__dict__["__mro__"].__get__(owner, type(owner))
|
|
163
|
+
for declaring_type in mro:
|
|
164
|
+
namespace = type.__dict__["__dict__"].__get__(
|
|
165
|
+
declaring_type, type(declaring_type)
|
|
166
|
+
)
|
|
167
|
+
if "on_registration_result" in namespace:
|
|
168
|
+
callback = namespace["on_registration_result"]
|
|
169
|
+
bind = getattr(callback, "__get__", None)
|
|
170
|
+
return bind(plugin, type(plugin)) if bind is not None else callback
|
|
171
|
+
return None
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _exclusive_groups(plugin_type: type[DurableInstrumentationPlugin]) -> list[str]:
|
|
175
|
+
"""Accumulate opted-in MRO contracts without interpreting legacy fields."""
|
|
176
|
+
mro: tuple[type[object], ...] = type.__dict__["__mro__"].__get__(
|
|
177
|
+
plugin_type, type(plugin_type)
|
|
178
|
+
)
|
|
179
|
+
groups: list[str] = []
|
|
180
|
+
for declaring_type in mro:
|
|
181
|
+
namespace = type.__dict__["__dict__"].__get__(
|
|
182
|
+
declaring_type, type(declaring_type)
|
|
183
|
+
)
|
|
184
|
+
version = namespace.get("__durable_registration_api__")
|
|
185
|
+
if type(version) is not int or version != 1:
|
|
186
|
+
continue
|
|
187
|
+
try:
|
|
188
|
+
group = getattr(declaring_type, "exclusive_group", None)
|
|
189
|
+
except Exception as error:
|
|
190
|
+
raise PluginLoadError(
|
|
191
|
+
f"Cannot read exclusive_group for {_qualified_class_name(plugin_type)}."
|
|
192
|
+
) from error
|
|
193
|
+
if group is None:
|
|
194
|
+
continue
|
|
195
|
+
if type(group) is not str or not group.strip():
|
|
196
|
+
raise PluginLoadError(
|
|
197
|
+
f"Durable instrumentation plugin {_qualified_class_name(plugin_type)} "
|
|
198
|
+
"must declare exclusive_group as None or a non-empty string."
|
|
199
|
+
)
|
|
200
|
+
# Repeating a marker or a group does not register this one plugin twice.
|
|
201
|
+
if group not in groups:
|
|
202
|
+
groups.append(group)
|
|
203
|
+
return groups
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _validate_exclusive_groups(
|
|
207
|
+
plugin_types: Sequence[type[DurableInstrumentationPlugin]],
|
|
208
|
+
) -> None:
|
|
209
|
+
"""Reject competing instrumentation before any lifecycle hooks run."""
|
|
210
|
+
groups: dict[str, type[DurableInstrumentationPlugin]] = {}
|
|
211
|
+
for plugin_type in plugin_types:
|
|
212
|
+
if _registration_api_owner(plugin_type) is None:
|
|
213
|
+
continue
|
|
214
|
+
for group in _exclusive_groups(plugin_type):
|
|
215
|
+
if (previous := groups.get(group)) is not None:
|
|
216
|
+
if previous is plugin_type:
|
|
217
|
+
raise PluginLoadError(
|
|
218
|
+
f"Durable instrumentation plugin {_qualified_class_name(plugin_type)} "
|
|
219
|
+
f"is registered more than once in exclusive group '{group}'. "
|
|
220
|
+
"Register this plugin only once in plugins."
|
|
221
|
+
)
|
|
222
|
+
raise PluginLoadError(
|
|
223
|
+
f"Durable instrumentation plugins {_qualified_class_name(previous)} "
|
|
224
|
+
f"and {_qualified_class_name(plugin_type)} are mutually exclusive "
|
|
225
|
+
f"(group '{group}'). Keep only one plugin from this group in "
|
|
226
|
+
"plugins and DURABLE_EXECUTION_PLUGINS."
|
|
227
|
+
)
|
|
228
|
+
groups[group] = plugin_type
|
|
229
|
+
|
|
230
|
+
|
|
133
231
|
def load_configured_plugins(
|
|
134
232
|
explicit_plugins: Sequence[DurableInstrumentationPlugin] | None,
|
|
135
233
|
*,
|
|
@@ -144,9 +242,45 @@ def load_configured_plugins(
|
|
|
144
242
|
"""
|
|
145
243
|
|
|
146
244
|
resolved_plugins = list(explicit_plugins or [])
|
|
245
|
+
try:
|
|
246
|
+
result = _resolve_configured_plugins(resolved_plugins, environment=environment)
|
|
247
|
+
except PluginLoadError:
|
|
248
|
+
_notify_registration_result(resolved_plugins, registered=False)
|
|
249
|
+
raise
|
|
250
|
+
_notify_registration_result(result, registered=True)
|
|
251
|
+
return result
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _notify_registration_result(
|
|
255
|
+
plugins: Sequence[DurableInstrumentationPlugin],
|
|
256
|
+
*,
|
|
257
|
+
registered: bool,
|
|
258
|
+
) -> None:
|
|
259
|
+
"""Let a plugin release rejected constructor resources without failing loading."""
|
|
260
|
+
for plugin in plugins:
|
|
261
|
+
owner = _registration_api_owner(type(plugin))
|
|
262
|
+
if owner is None:
|
|
263
|
+
continue
|
|
264
|
+
try:
|
|
265
|
+
callback = _registration_result_callback(plugin, owner)
|
|
266
|
+
if callable(callback):
|
|
267
|
+
callback(registered)
|
|
268
|
+
except Exception:
|
|
269
|
+
try:
|
|
270
|
+
logger.exception("Plugin registration-result callback failed")
|
|
271
|
+
except Exception:
|
|
272
|
+
pass
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _resolve_configured_plugins(
|
|
276
|
+
resolved_plugins: list[DurableInstrumentationPlugin],
|
|
277
|
+
*,
|
|
278
|
+
environment: Mapping[str, str] | None,
|
|
279
|
+
) -> list[DurableInstrumentationPlugin]:
|
|
147
280
|
resolved_environment = os.environ if environment is None else environment
|
|
148
281
|
plugin_names = _parse_configured_plugin_names(resolved_environment)
|
|
149
282
|
if not plugin_names:
|
|
283
|
+
_validate_exclusive_groups([type(plugin) for plugin in resolved_plugins])
|
|
150
284
|
return resolved_plugins
|
|
151
285
|
|
|
152
286
|
try:
|
|
@@ -167,6 +301,9 @@ def load_configured_plugins(
|
|
|
167
301
|
type(plugin): "the decorator's plugins argument" for plugin in resolved_plugins
|
|
168
302
|
}
|
|
169
303
|
|
|
304
|
+
selected_providers: list[
|
|
305
|
+
tuple[str, metadata.EntryPoint, DurableInstrumentationPluginProvider]
|
|
306
|
+
] = []
|
|
170
307
|
for plugin_name in plugin_names:
|
|
171
308
|
matching_entry_points = entry_points_by_name.get(plugin_name, [])
|
|
172
309
|
if not matching_entry_points:
|
|
@@ -202,8 +339,15 @@ def load_configured_plugins(
|
|
|
202
339
|
)
|
|
203
340
|
continue
|
|
204
341
|
|
|
205
|
-
|
|
206
|
-
resolved_plugins.append(plugin)
|
|
342
|
+
selected_providers.append((plugin_name, entry_point, provider))
|
|
207
343
|
registered_types[provider.plugin_type] = f"dynamic provider '{plugin_name}'"
|
|
208
344
|
|
|
345
|
+
# Factories can install instrumentation globally. Validate every selected
|
|
346
|
+
# type first so a rejected configuration leaves no discarded plugin behind.
|
|
347
|
+
_validate_exclusive_groups(
|
|
348
|
+
[type(plugin) for plugin in resolved_plugins]
|
|
349
|
+
+ [provider.plugin_type for _, _, provider in selected_providers]
|
|
350
|
+
)
|
|
351
|
+
for plugin_name, entry_point, provider in selected_providers:
|
|
352
|
+
resolved_plugins.append(_create_plugin(plugin_name, entry_point, provider))
|
|
209
353
|
return resolved_plugins
|