python-saga-orchestrator 0.1.1__tar.gz → 0.1.3__tar.gz
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.
- {python_saga_orchestrator-0.1.1/python_saga_orchestrator.egg-info → python_saga_orchestrator-0.1.3}/PKG-INFO +13 -1
- {python_saga_orchestrator-0.1.1 → python_saga_orchestrator-0.1.3}/README.md +12 -0
- {python_saga_orchestrator-0.1.1 → python_saga_orchestrator-0.1.3}/pyproject.toml +1 -1
- {python_saga_orchestrator-0.1.1 → python_saga_orchestrator-0.1.3/python_saga_orchestrator.egg-info}/PKG-INFO +13 -1
- {python_saga_orchestrator-0.1.1 → python_saga_orchestrator-0.1.3}/python_saga_orchestrator.egg-info/SOURCES.txt +11 -1
- {python_saga_orchestrator-0.1.1 → python_saga_orchestrator-0.1.3}/saga_orchestrator/__init__.py +40 -0
- {python_saga_orchestrator-0.1.1 → python_saga_orchestrator-0.1.3}/saga_orchestrator/core/builder.py +5 -0
- {python_saga_orchestrator-0.1.1 → python_saga_orchestrator-0.1.3}/saga_orchestrator/core/engine.py +252 -7
- {python_saga_orchestrator-0.1.1 → python_saga_orchestrator-0.1.3}/saga_orchestrator/core/orchestrator.py +58 -2
- {python_saga_orchestrator-0.1.1 → python_saga_orchestrator-0.1.3}/saga_orchestrator/domain/models/__init__.py +13 -1
- python_saga_orchestrator-0.1.3/saga_orchestrator/domain/models/notify.py +32 -0
- {python_saga_orchestrator-0.1.1 → python_saga_orchestrator-0.1.3}/saga_orchestrator/domain/models/step.py +3 -0
- python_saga_orchestrator-0.1.3/saga_orchestrator/outbox/__init__.py +33 -0
- python_saga_orchestrator-0.1.3/saga_orchestrator/outbox/contracts.py +87 -0
- python_saga_orchestrator-0.1.3/saga_orchestrator/outbox/dispatcher.py +84 -0
- python_saga_orchestrator-0.1.3/saga_orchestrator/outbox/event.py +12 -0
- python_saga_orchestrator-0.1.3/saga_orchestrator/outbox/factory.py +62 -0
- python_saga_orchestrator-0.1.3/saga_orchestrator/outbox/models.py +71 -0
- python_saga_orchestrator-0.1.3/saga_orchestrator/outbox/repository.py +154 -0
- python_saga_orchestrator-0.1.3/saga_orchestrator/outbox/retry.py +20 -0
- python_saga_orchestrator-0.1.3/saga_orchestrator/outbox/serialization.py +47 -0
- {python_saga_orchestrator-0.1.1 → python_saga_orchestrator-0.1.3}/LICENSE +0 -0
- {python_saga_orchestrator-0.1.1 → python_saga_orchestrator-0.1.3}/python_saga_orchestrator.egg-info/dependency_links.txt +0 -0
- {python_saga_orchestrator-0.1.1 → python_saga_orchestrator-0.1.3}/python_saga_orchestrator.egg-info/requires.txt +0 -0
- {python_saga_orchestrator-0.1.1 → python_saga_orchestrator-0.1.3}/python_saga_orchestrator.egg-info/top_level.txt +0 -0
- {python_saga_orchestrator-0.1.1 → python_saga_orchestrator-0.1.3}/saga_orchestrator/admin/__init__.py +0 -0
- {python_saga_orchestrator-0.1.1 → python_saga_orchestrator-0.1.3}/saga_orchestrator/admin/api.py +0 -0
- {python_saga_orchestrator-0.1.1 → python_saga_orchestrator-0.1.3}/saga_orchestrator/core/__init__.py +0 -0
- {python_saga_orchestrator-0.1.1 → python_saga_orchestrator-0.1.3}/saga_orchestrator/core/repository.py +0 -0
- {python_saga_orchestrator-0.1.1 → python_saga_orchestrator-0.1.3}/saga_orchestrator/domain/__init__.py +0 -0
- {python_saga_orchestrator-0.1.1 → python_saga_orchestrator-0.1.3}/saga_orchestrator/domain/exceptions/__init__.py +0 -0
- {python_saga_orchestrator-0.1.1 → python_saga_orchestrator-0.1.3}/saga_orchestrator/domain/exceptions/saga.py +0 -0
- {python_saga_orchestrator-0.1.1 → python_saga_orchestrator-0.1.3}/saga_orchestrator/domain/mixins/__init__.py +0 -0
- {python_saga_orchestrator-0.1.1 → python_saga_orchestrator-0.1.3}/saga_orchestrator/domain/mixins/saga_state.py +0 -0
- {python_saga_orchestrator-0.1.1 → python_saga_orchestrator-0.1.3}/saga_orchestrator/domain/models/builder.py +0 -0
- {python_saga_orchestrator-0.1.1 → python_saga_orchestrator-0.1.3}/saga_orchestrator/domain/models/enums/__init__.py +0 -0
- {python_saga_orchestrator-0.1.1 → python_saga_orchestrator-0.1.3}/saga_orchestrator/domain/models/enums/saga_status.py +0 -0
- {python_saga_orchestrator-0.1.1 → python_saga_orchestrator-0.1.3}/saga_orchestrator/domain/models/retry.py +0 -0
- {python_saga_orchestrator-0.1.1 → python_saga_orchestrator-0.1.3}/saga_orchestrator/domain/models/saga_snapshot.py +0 -0
- {python_saga_orchestrator-0.1.1 → python_saga_orchestrator-0.1.3}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-saga-orchestrator
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.3
|
|
4
4
|
Summary: Lightweight embedded saga orchestrator for asyncio Python services
|
|
5
5
|
Author-email: Maxim Vasilyev <mayxis@inbox.ru>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -263,6 +263,18 @@ accepted = await orchestrator.notify(
|
|
|
263
263
|
)
|
|
264
264
|
```
|
|
265
265
|
|
|
266
|
+
Configure explicit event expectations through a public API:
|
|
267
|
+
|
|
268
|
+
```python
|
|
269
|
+
token = await orchestrator.await_event(
|
|
270
|
+
saga_id=saga_id,
|
|
271
|
+
event=AwaitingEvent(
|
|
272
|
+
event_type="model.approved",
|
|
273
|
+
correlation_id="corr-123",
|
|
274
|
+
),
|
|
275
|
+
)
|
|
276
|
+
```
|
|
277
|
+
|
|
266
278
|
The event payload is stored in saga context and can be used by root-step `input_map` functions through `InputContext`.
|
|
267
279
|
|
|
268
280
|
## Administrative operations
|
|
@@ -229,6 +229,18 @@ accepted = await orchestrator.notify(
|
|
|
229
229
|
)
|
|
230
230
|
```
|
|
231
231
|
|
|
232
|
+
Configure explicit event expectations through a public API:
|
|
233
|
+
|
|
234
|
+
```python
|
|
235
|
+
token = await orchestrator.await_event(
|
|
236
|
+
saga_id=saga_id,
|
|
237
|
+
event=AwaitingEvent(
|
|
238
|
+
event_type="model.approved",
|
|
239
|
+
correlation_id="corr-123",
|
|
240
|
+
),
|
|
241
|
+
)
|
|
242
|
+
```
|
|
243
|
+
|
|
232
244
|
The event payload is stored in saga context and can be used by root-step `input_map` functions through `InputContext`.
|
|
233
245
|
|
|
234
246
|
## Administrative operations
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "python-saga-orchestrator"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.3"
|
|
8
8
|
description = "Lightweight embedded saga orchestrator for asyncio Python services"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.12"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-saga-orchestrator
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.3
|
|
4
4
|
Summary: Lightweight embedded saga orchestrator for asyncio Python services
|
|
5
5
|
Author-email: Maxim Vasilyev <mayxis@inbox.ru>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -263,6 +263,18 @@ accepted = await orchestrator.notify(
|
|
|
263
263
|
)
|
|
264
264
|
```
|
|
265
265
|
|
|
266
|
+
Configure explicit event expectations through a public API:
|
|
267
|
+
|
|
268
|
+
```python
|
|
269
|
+
token = await orchestrator.await_event(
|
|
270
|
+
saga_id=saga_id,
|
|
271
|
+
event=AwaitingEvent(
|
|
272
|
+
event_type="model.approved",
|
|
273
|
+
correlation_id="corr-123",
|
|
274
|
+
),
|
|
275
|
+
)
|
|
276
|
+
```
|
|
277
|
+
|
|
266
278
|
The event payload is stored in saga context and can be used by root-step `input_map` functions through `InputContext`.
|
|
267
279
|
|
|
268
280
|
## Administrative operations
|
|
@@ -21,8 +21,18 @@ saga_orchestrator/domain/mixins/__init__.py
|
|
|
21
21
|
saga_orchestrator/domain/mixins/saga_state.py
|
|
22
22
|
saga_orchestrator/domain/models/__init__.py
|
|
23
23
|
saga_orchestrator/domain/models/builder.py
|
|
24
|
+
saga_orchestrator/domain/models/notify.py
|
|
24
25
|
saga_orchestrator/domain/models/retry.py
|
|
25
26
|
saga_orchestrator/domain/models/saga_snapshot.py
|
|
26
27
|
saga_orchestrator/domain/models/step.py
|
|
27
28
|
saga_orchestrator/domain/models/enums/__init__.py
|
|
28
|
-
saga_orchestrator/domain/models/enums/saga_status.py
|
|
29
|
+
saga_orchestrator/domain/models/enums/saga_status.py
|
|
30
|
+
saga_orchestrator/outbox/__init__.py
|
|
31
|
+
saga_orchestrator/outbox/contracts.py
|
|
32
|
+
saga_orchestrator/outbox/dispatcher.py
|
|
33
|
+
saga_orchestrator/outbox/event.py
|
|
34
|
+
saga_orchestrator/outbox/factory.py
|
|
35
|
+
saga_orchestrator/outbox/models.py
|
|
36
|
+
saga_orchestrator/outbox/repository.py
|
|
37
|
+
saga_orchestrator/outbox/retry.py
|
|
38
|
+
saga_orchestrator/outbox/serialization.py
|
{python_saga_orchestrator-0.1.1 → python_saga_orchestrator-0.1.3}/saga_orchestrator/__init__.py
RENAMED
|
@@ -11,11 +11,15 @@ from .domain.exceptions import (
|
|
|
11
11
|
)
|
|
12
12
|
from .domain.mixins import SagaStateMixin
|
|
13
13
|
from .domain.models import (
|
|
14
|
+
AwaitingEvent,
|
|
14
15
|
BaseStep,
|
|
15
16
|
ExponentialRetry,
|
|
16
17
|
FixedRetry,
|
|
17
18
|
InputContext,
|
|
18
19
|
NoRetry,
|
|
20
|
+
NotifyEvent,
|
|
21
|
+
NotifyResult,
|
|
22
|
+
OutboxMap,
|
|
19
23
|
RetryPolicy,
|
|
20
24
|
SagaAdminSnapshot,
|
|
21
25
|
SagaDefinition,
|
|
@@ -25,14 +29,50 @@ from .domain.models import (
|
|
|
25
29
|
StepRef,
|
|
26
30
|
)
|
|
27
31
|
from .domain.models.enums import SagaStatus
|
|
32
|
+
from .outbox import (
|
|
33
|
+
ClaimedOutboxMessage,
|
|
34
|
+
DefaultOutboxMessageFactory,
|
|
35
|
+
FixedOutboxDispatchRetry,
|
|
36
|
+
JsonOutboxSerializer,
|
|
37
|
+
OutboxDispatcher,
|
|
38
|
+
OutboxDispatchRetryPolicy,
|
|
39
|
+
OutboxEvent,
|
|
40
|
+
OutboxMessageFactory,
|
|
41
|
+
OutboxMessageMixin,
|
|
42
|
+
OutboxPublisher,
|
|
43
|
+
OutboxRepository,
|
|
44
|
+
OutboxSerializer,
|
|
45
|
+
OutboxStatus,
|
|
46
|
+
OutboxWriteMessage,
|
|
47
|
+
OutboxWriter,
|
|
48
|
+
)
|
|
28
49
|
|
|
29
50
|
__all__ = [
|
|
30
51
|
"ActiveSagaAlreadyExistsError",
|
|
52
|
+
"AwaitingEvent",
|
|
31
53
|
"BaseStep",
|
|
54
|
+
"ClaimedOutboxMessage",
|
|
55
|
+
"DefaultOutboxMessageFactory",
|
|
32
56
|
"ExponentialRetry",
|
|
57
|
+
"FixedOutboxDispatchRetry",
|
|
33
58
|
"FixedRetry",
|
|
34
59
|
"InputContext",
|
|
60
|
+
"JsonOutboxSerializer",
|
|
61
|
+
"NotifyEvent",
|
|
62
|
+
"NotifyResult",
|
|
35
63
|
"NoRetry",
|
|
64
|
+
"OutboxDispatcher",
|
|
65
|
+
"OutboxDispatchRetryPolicy",
|
|
66
|
+
"OutboxEvent",
|
|
67
|
+
"OutboxMessageFactory",
|
|
68
|
+
"OutboxMap",
|
|
69
|
+
"OutboxMessageMixin",
|
|
70
|
+
"OutboxPublisher",
|
|
71
|
+
"OutboxRepository",
|
|
72
|
+
"OutboxSerializer",
|
|
73
|
+
"OutboxStatus",
|
|
74
|
+
"OutboxWriteMessage",
|
|
75
|
+
"OutboxWriter",
|
|
36
76
|
"RetryPolicy",
|
|
37
77
|
"SagaAdmin",
|
|
38
78
|
"SagaAdminSnapshot",
|
{python_saga_orchestrator-0.1.1 → python_saga_orchestrator-0.1.3}/saga_orchestrator/core/builder.py
RENAMED
|
@@ -11,6 +11,7 @@ from ..domain.models import (
|
|
|
11
11
|
BaseStep,
|
|
12
12
|
InputContext,
|
|
13
13
|
NoRetry,
|
|
14
|
+
OutboxMap,
|
|
14
15
|
RetryPolicy,
|
|
15
16
|
SagaDefinition,
|
|
16
17
|
StepDefinition,
|
|
@@ -35,11 +36,14 @@ class SagaBuilder:
|
|
|
35
36
|
timeout: timedelta | None = None,
|
|
36
37
|
retry_policy: RetryPolicy | None = None,
|
|
37
38
|
depends_on: StepRef[Any] | None = None,
|
|
39
|
+
outbox_map: OutboxMap[Any, Any] | None = None,
|
|
38
40
|
step_id: str | None = None,
|
|
39
41
|
) -> StepRef[Any]:
|
|
40
42
|
"""Add one step definition and return a reference to its output."""
|
|
41
43
|
if not callable(input_map):
|
|
42
44
|
raise SagaDefinitionError("input_map must be callable")
|
|
45
|
+
if outbox_map is not None and not callable(outbox_map):
|
|
46
|
+
raise SagaDefinitionError("outbox_map must be callable")
|
|
43
47
|
self.validate_input_map_types(input_map, step.input_model, depends_on)
|
|
44
48
|
|
|
45
49
|
normalized_step_id = step_id or f"step_{len(self._steps)}"
|
|
@@ -53,6 +57,7 @@ class SagaBuilder:
|
|
|
53
57
|
timeout=timeout,
|
|
54
58
|
retry_policy=retry_policy or NoRetry(),
|
|
55
59
|
depends_on=depends_on,
|
|
60
|
+
outbox_map=outbox_map,
|
|
56
61
|
)
|
|
57
62
|
self._steps.append(definition)
|
|
58
63
|
return StepRef(step_id=normalized_step_id, output_model=step.output_model)
|
{python_saga_orchestrator-0.1.1 → python_saga_orchestrator-0.1.3}/saga_orchestrator/core/engine.py
RENAMED
|
@@ -13,13 +13,21 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
|
|
13
13
|
from ..domain.exceptions import SagaDefinitionError, SagaStateError
|
|
14
14
|
from ..domain.mixins import SagaStateMixin
|
|
15
15
|
from ..domain.models import (
|
|
16
|
+
AwaitingEvent,
|
|
16
17
|
InputContext,
|
|
18
|
+
NotifyEvent,
|
|
19
|
+
NotifyResult,
|
|
17
20
|
SagaAdminSnapshot,
|
|
18
21
|
SagaDefinition,
|
|
19
22
|
SagaSnapshot,
|
|
20
23
|
StepDefinition,
|
|
21
24
|
)
|
|
22
25
|
from ..domain.models.enums import SagaStatus
|
|
26
|
+
from ..outbox.contracts import OutboxWriter
|
|
27
|
+
from ..outbox.factory import DefaultOutboxMessageFactory, OutboxMessageFactory
|
|
28
|
+
from ..outbox.models import OutboxMessageMixin
|
|
29
|
+
from ..outbox.repository import OutboxRepository
|
|
30
|
+
from ..outbox.serialization import JsonOutboxSerializer, OutboxSerializer
|
|
23
31
|
from .repository import SagaRepository
|
|
24
32
|
|
|
25
33
|
ModelT = TypeVar("ModelT", bound=SagaStateMixin)
|
|
@@ -33,6 +41,10 @@ class SagaEngine(Generic[ModelT]):
|
|
|
33
41
|
*,
|
|
34
42
|
model_class: type[ModelT],
|
|
35
43
|
session_maker: async_sessionmaker[AsyncSession],
|
|
44
|
+
outbox_model_class: type[OutboxMessageMixin] | None = None,
|
|
45
|
+
outbox_writer: OutboxWriter | None = None,
|
|
46
|
+
outbox_serializer: OutboxSerializer | None = None,
|
|
47
|
+
outbox_message_factory: OutboxMessageFactory | None = None,
|
|
36
48
|
execution_lease: timedelta = timedelta(minutes=5),
|
|
37
49
|
) -> None:
|
|
38
50
|
"""Initialize the engine dependencies and execution lease."""
|
|
@@ -40,6 +52,20 @@ class SagaEngine(Generic[ModelT]):
|
|
|
40
52
|
self._session_maker = session_maker
|
|
41
53
|
self._execution_lease = execution_lease
|
|
42
54
|
self._repository = SagaRepository(model_class)
|
|
55
|
+
self._outbox_repository: OutboxRepository[OutboxMessageMixin] | None = None
|
|
56
|
+
if outbox_writer is not None:
|
|
57
|
+
self._outbox_writer: OutboxWriter | None = outbox_writer
|
|
58
|
+
elif outbox_model_class is not None:
|
|
59
|
+
self._outbox_repository = OutboxRepository(outbox_model_class)
|
|
60
|
+
self._outbox_writer = self._outbox_repository
|
|
61
|
+
else:
|
|
62
|
+
self._outbox_writer = None
|
|
63
|
+
self._outbox_serializer = outbox_serializer or JsonOutboxSerializer(
|
|
64
|
+
normalize=self._serialize_value
|
|
65
|
+
)
|
|
66
|
+
self._outbox_message_factory = (
|
|
67
|
+
outbox_message_factory or DefaultOutboxMessageFactory()
|
|
68
|
+
)
|
|
43
69
|
self._registry: dict[str, SagaDefinition] = {}
|
|
44
70
|
|
|
45
71
|
@property
|
|
@@ -47,6 +73,16 @@ class SagaEngine(Generic[ModelT]):
|
|
|
47
73
|
"""Return the repository used by the engine."""
|
|
48
74
|
return self._repository
|
|
49
75
|
|
|
76
|
+
@property
|
|
77
|
+
def outbox_repository(self) -> OutboxRepository[OutboxMessageMixin] | None:
|
|
78
|
+
"""Return the outbox repository used by the engine."""
|
|
79
|
+
return self._outbox_repository
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def outbox_writer(self) -> OutboxWriter | None:
|
|
83
|
+
"""Return the outbox writer used by the engine."""
|
|
84
|
+
return self._outbox_writer
|
|
85
|
+
|
|
50
86
|
def register(self, name: str, saga_definition: SagaDefinition) -> None:
|
|
51
87
|
"""Register a saga definition under a runtime name."""
|
|
52
88
|
if name in self._registry:
|
|
@@ -102,21 +138,110 @@ class SagaEngine(Generic[ModelT]):
|
|
|
102
138
|
return saga_id
|
|
103
139
|
|
|
104
140
|
async def notify(
|
|
105
|
-
self,
|
|
141
|
+
self,
|
|
142
|
+
*,
|
|
143
|
+
saga_id: UUID,
|
|
144
|
+
token: UUID,
|
|
145
|
+
event: NotifyEvent | dict[str, Any] | Any | None = None,
|
|
106
146
|
) -> bool:
|
|
107
147
|
"""Resume a suspended saga when the provided execution token matches."""
|
|
148
|
+
result = await self.notify_detailed(
|
|
149
|
+
saga_id=saga_id,
|
|
150
|
+
token=token,
|
|
151
|
+
event=event,
|
|
152
|
+
)
|
|
153
|
+
return result == NotifyResult.ACCEPTED
|
|
154
|
+
|
|
155
|
+
async def notify_detailed(
|
|
156
|
+
self,
|
|
157
|
+
*,
|
|
158
|
+
saga_id: UUID,
|
|
159
|
+
token: UUID,
|
|
160
|
+
event: NotifyEvent | dict[str, Any] | Any | None = None,
|
|
161
|
+
) -> NotifyResult:
|
|
162
|
+
"""Resume a suspended saga and return a detailed notify outcome."""
|
|
163
|
+
normalized_event, idempotency_key = self._normalize_notify_event(event)
|
|
164
|
+
|
|
108
165
|
async with self._session_maker() as session:
|
|
109
166
|
async with session.begin():
|
|
110
167
|
saga = await self._repository.get_for_update(session, saga_id)
|
|
111
168
|
if saga.status != SagaStatus.SUSPENDED:
|
|
112
|
-
|
|
169
|
+
self._append_notify_log(
|
|
170
|
+
saga=saga,
|
|
171
|
+
event=normalized_event,
|
|
172
|
+
result=NotifyResult.NOT_SUSPENDED,
|
|
173
|
+
)
|
|
174
|
+
return NotifyResult.NOT_SUSPENDED
|
|
113
175
|
if saga.step_execution_token != token:
|
|
114
176
|
logger.info("Ignoring stale notify for saga_id=%s", saga_id)
|
|
115
|
-
|
|
116
|
-
|
|
177
|
+
self._append_notify_log(
|
|
178
|
+
saga=saga,
|
|
179
|
+
event=normalized_event,
|
|
180
|
+
result=NotifyResult.STALE_TOKEN,
|
|
181
|
+
)
|
|
182
|
+
return NotifyResult.STALE_TOKEN
|
|
183
|
+
|
|
184
|
+
processed_ids = saga.context.setdefault("processed_event_ids", [])
|
|
185
|
+
if idempotency_key is not None and idempotency_key in processed_ids:
|
|
186
|
+
self._append_notify_log(
|
|
187
|
+
saga=saga,
|
|
188
|
+
event=normalized_event,
|
|
189
|
+
result=NotifyResult.DUPLICATE,
|
|
190
|
+
)
|
|
191
|
+
return NotifyResult.DUPLICATE
|
|
192
|
+
|
|
193
|
+
expected_type = saga.context.get("awaiting_event_type")
|
|
194
|
+
if (
|
|
195
|
+
expected_type is not None
|
|
196
|
+
and normalized_event is not None
|
|
197
|
+
and normalized_event.event_type != expected_type
|
|
198
|
+
):
|
|
199
|
+
self._append_notify_log(
|
|
200
|
+
saga=saga,
|
|
201
|
+
event=normalized_event,
|
|
202
|
+
result=NotifyResult.EVENT_TYPE_MISMATCH,
|
|
203
|
+
)
|
|
204
|
+
return NotifyResult.EVENT_TYPE_MISMATCH
|
|
205
|
+
|
|
206
|
+
expected_correlation = saga.context.get("awaiting_correlation_id")
|
|
207
|
+
if (
|
|
208
|
+
expected_correlation is not None
|
|
209
|
+
and normalized_event is not None
|
|
210
|
+
and normalized_event.correlation_id != expected_correlation
|
|
211
|
+
):
|
|
212
|
+
self._append_notify_log(
|
|
213
|
+
saga=saga,
|
|
214
|
+
event=normalized_event,
|
|
215
|
+
result=NotifyResult.CORRELATION_MISMATCH,
|
|
216
|
+
)
|
|
217
|
+
return NotifyResult.CORRELATION_MISMATCH
|
|
218
|
+
|
|
219
|
+
awaiting_until = self._parse_iso_datetime(
|
|
220
|
+
saga.context.get("awaiting_until")
|
|
221
|
+
)
|
|
222
|
+
if awaiting_until is not None and datetime.now(UTC) > awaiting_until:
|
|
223
|
+
self._append_notify_log(
|
|
224
|
+
saga=saga,
|
|
225
|
+
event=normalized_event,
|
|
226
|
+
result=NotifyResult.EXPIRED,
|
|
227
|
+
)
|
|
228
|
+
return NotifyResult.EXPIRED
|
|
229
|
+
|
|
230
|
+
if normalized_event is not None:
|
|
117
231
|
events = saga.context.setdefault("events", [])
|
|
118
|
-
events.append(self._serialize_value(
|
|
119
|
-
saga.context["latest_event"] = self._serialize_value(
|
|
232
|
+
events.append(self._serialize_value(normalized_event.payload))
|
|
233
|
+
saga.context["latest_event"] = self._serialize_value(
|
|
234
|
+
normalized_event.payload
|
|
235
|
+
)
|
|
236
|
+
saga.context["latest_event_meta"] = self._serialize_value(
|
|
237
|
+
normalized_event.model_dump(mode="json")
|
|
238
|
+
)
|
|
239
|
+
if idempotency_key is not None:
|
|
240
|
+
processed_ids.append(idempotency_key)
|
|
241
|
+
|
|
242
|
+
saga.context.pop("awaiting_event_type", None)
|
|
243
|
+
saga.context.pop("awaiting_correlation_id", None)
|
|
244
|
+
saga.context.pop("awaiting_until", None)
|
|
120
245
|
saga.status = SagaStatus.RUNNING
|
|
121
246
|
step_def = self._registry[saga.context["saga_name"]].steps[
|
|
122
247
|
saga.current_step_index
|
|
@@ -126,9 +251,50 @@ class SagaEngine(Generic[ModelT]):
|
|
|
126
251
|
now=datetime.now(UTC),
|
|
127
252
|
)
|
|
128
253
|
saga.step_execution_token = uuid.uuid4()
|
|
254
|
+
self._append_notify_log(
|
|
255
|
+
saga=saga,
|
|
256
|
+
event=normalized_event,
|
|
257
|
+
result=NotifyResult.ACCEPTED,
|
|
258
|
+
)
|
|
129
259
|
|
|
130
260
|
await self._drive(saga_id)
|
|
131
|
-
return
|
|
261
|
+
return NotifyResult.ACCEPTED
|
|
262
|
+
|
|
263
|
+
async def await_event(
|
|
264
|
+
self,
|
|
265
|
+
*,
|
|
266
|
+
saga_id: UUID,
|
|
267
|
+
event: AwaitingEvent,
|
|
268
|
+
) -> UUID:
|
|
269
|
+
"""Configure a suspended saga to wait for a specific external event."""
|
|
270
|
+
async with self._session_maker() as session:
|
|
271
|
+
async with session.begin():
|
|
272
|
+
saga = await self._repository.get_for_update(session, saga_id)
|
|
273
|
+
if saga.status != SagaStatus.SUSPENDED:
|
|
274
|
+
raise SagaStateError(
|
|
275
|
+
"Cannot configure external wait unless saga is suspended "
|
|
276
|
+
f"(status={saga.status.value})"
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
if event.event_type is None:
|
|
280
|
+
saga.context.pop("awaiting_event_type", None)
|
|
281
|
+
else:
|
|
282
|
+
saga.context["awaiting_event_type"] = event.event_type
|
|
283
|
+
|
|
284
|
+
if event.correlation_id is None:
|
|
285
|
+
saga.context.pop("awaiting_correlation_id", None)
|
|
286
|
+
else:
|
|
287
|
+
saga.context["awaiting_correlation_id"] = event.correlation_id
|
|
288
|
+
|
|
289
|
+
if event.until is None:
|
|
290
|
+
saga.context.pop("awaiting_until", None)
|
|
291
|
+
else:
|
|
292
|
+
saga.context["awaiting_until"] = event.until.isoformat()
|
|
293
|
+
|
|
294
|
+
# External event waits should not be auto-resumed by run_due.
|
|
295
|
+
saga.deadline_at = None
|
|
296
|
+
saga.step_execution_token = uuid.uuid4()
|
|
297
|
+
return saga.step_execution_token
|
|
132
298
|
|
|
133
299
|
async def run_due(self, *, limit: int = 100) -> int:
|
|
134
300
|
"""Resume due running, suspended, and compensating sagas."""
|
|
@@ -444,6 +610,31 @@ class SagaEngine(Generic[ModelT]):
|
|
|
444
610
|
return False
|
|
445
611
|
|
|
446
612
|
if error is None and step_output is not None:
|
|
613
|
+
if step_def.outbox_map is not None:
|
|
614
|
+
if self._outbox_writer is None:
|
|
615
|
+
raise SagaStateError(
|
|
616
|
+
"outbox_map is configured for step "
|
|
617
|
+
f"'{step_def.step_id}', but outbox writer is not configured in SagaEngine"
|
|
618
|
+
)
|
|
619
|
+
outbox_events = (
|
|
620
|
+
step_def.outbox_map(step_input, step_output) or []
|
|
621
|
+
)
|
|
622
|
+
if outbox_events:
|
|
623
|
+
now = datetime.now(UTC)
|
|
624
|
+
outbox_messages = (
|
|
625
|
+
self._outbox_message_factory.build_messages(
|
|
626
|
+
saga_id=saga.id,
|
|
627
|
+
aggregation_id=saga.aggregation_id,
|
|
628
|
+
step_id=step_def.step_id,
|
|
629
|
+
trace_id=saga.trace_id,
|
|
630
|
+
step_input=step_input,
|
|
631
|
+
step_output=step_output,
|
|
632
|
+
events=outbox_events,
|
|
633
|
+
now=now,
|
|
634
|
+
serializer=self._outbox_serializer,
|
|
635
|
+
)
|
|
636
|
+
)
|
|
637
|
+
await self._outbox_writer.save(session, outbox_messages)
|
|
447
638
|
saga.step_history.append(
|
|
448
639
|
self._history_entry(
|
|
449
640
|
phase="execute",
|
|
@@ -697,6 +888,60 @@ class SagaEngine(Generic[ModelT]):
|
|
|
697
888
|
return [self._serialize_value(item) for item in value]
|
|
698
889
|
return value
|
|
699
890
|
|
|
891
|
+
def _normalize_notify_event(
|
|
892
|
+
self,
|
|
893
|
+
event: NotifyEvent | dict[str, Any] | Any | None,
|
|
894
|
+
) -> tuple[NotifyEvent | None, str | None]:
|
|
895
|
+
if event is None:
|
|
896
|
+
return None, None
|
|
897
|
+
if isinstance(event, NotifyEvent):
|
|
898
|
+
return event, event.event_id
|
|
899
|
+
if isinstance(event, dict):
|
|
900
|
+
envelope_keys = {
|
|
901
|
+
"event_id",
|
|
902
|
+
"event_type",
|
|
903
|
+
"correlation_id",
|
|
904
|
+
"payload",
|
|
905
|
+
"source",
|
|
906
|
+
"occurred_at",
|
|
907
|
+
}
|
|
908
|
+
if any(key in event for key in envelope_keys):
|
|
909
|
+
notify_event = NotifyEvent.model_validate(event)
|
|
910
|
+
return notify_event, notify_event.event_id
|
|
911
|
+
return NotifyEvent(payload=self._serialize_value(event)), None
|
|
912
|
+
return NotifyEvent(payload=self._serialize_value(event)), None
|
|
913
|
+
|
|
914
|
+
@staticmethod
|
|
915
|
+
def _parse_iso_datetime(value: Any) -> datetime | None:
|
|
916
|
+
if not isinstance(value, str):
|
|
917
|
+
return None
|
|
918
|
+
normalized = value.replace("Z", "+00:00")
|
|
919
|
+
try:
|
|
920
|
+
parsed = datetime.fromisoformat(normalized)
|
|
921
|
+
except ValueError:
|
|
922
|
+
return None
|
|
923
|
+
if parsed.tzinfo is None:
|
|
924
|
+
return parsed.replace(tzinfo=UTC)
|
|
925
|
+
return parsed
|
|
926
|
+
|
|
927
|
+
def _append_notify_log(
|
|
928
|
+
self,
|
|
929
|
+
*,
|
|
930
|
+
saga: ModelT,
|
|
931
|
+
event: NotifyEvent | None,
|
|
932
|
+
result: NotifyResult,
|
|
933
|
+
) -> None:
|
|
934
|
+
inbox = saga.context.setdefault("notify_inbox", [])
|
|
935
|
+
inbox.append(
|
|
936
|
+
{
|
|
937
|
+
"timestamp": datetime.now(UTC).isoformat(),
|
|
938
|
+
"result": result.value,
|
|
939
|
+
"event_id": event.event_id if event is not None else None,
|
|
940
|
+
"event_type": event.event_type if event is not None else None,
|
|
941
|
+
"correlation_id": (event.correlation_id if event is not None else None),
|
|
942
|
+
}
|
|
943
|
+
)
|
|
944
|
+
|
|
700
945
|
def _history_entry(
|
|
701
946
|
self,
|
|
702
947
|
*,
|
|
@@ -8,7 +8,18 @@ from pydantic import BaseModel
|
|
|
8
8
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
|
9
9
|
|
|
10
10
|
from ..domain.mixins import SagaStateMixin
|
|
11
|
-
from ..domain.models import
|
|
11
|
+
from ..domain.models import (
|
|
12
|
+
AwaitingEvent,
|
|
13
|
+
NotifyEvent,
|
|
14
|
+
NotifyResult,
|
|
15
|
+
SagaDefinition,
|
|
16
|
+
SagaSnapshot,
|
|
17
|
+
)
|
|
18
|
+
from ..outbox.contracts import OutboxWriter
|
|
19
|
+
from ..outbox.factory import OutboxMessageFactory
|
|
20
|
+
from ..outbox.models import OutboxMessageMixin
|
|
21
|
+
from ..outbox.repository import OutboxRepository
|
|
22
|
+
from ..outbox.serialization import OutboxSerializer
|
|
12
23
|
from .engine import SagaEngine
|
|
13
24
|
from .repository import SagaRepository
|
|
14
25
|
|
|
@@ -23,12 +34,20 @@ class SagaOrchestrator(Generic[ModelT]):
|
|
|
23
34
|
*,
|
|
24
35
|
model_class: type[ModelT],
|
|
25
36
|
session_maker: async_sessionmaker[AsyncSession],
|
|
37
|
+
outbox_model_class: type[OutboxMessageMixin] | None = None,
|
|
38
|
+
outbox_writer: OutboxWriter | None = None,
|
|
39
|
+
outbox_serializer: OutboxSerializer | None = None,
|
|
40
|
+
outbox_message_factory: OutboxMessageFactory | None = None,
|
|
26
41
|
execution_lease: timedelta = timedelta(minutes=5),
|
|
27
42
|
) -> None:
|
|
28
43
|
"""Initialize the orchestrator facade."""
|
|
29
44
|
self._engine = SagaEngine(
|
|
30
45
|
model_class=model_class,
|
|
31
46
|
session_maker=session_maker,
|
|
47
|
+
outbox_model_class=outbox_model_class,
|
|
48
|
+
outbox_writer=outbox_writer,
|
|
49
|
+
outbox_serializer=outbox_serializer,
|
|
50
|
+
outbox_message_factory=outbox_message_factory,
|
|
32
51
|
execution_lease=execution_lease,
|
|
33
52
|
)
|
|
34
53
|
|
|
@@ -42,6 +61,16 @@ class SagaOrchestrator(Generic[ModelT]):
|
|
|
42
61
|
"""Return the repository used by the engine."""
|
|
43
62
|
return self._engine.repository
|
|
44
63
|
|
|
64
|
+
@property
|
|
65
|
+
def outbox_repository(self) -> OutboxRepository[OutboxMessageMixin] | None:
|
|
66
|
+
"""Return the outbox repository used by the engine."""
|
|
67
|
+
return self._engine.outbox_repository
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def outbox_writer(self) -> OutboxWriter | None:
|
|
71
|
+
"""Return the outbox writer used by the engine."""
|
|
72
|
+
return self._engine.outbox_writer
|
|
73
|
+
|
|
45
74
|
def register(self, name: str, saga_definition: SagaDefinition) -> None:
|
|
46
75
|
"""Register a saga definition under a runtime name."""
|
|
47
76
|
self._engine.register(name, saga_definition)
|
|
@@ -63,11 +92,38 @@ class SagaOrchestrator(Generic[ModelT]):
|
|
|
63
92
|
)
|
|
64
93
|
|
|
65
94
|
async def notify(
|
|
66
|
-
self,
|
|
95
|
+
self,
|
|
96
|
+
*,
|
|
97
|
+
saga_id: UUID,
|
|
98
|
+
token: UUID,
|
|
99
|
+
event: NotifyEvent | dict[str, Any] | Any | None = None,
|
|
67
100
|
) -> bool:
|
|
68
101
|
"""Resume a suspended saga when the provided execution token matches."""
|
|
69
102
|
return await self._engine.notify(saga_id=saga_id, token=token, event=event)
|
|
70
103
|
|
|
104
|
+
async def notify_detailed(
|
|
105
|
+
self,
|
|
106
|
+
*,
|
|
107
|
+
saga_id: UUID,
|
|
108
|
+
token: UUID,
|
|
109
|
+
event: NotifyEvent | dict[str, Any] | Any | None = None,
|
|
110
|
+
) -> NotifyResult:
|
|
111
|
+
"""Resume a suspended saga and return a detailed notify outcome."""
|
|
112
|
+
return await self._engine.notify_detailed(
|
|
113
|
+
saga_id=saga_id,
|
|
114
|
+
token=token,
|
|
115
|
+
event=event,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
async def await_event(
|
|
119
|
+
self,
|
|
120
|
+
*,
|
|
121
|
+
saga_id: UUID,
|
|
122
|
+
event: AwaitingEvent,
|
|
123
|
+
) -> UUID:
|
|
124
|
+
"""Configure a suspended saga to wait for an external event."""
|
|
125
|
+
return await self._engine.await_event(saga_id=saga_id, event=event)
|
|
126
|
+
|
|
71
127
|
async def run_due(self, *, limit: int = 100) -> int:
|
|
72
128
|
"""Resume due running, suspended, and compensating sagas."""
|
|
73
129
|
return await self._engine.run_due(limit=limit)
|
|
@@ -1,12 +1,23 @@
|
|
|
1
1
|
"""Domain models module."""
|
|
2
2
|
|
|
3
3
|
from .builder import SagaDefinition
|
|
4
|
+
from .notify import AwaitingEvent, NotifyEvent, NotifyResult
|
|
4
5
|
from .retry import ExponentialRetry, FixedRetry, NoRetry, RetryPolicy
|
|
5
6
|
from .saga_snapshot import SagaAdminSnapshot, SagaSnapshot
|
|
6
|
-
from .step import
|
|
7
|
+
from .step import (
|
|
8
|
+
BaseStep,
|
|
9
|
+
InputContext,
|
|
10
|
+
OutboxMap,
|
|
11
|
+
StepDefinition,
|
|
12
|
+
StepInputMap,
|
|
13
|
+
StepRef,
|
|
14
|
+
)
|
|
7
15
|
|
|
8
16
|
__all__ = [
|
|
9
17
|
"SagaDefinition",
|
|
18
|
+
"AwaitingEvent",
|
|
19
|
+
"NotifyEvent",
|
|
20
|
+
"NotifyResult",
|
|
10
21
|
"RetryPolicy",
|
|
11
22
|
"NoRetry",
|
|
12
23
|
"FixedRetry",
|
|
@@ -15,6 +26,7 @@ __all__ = [
|
|
|
15
26
|
"SagaSnapshot",
|
|
16
27
|
"StepRef",
|
|
17
28
|
"InputContext",
|
|
29
|
+
"OutboxMap",
|
|
18
30
|
"StepInputMap",
|
|
19
31
|
"StepDefinition",
|
|
20
32
|
"BaseStep",
|