python-saga-orchestrator 0.1.3__tar.gz → 0.1.4__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.3/python_saga_orchestrator.egg-info → python_saga_orchestrator-0.1.4}/PKG-INFO +20 -1
- {python_saga_orchestrator-0.1.3 → python_saga_orchestrator-0.1.4}/README.md +19 -0
- {python_saga_orchestrator-0.1.3 → python_saga_orchestrator-0.1.4}/pyproject.toml +1 -1
- {python_saga_orchestrator-0.1.3 → python_saga_orchestrator-0.1.4/python_saga_orchestrator.egg-info}/PKG-INFO +20 -1
- {python_saga_orchestrator-0.1.3 → python_saga_orchestrator-0.1.4}/python_saga_orchestrator.egg-info/SOURCES.txt +6 -0
- {python_saga_orchestrator-0.1.3 → python_saga_orchestrator-0.1.4}/saga_orchestrator/__init__.py +28 -0
- {python_saga_orchestrator-0.1.3 → python_saga_orchestrator-0.1.4}/saga_orchestrator/core/engine.py +263 -4
- {python_saga_orchestrator-0.1.3 → python_saga_orchestrator-0.1.4}/saga_orchestrator/core/orchestrator.py +35 -0
- {python_saga_orchestrator-0.1.3 → python_saga_orchestrator-0.1.4}/saga_orchestrator/domain/models/__init__.py +2 -0
- {python_saga_orchestrator-0.1.3 → python_saga_orchestrator-0.1.4}/saga_orchestrator/domain/models/notify.py +1 -0
- {python_saga_orchestrator-0.1.3 → python_saga_orchestrator-0.1.4}/saga_orchestrator/domain/models/step.py +42 -7
- python_saga_orchestrator-0.1.4/saga_orchestrator/inbox/__init__.py +27 -0
- python_saga_orchestrator-0.1.4/saga_orchestrator/inbox/contracts.py +81 -0
- python_saga_orchestrator-0.1.4/saga_orchestrator/inbox/dispatcher.py +120 -0
- python_saga_orchestrator-0.1.4/saga_orchestrator/inbox/models.py +84 -0
- python_saga_orchestrator-0.1.4/saga_orchestrator/inbox/repository.py +165 -0
- python_saga_orchestrator-0.1.4/saga_orchestrator/inbox/retry.py +20 -0
- {python_saga_orchestrator-0.1.3 → python_saga_orchestrator-0.1.4}/LICENSE +0 -0
- {python_saga_orchestrator-0.1.3 → python_saga_orchestrator-0.1.4}/python_saga_orchestrator.egg-info/dependency_links.txt +0 -0
- {python_saga_orchestrator-0.1.3 → python_saga_orchestrator-0.1.4}/python_saga_orchestrator.egg-info/requires.txt +0 -0
- {python_saga_orchestrator-0.1.3 → python_saga_orchestrator-0.1.4}/python_saga_orchestrator.egg-info/top_level.txt +0 -0
- {python_saga_orchestrator-0.1.3 → python_saga_orchestrator-0.1.4}/saga_orchestrator/admin/__init__.py +0 -0
- {python_saga_orchestrator-0.1.3 → python_saga_orchestrator-0.1.4}/saga_orchestrator/admin/api.py +0 -0
- {python_saga_orchestrator-0.1.3 → python_saga_orchestrator-0.1.4}/saga_orchestrator/core/__init__.py +0 -0
- {python_saga_orchestrator-0.1.3 → python_saga_orchestrator-0.1.4}/saga_orchestrator/core/builder.py +0 -0
- {python_saga_orchestrator-0.1.3 → python_saga_orchestrator-0.1.4}/saga_orchestrator/core/repository.py +0 -0
- {python_saga_orchestrator-0.1.3 → python_saga_orchestrator-0.1.4}/saga_orchestrator/domain/__init__.py +0 -0
- {python_saga_orchestrator-0.1.3 → python_saga_orchestrator-0.1.4}/saga_orchestrator/domain/exceptions/__init__.py +0 -0
- {python_saga_orchestrator-0.1.3 → python_saga_orchestrator-0.1.4}/saga_orchestrator/domain/exceptions/saga.py +0 -0
- {python_saga_orchestrator-0.1.3 → python_saga_orchestrator-0.1.4}/saga_orchestrator/domain/mixins/__init__.py +0 -0
- {python_saga_orchestrator-0.1.3 → python_saga_orchestrator-0.1.4}/saga_orchestrator/domain/mixins/saga_state.py +0 -0
- {python_saga_orchestrator-0.1.3 → python_saga_orchestrator-0.1.4}/saga_orchestrator/domain/models/builder.py +0 -0
- {python_saga_orchestrator-0.1.3 → python_saga_orchestrator-0.1.4}/saga_orchestrator/domain/models/enums/__init__.py +0 -0
- {python_saga_orchestrator-0.1.3 → python_saga_orchestrator-0.1.4}/saga_orchestrator/domain/models/enums/saga_status.py +0 -0
- {python_saga_orchestrator-0.1.3 → python_saga_orchestrator-0.1.4}/saga_orchestrator/domain/models/retry.py +0 -0
- {python_saga_orchestrator-0.1.3 → python_saga_orchestrator-0.1.4}/saga_orchestrator/domain/models/saga_snapshot.py +0 -0
- {python_saga_orchestrator-0.1.3 → python_saga_orchestrator-0.1.4}/saga_orchestrator/outbox/__init__.py +0 -0
- {python_saga_orchestrator-0.1.3 → python_saga_orchestrator-0.1.4}/saga_orchestrator/outbox/contracts.py +0 -0
- {python_saga_orchestrator-0.1.3 → python_saga_orchestrator-0.1.4}/saga_orchestrator/outbox/dispatcher.py +0 -0
- {python_saga_orchestrator-0.1.3 → python_saga_orchestrator-0.1.4}/saga_orchestrator/outbox/event.py +0 -0
- {python_saga_orchestrator-0.1.3 → python_saga_orchestrator-0.1.4}/saga_orchestrator/outbox/factory.py +0 -0
- {python_saga_orchestrator-0.1.3 → python_saga_orchestrator-0.1.4}/saga_orchestrator/outbox/models.py +0 -0
- {python_saga_orchestrator-0.1.3 → python_saga_orchestrator-0.1.4}/saga_orchestrator/outbox/repository.py +0 -0
- {python_saga_orchestrator-0.1.3 → python_saga_orchestrator-0.1.4}/saga_orchestrator/outbox/retry.py +0 -0
- {python_saga_orchestrator-0.1.3 → python_saga_orchestrator-0.1.4}/saga_orchestrator/outbox/serialization.py +0 -0
- {python_saga_orchestrator-0.1.3 → python_saga_orchestrator-0.1.4}/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.4
|
|
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
|
|
@@ -51,6 +51,7 @@ Unlike external workflow platforms, this library runs inside your service and st
|
|
|
51
51
|
- persisted saga state through `SagaStateMixin`
|
|
52
52
|
- runtime execution through `SagaOrchestrator` and `SagaEngine`
|
|
53
53
|
- retry, timeout, recovery, and compensation
|
|
54
|
+
- async queue-style steps through `StepAwaitEvent` and `notify(...)`
|
|
54
55
|
- administrative operations through `SagaAdmin`
|
|
55
56
|
- PostgreSQL-first reliability using `SELECT ... FOR UPDATE`
|
|
56
57
|
|
|
@@ -277,6 +278,23 @@ token = await orchestrator.await_event(
|
|
|
277
278
|
|
|
278
279
|
The event payload is stored in saga context and can be used by root-step `input_map` functions through `InputContext`.
|
|
279
280
|
|
|
281
|
+
For distributed consumers, use transactional inbox ingestion first, then process inbox rows:
|
|
282
|
+
|
|
283
|
+
```python
|
|
284
|
+
stored = await orchestrator.ingest_event(
|
|
285
|
+
aggregation_id="order-123",
|
|
286
|
+
event={
|
|
287
|
+
"event_id": "evt-123",
|
|
288
|
+
"event_type": "payment.completed",
|
|
289
|
+
"correlation_id": "corr-123",
|
|
290
|
+
"payload": {"payment_id": "pay-1"},
|
|
291
|
+
},
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
if stored:
|
|
295
|
+
await orchestrator.run_inbox_due(limit=100)
|
|
296
|
+
```
|
|
297
|
+
|
|
280
298
|
## Administrative operations
|
|
281
299
|
|
|
282
300
|
Get the full persisted state:
|
|
@@ -334,6 +352,7 @@ A runnable end-to-end example is available in:
|
|
|
334
352
|
- [`examples/retry_recovery.py`](./examples/retry_recovery.py)
|
|
335
353
|
- [`examples/compensation_flow.py`](./examples/compensation_flow.py)
|
|
336
354
|
- [`examples/admin_skip.py`](./examples/admin_skip.py)
|
|
355
|
+
- [`examples/http_and_queue.py`](./examples/http_and_queue.py)
|
|
337
356
|
|
|
338
357
|
These examples demonstrate:
|
|
339
358
|
- basic model deployment
|
|
@@ -17,6 +17,7 @@ Unlike external workflow platforms, this library runs inside your service and st
|
|
|
17
17
|
- persisted saga state through `SagaStateMixin`
|
|
18
18
|
- runtime execution through `SagaOrchestrator` and `SagaEngine`
|
|
19
19
|
- retry, timeout, recovery, and compensation
|
|
20
|
+
- async queue-style steps through `StepAwaitEvent` and `notify(...)`
|
|
20
21
|
- administrative operations through `SagaAdmin`
|
|
21
22
|
- PostgreSQL-first reliability using `SELECT ... FOR UPDATE`
|
|
22
23
|
|
|
@@ -243,6 +244,23 @@ token = await orchestrator.await_event(
|
|
|
243
244
|
|
|
244
245
|
The event payload is stored in saga context and can be used by root-step `input_map` functions through `InputContext`.
|
|
245
246
|
|
|
247
|
+
For distributed consumers, use transactional inbox ingestion first, then process inbox rows:
|
|
248
|
+
|
|
249
|
+
```python
|
|
250
|
+
stored = await orchestrator.ingest_event(
|
|
251
|
+
aggregation_id="order-123",
|
|
252
|
+
event={
|
|
253
|
+
"event_id": "evt-123",
|
|
254
|
+
"event_type": "payment.completed",
|
|
255
|
+
"correlation_id": "corr-123",
|
|
256
|
+
"payload": {"payment_id": "pay-1"},
|
|
257
|
+
},
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
if stored:
|
|
261
|
+
await orchestrator.run_inbox_due(limit=100)
|
|
262
|
+
```
|
|
263
|
+
|
|
246
264
|
## Administrative operations
|
|
247
265
|
|
|
248
266
|
Get the full persisted state:
|
|
@@ -300,6 +318,7 @@ A runnable end-to-end example is available in:
|
|
|
300
318
|
- [`examples/retry_recovery.py`](./examples/retry_recovery.py)
|
|
301
319
|
- [`examples/compensation_flow.py`](./examples/compensation_flow.py)
|
|
302
320
|
- [`examples/admin_skip.py`](./examples/admin_skip.py)
|
|
321
|
+
- [`examples/http_and_queue.py`](./examples/http_and_queue.py)
|
|
303
322
|
|
|
304
323
|
These examples demonstrate:
|
|
305
324
|
- basic model deployment
|
|
@@ -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.4"
|
|
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.4
|
|
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
|
|
@@ -51,6 +51,7 @@ Unlike external workflow platforms, this library runs inside your service and st
|
|
|
51
51
|
- persisted saga state through `SagaStateMixin`
|
|
52
52
|
- runtime execution through `SagaOrchestrator` and `SagaEngine`
|
|
53
53
|
- retry, timeout, recovery, and compensation
|
|
54
|
+
- async queue-style steps through `StepAwaitEvent` and `notify(...)`
|
|
54
55
|
- administrative operations through `SagaAdmin`
|
|
55
56
|
- PostgreSQL-first reliability using `SELECT ... FOR UPDATE`
|
|
56
57
|
|
|
@@ -277,6 +278,23 @@ token = await orchestrator.await_event(
|
|
|
277
278
|
|
|
278
279
|
The event payload is stored in saga context and can be used by root-step `input_map` functions through `InputContext`.
|
|
279
280
|
|
|
281
|
+
For distributed consumers, use transactional inbox ingestion first, then process inbox rows:
|
|
282
|
+
|
|
283
|
+
```python
|
|
284
|
+
stored = await orchestrator.ingest_event(
|
|
285
|
+
aggregation_id="order-123",
|
|
286
|
+
event={
|
|
287
|
+
"event_id": "evt-123",
|
|
288
|
+
"event_type": "payment.completed",
|
|
289
|
+
"correlation_id": "corr-123",
|
|
290
|
+
"payload": {"payment_id": "pay-1"},
|
|
291
|
+
},
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
if stored:
|
|
295
|
+
await orchestrator.run_inbox_due(limit=100)
|
|
296
|
+
```
|
|
297
|
+
|
|
280
298
|
## Administrative operations
|
|
281
299
|
|
|
282
300
|
Get the full persisted state:
|
|
@@ -334,6 +352,7 @@ A runnable end-to-end example is available in:
|
|
|
334
352
|
- [`examples/retry_recovery.py`](./examples/retry_recovery.py)
|
|
335
353
|
- [`examples/compensation_flow.py`](./examples/compensation_flow.py)
|
|
336
354
|
- [`examples/admin_skip.py`](./examples/admin_skip.py)
|
|
355
|
+
- [`examples/http_and_queue.py`](./examples/http_and_queue.py)
|
|
337
356
|
|
|
338
357
|
These examples demonstrate:
|
|
339
358
|
- basic model deployment
|
|
@@ -27,6 +27,12 @@ saga_orchestrator/domain/models/saga_snapshot.py
|
|
|
27
27
|
saga_orchestrator/domain/models/step.py
|
|
28
28
|
saga_orchestrator/domain/models/enums/__init__.py
|
|
29
29
|
saga_orchestrator/domain/models/enums/saga_status.py
|
|
30
|
+
saga_orchestrator/inbox/__init__.py
|
|
31
|
+
saga_orchestrator/inbox/contracts.py
|
|
32
|
+
saga_orchestrator/inbox/dispatcher.py
|
|
33
|
+
saga_orchestrator/inbox/models.py
|
|
34
|
+
saga_orchestrator/inbox/repository.py
|
|
35
|
+
saga_orchestrator/inbox/retry.py
|
|
30
36
|
saga_orchestrator/outbox/__init__.py
|
|
31
37
|
saga_orchestrator/outbox/contracts.py
|
|
32
38
|
saga_orchestrator/outbox/dispatcher.py
|
{python_saga_orchestrator-0.1.3 → python_saga_orchestrator-0.1.4}/saga_orchestrator/__init__.py
RENAMED
|
@@ -24,11 +24,26 @@ from .domain.models import (
|
|
|
24
24
|
SagaAdminSnapshot,
|
|
25
25
|
SagaDefinition,
|
|
26
26
|
SagaSnapshot,
|
|
27
|
+
StepAwaitEvent,
|
|
27
28
|
StepDefinition,
|
|
28
29
|
StepInputMap,
|
|
29
30
|
StepRef,
|
|
30
31
|
)
|
|
31
32
|
from .domain.models.enums import SagaStatus
|
|
33
|
+
from .inbox import (
|
|
34
|
+
ClaimedInboxMessage,
|
|
35
|
+
FixedInboxRetry,
|
|
36
|
+
InboxDispatcher,
|
|
37
|
+
InboxMessageMixin,
|
|
38
|
+
InboxProcessor,
|
|
39
|
+
InboxProcessOutcome,
|
|
40
|
+
InboxProcessStatus,
|
|
41
|
+
InboxRepository,
|
|
42
|
+
InboxRetryPolicy,
|
|
43
|
+
InboxStatus,
|
|
44
|
+
InboxWriteMessage,
|
|
45
|
+
InboxWriter,
|
|
46
|
+
)
|
|
32
47
|
from .outbox import (
|
|
33
48
|
ClaimedOutboxMessage,
|
|
34
49
|
DefaultOutboxMessageFactory,
|
|
@@ -51,11 +66,23 @@ __all__ = [
|
|
|
51
66
|
"ActiveSagaAlreadyExistsError",
|
|
52
67
|
"AwaitingEvent",
|
|
53
68
|
"BaseStep",
|
|
69
|
+
"ClaimedInboxMessage",
|
|
54
70
|
"ClaimedOutboxMessage",
|
|
55
71
|
"DefaultOutboxMessageFactory",
|
|
56
72
|
"ExponentialRetry",
|
|
73
|
+
"FixedInboxRetry",
|
|
57
74
|
"FixedOutboxDispatchRetry",
|
|
58
75
|
"FixedRetry",
|
|
76
|
+
"InboxDispatcher",
|
|
77
|
+
"InboxMessageMixin",
|
|
78
|
+
"InboxProcessOutcome",
|
|
79
|
+
"InboxProcessStatus",
|
|
80
|
+
"InboxProcessor",
|
|
81
|
+
"InboxRepository",
|
|
82
|
+
"InboxRetryPolicy",
|
|
83
|
+
"InboxStatus",
|
|
84
|
+
"InboxWriteMessage",
|
|
85
|
+
"InboxWriter",
|
|
59
86
|
"InputContext",
|
|
60
87
|
"JsonOutboxSerializer",
|
|
61
88
|
"NotifyEvent",
|
|
@@ -88,6 +115,7 @@ __all__ = [
|
|
|
88
115
|
"SagaStateMixin",
|
|
89
116
|
"SagaStatus",
|
|
90
117
|
"StepDefinition",
|
|
118
|
+
"StepAwaitEvent",
|
|
91
119
|
"StepInputMap",
|
|
92
120
|
"StepRef",
|
|
93
121
|
"TypeValidationError",
|
{python_saga_orchestrator-0.1.3 → python_saga_orchestrator-0.1.4}/saga_orchestrator/core/engine.py
RENAMED
|
@@ -10,7 +10,7 @@ from loguru import logger
|
|
|
10
10
|
from pydantic import BaseModel
|
|
11
11
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
|
12
12
|
|
|
13
|
-
from ..domain.exceptions import SagaDefinitionError, SagaStateError
|
|
13
|
+
from ..domain.exceptions import SagaDefinitionError, SagaNotFoundError, SagaStateError
|
|
14
14
|
from ..domain.mixins import SagaStateMixin
|
|
15
15
|
from ..domain.models import (
|
|
16
16
|
AwaitingEvent,
|
|
@@ -20,10 +20,15 @@ from ..domain.models import (
|
|
|
20
20
|
SagaAdminSnapshot,
|
|
21
21
|
SagaDefinition,
|
|
22
22
|
SagaSnapshot,
|
|
23
|
+
StepAwaitEvent,
|
|
23
24
|
StepDefinition,
|
|
24
25
|
)
|
|
25
26
|
from ..domain.models.enums import SagaStatus
|
|
26
|
-
from ..
|
|
27
|
+
from ..inbox.contracts import ClaimedInboxMessage, InboxWriteMessage, InboxWriter
|
|
28
|
+
from ..inbox.dispatcher import InboxDispatcher, InboxProcessOutcome, InboxProcessStatus
|
|
29
|
+
from ..inbox.models import InboxMessageMixin
|
|
30
|
+
from ..inbox.repository import InboxRepository
|
|
31
|
+
from ..outbox.contracts import OutboxWriteMessage, OutboxWriter
|
|
27
32
|
from ..outbox.factory import DefaultOutboxMessageFactory, OutboxMessageFactory
|
|
28
33
|
from ..outbox.models import OutboxMessageMixin
|
|
29
34
|
from ..outbox.repository import OutboxRepository
|
|
@@ -41,6 +46,8 @@ class SagaEngine(Generic[ModelT]):
|
|
|
41
46
|
*,
|
|
42
47
|
model_class: type[ModelT],
|
|
43
48
|
session_maker: async_sessionmaker[AsyncSession],
|
|
49
|
+
inbox_model_class: type[InboxMessageMixin] | None = None,
|
|
50
|
+
inbox_writer: InboxWriter | None = None,
|
|
44
51
|
outbox_model_class: type[OutboxMessageMixin] | None = None,
|
|
45
52
|
outbox_writer: OutboxWriter | None = None,
|
|
46
53
|
outbox_serializer: OutboxSerializer | None = None,
|
|
@@ -52,6 +59,21 @@ class SagaEngine(Generic[ModelT]):
|
|
|
52
59
|
self._session_maker = session_maker
|
|
53
60
|
self._execution_lease = execution_lease
|
|
54
61
|
self._repository = SagaRepository(model_class)
|
|
62
|
+
self._inbox_repository: InboxRepository[InboxMessageMixin] | None = None
|
|
63
|
+
if inbox_writer is not None:
|
|
64
|
+
self._inbox_writer: InboxWriter | None = inbox_writer
|
|
65
|
+
elif inbox_model_class is not None:
|
|
66
|
+
self._inbox_repository = InboxRepository(inbox_model_class)
|
|
67
|
+
self._inbox_writer = self._inbox_repository
|
|
68
|
+
else:
|
|
69
|
+
self._inbox_writer = None
|
|
70
|
+
self._inbox_dispatcher: InboxDispatcher | None = None
|
|
71
|
+
if self._inbox_writer is not None:
|
|
72
|
+
self._inbox_dispatcher = InboxDispatcher(
|
|
73
|
+
session_maker=session_maker,
|
|
74
|
+
writer=self._inbox_writer,
|
|
75
|
+
processor=self,
|
|
76
|
+
)
|
|
55
77
|
self._outbox_repository: OutboxRepository[OutboxMessageMixin] | None = None
|
|
56
78
|
if outbox_writer is not None:
|
|
57
79
|
self._outbox_writer: OutboxWriter | None = outbox_writer
|
|
@@ -73,6 +95,16 @@ class SagaEngine(Generic[ModelT]):
|
|
|
73
95
|
"""Return the repository used by the engine."""
|
|
74
96
|
return self._repository
|
|
75
97
|
|
|
98
|
+
@property
|
|
99
|
+
def inbox_repository(self) -> InboxRepository[InboxMessageMixin] | None:
|
|
100
|
+
"""Return the inbox repository used by the engine."""
|
|
101
|
+
return self._inbox_repository
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def inbox_writer(self) -> InboxWriter | None:
|
|
105
|
+
"""Return the inbox writer used by the engine."""
|
|
106
|
+
return self._inbox_writer
|
|
107
|
+
|
|
76
108
|
@property
|
|
77
109
|
def outbox_repository(self) -> OutboxRepository[OutboxMessageMixin] | None:
|
|
78
110
|
"""Return the outbox repository used by the engine."""
|
|
@@ -190,7 +222,30 @@ class SagaEngine(Generic[ModelT]):
|
|
|
190
222
|
)
|
|
191
223
|
return NotifyResult.DUPLICATE
|
|
192
224
|
|
|
225
|
+
expected_types = saga.context.get("awaiting_event_types")
|
|
193
226
|
expected_type = saga.context.get("awaiting_event_type")
|
|
227
|
+
if normalized_event is None and (
|
|
228
|
+
expected_type is not None
|
|
229
|
+
or (isinstance(expected_types, list) and len(expected_types) > 0)
|
|
230
|
+
):
|
|
231
|
+
self._append_notify_log(
|
|
232
|
+
saga=saga,
|
|
233
|
+
event=normalized_event,
|
|
234
|
+
result=NotifyResult.EVENT_TYPE_MISMATCH,
|
|
235
|
+
)
|
|
236
|
+
return NotifyResult.EVENT_TYPE_MISMATCH
|
|
237
|
+
if (
|
|
238
|
+
normalized_event is not None
|
|
239
|
+
and isinstance(expected_types, list)
|
|
240
|
+
and expected_types
|
|
241
|
+
and normalized_event.event_type not in expected_types
|
|
242
|
+
):
|
|
243
|
+
self._append_notify_log(
|
|
244
|
+
saga=saga,
|
|
245
|
+
event=normalized_event,
|
|
246
|
+
result=NotifyResult.EVENT_TYPE_MISMATCH,
|
|
247
|
+
)
|
|
248
|
+
return NotifyResult.EVENT_TYPE_MISMATCH
|
|
194
249
|
if (
|
|
195
250
|
expected_type is not None
|
|
196
251
|
and normalized_event is not None
|
|
@@ -240,6 +295,7 @@ class SagaEngine(Generic[ModelT]):
|
|
|
240
295
|
processed_ids.append(idempotency_key)
|
|
241
296
|
|
|
242
297
|
saga.context.pop("awaiting_event_type", None)
|
|
298
|
+
saga.context.pop("awaiting_event_types", None)
|
|
243
299
|
saga.context.pop("awaiting_correlation_id", None)
|
|
244
300
|
saga.context.pop("awaiting_until", None)
|
|
245
301
|
saga.status = SagaStatus.RUNNING
|
|
@@ -260,6 +316,48 @@ class SagaEngine(Generic[ModelT]):
|
|
|
260
316
|
await self._drive(saga_id)
|
|
261
317
|
return NotifyResult.ACCEPTED
|
|
262
318
|
|
|
319
|
+
async def ingest_event(
|
|
320
|
+
self,
|
|
321
|
+
*,
|
|
322
|
+
event: NotifyEvent | dict[str, Any] | Any,
|
|
323
|
+
saga_id: UUID | None = None,
|
|
324
|
+
aggregation_id: str | None = None,
|
|
325
|
+
) -> bool:
|
|
326
|
+
"""Persist one inbound event into inbox storage for asynchronous processing."""
|
|
327
|
+
if self._inbox_writer is None:
|
|
328
|
+
raise SagaStateError("Inbox writer is not configured in SagaEngine")
|
|
329
|
+
|
|
330
|
+
normalized_event, idempotency_key = self._normalize_notify_event(event)
|
|
331
|
+
if normalized_event is None:
|
|
332
|
+
raise SagaStateError("Inbox ingestion requires a non-empty event payload")
|
|
333
|
+
if idempotency_key is None:
|
|
334
|
+
raise SagaStateError("Inbox ingestion requires event.event_id")
|
|
335
|
+
if saga_id is None and aggregation_id is None:
|
|
336
|
+
raise SagaStateError(
|
|
337
|
+
"Inbox ingestion requires saga_id or aggregation_id for routing"
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
message = InboxWriteMessage(
|
|
341
|
+
event_id=idempotency_key,
|
|
342
|
+
saga_id=saga_id,
|
|
343
|
+
aggregation_id=aggregation_id,
|
|
344
|
+
event_type=normalized_event.event_type,
|
|
345
|
+
correlation_id=normalized_event.correlation_id,
|
|
346
|
+
payload=self._serialize_value(normalized_event.payload),
|
|
347
|
+
source=normalized_event.source,
|
|
348
|
+
occurred_at=normalized_event.occurred_at,
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
async with self._session_maker() as session:
|
|
352
|
+
async with session.begin():
|
|
353
|
+
return await self._inbox_writer.save(session, message)
|
|
354
|
+
|
|
355
|
+
async def run_inbox_due(self, *, limit: int = 100) -> int:
|
|
356
|
+
"""Process due inbox events through the configured inbox dispatcher."""
|
|
357
|
+
if self._inbox_dispatcher is None:
|
|
358
|
+
return 0
|
|
359
|
+
return await self._inbox_dispatcher.run_once(limit=limit)
|
|
360
|
+
|
|
263
361
|
async def await_event(
|
|
264
362
|
self,
|
|
265
363
|
*,
|
|
@@ -280,6 +378,12 @@ class SagaEngine(Generic[ModelT]):
|
|
|
280
378
|
saga.context.pop("awaiting_event_type", None)
|
|
281
379
|
else:
|
|
282
380
|
saga.context["awaiting_event_type"] = event.event_type
|
|
381
|
+
if event.event_types is None:
|
|
382
|
+
saga.context.pop("awaiting_event_types", None)
|
|
383
|
+
else:
|
|
384
|
+
saga.context["awaiting_event_types"] = list(event.event_types)
|
|
385
|
+
if event.event_type is None:
|
|
386
|
+
saga.context["awaiting_event_type"] = event.event_types[0]
|
|
283
387
|
|
|
284
388
|
if event.correlation_id is None:
|
|
285
389
|
saga.context.pop("awaiting_correlation_id", None)
|
|
@@ -522,16 +626,21 @@ class SagaEngine(Generic[ModelT]):
|
|
|
522
626
|
|
|
523
627
|
success = False
|
|
524
628
|
step_output: BaseModel | None = None
|
|
629
|
+
wait_spec: StepAwaitEvent | None = None
|
|
525
630
|
error: Exception | None = None
|
|
526
631
|
|
|
527
632
|
try:
|
|
528
633
|
if step_def.timeout is None:
|
|
529
|
-
|
|
634
|
+
step_result = await step_def.step.execute(step_input)
|
|
530
635
|
else:
|
|
531
|
-
|
|
636
|
+
step_result = await asyncio.wait_for(
|
|
532
637
|
step_def.step.execute(step_input),
|
|
533
638
|
timeout=step_def.timeout.total_seconds(),
|
|
534
639
|
)
|
|
640
|
+
if isinstance(step_result, StepAwaitEvent):
|
|
641
|
+
wait_spec = step_result
|
|
642
|
+
else:
|
|
643
|
+
step_output = step_result
|
|
535
644
|
success = True
|
|
536
645
|
except Exception as exc: # noqa: BLE001
|
|
537
646
|
error = exc
|
|
@@ -542,6 +651,7 @@ class SagaEngine(Generic[ModelT]):
|
|
|
542
651
|
token=step_token,
|
|
543
652
|
step_input=step_input,
|
|
544
653
|
step_output=step_output,
|
|
654
|
+
wait_spec=wait_spec,
|
|
545
655
|
error=error,
|
|
546
656
|
attempt_number=attempt_number,
|
|
547
657
|
)
|
|
@@ -595,6 +705,7 @@ class SagaEngine(Generic[ModelT]):
|
|
|
595
705
|
token: UUID,
|
|
596
706
|
step_input: BaseModel,
|
|
597
707
|
step_output: BaseModel | None,
|
|
708
|
+
wait_spec: StepAwaitEvent | None,
|
|
598
709
|
error: Exception | None,
|
|
599
710
|
attempt_number: int,
|
|
600
711
|
) -> bool:
|
|
@@ -609,6 +720,75 @@ class SagaEngine(Generic[ModelT]):
|
|
|
609
720
|
logger.info("Stale step result ignored for saga_id=%s", saga_id)
|
|
610
721
|
return False
|
|
611
722
|
|
|
723
|
+
if error is None and wait_spec is not None:
|
|
724
|
+
if wait_spec.outbox_events:
|
|
725
|
+
if self._outbox_writer is None:
|
|
726
|
+
raise SagaStateError(
|
|
727
|
+
"Step returned StepAwaitEvent with outbox_events, "
|
|
728
|
+
"but outbox writer is not configured in SagaEngine"
|
|
729
|
+
)
|
|
730
|
+
now = datetime.now(UTC)
|
|
731
|
+
await_messages = [
|
|
732
|
+
OutboxWriteMessage(
|
|
733
|
+
saga_id=saga.id,
|
|
734
|
+
aggregation_id=saga.aggregation_id,
|
|
735
|
+
step_id=step_def.step_id,
|
|
736
|
+
trace_id=saga.trace_id,
|
|
737
|
+
topic=event.topic,
|
|
738
|
+
key=event.key,
|
|
739
|
+
payload=self._outbox_serializer.serialize_payload(
|
|
740
|
+
event.payload
|
|
741
|
+
),
|
|
742
|
+
headers=self._outbox_serializer.serialize_headers(
|
|
743
|
+
event.headers
|
|
744
|
+
),
|
|
745
|
+
next_attempt_at=now,
|
|
746
|
+
)
|
|
747
|
+
for event in wait_spec.outbox_events
|
|
748
|
+
]
|
|
749
|
+
await self._outbox_writer.save(session, await_messages)
|
|
750
|
+
|
|
751
|
+
saga.step_history.append(
|
|
752
|
+
self._history_entry(
|
|
753
|
+
phase="execute",
|
|
754
|
+
status="WAITING",
|
|
755
|
+
step_def=step_def,
|
|
756
|
+
token=token,
|
|
757
|
+
attempt=attempt_number,
|
|
758
|
+
step_input=step_input,
|
|
759
|
+
step_output=None,
|
|
760
|
+
error=None,
|
|
761
|
+
)
|
|
762
|
+
)
|
|
763
|
+
if wait_spec.event_types is None:
|
|
764
|
+
saga.context.pop("awaiting_event_types", None)
|
|
765
|
+
saga.context.pop("awaiting_event_type", None)
|
|
766
|
+
else:
|
|
767
|
+
saga.context["awaiting_event_types"] = list(
|
|
768
|
+
wait_spec.event_types
|
|
769
|
+
)
|
|
770
|
+
saga.context["awaiting_event_type"] = wait_spec.event_types[0]
|
|
771
|
+
if wait_spec.correlation_id is None:
|
|
772
|
+
saga.context.pop("awaiting_correlation_id", None)
|
|
773
|
+
else:
|
|
774
|
+
saga.context["awaiting_correlation_id"] = (
|
|
775
|
+
wait_spec.correlation_id
|
|
776
|
+
)
|
|
777
|
+
if wait_spec.until is None:
|
|
778
|
+
saga.context.pop("awaiting_until", None)
|
|
779
|
+
saga.deadline_at = None
|
|
780
|
+
else:
|
|
781
|
+
until = datetime.now(UTC) + wait_spec.until
|
|
782
|
+
saga.context["awaiting_until"] = until.isoformat()
|
|
783
|
+
saga.deadline_at = until
|
|
784
|
+
|
|
785
|
+
saga.status = SagaStatus.SUSPENDED
|
|
786
|
+
saga.last_error = None
|
|
787
|
+
saga.context.pop("latest_event", None)
|
|
788
|
+
saga.context.pop("latest_event_meta", None)
|
|
789
|
+
saga.step_execution_token = uuid.uuid4()
|
|
790
|
+
return False
|
|
791
|
+
|
|
612
792
|
if error is None and step_output is not None:
|
|
613
793
|
if step_def.outbox_map is not None:
|
|
614
794
|
if self._outbox_writer is None:
|
|
@@ -650,6 +830,7 @@ class SagaEngine(Generic[ModelT]):
|
|
|
650
830
|
outputs = saga.context.setdefault("step_outputs", {})
|
|
651
831
|
outputs[step_def.step_id] = self._serialize_value(step_output)
|
|
652
832
|
saga.context.pop("latest_event", None)
|
|
833
|
+
saga.context.pop("latest_event_meta", None)
|
|
653
834
|
saga.current_step_index += 1
|
|
654
835
|
saga.retry_counter = 0
|
|
655
836
|
saga.deadline_at = None
|
|
@@ -842,6 +1023,84 @@ class SagaEngine(Generic[ModelT]):
|
|
|
842
1023
|
saga.deadline_at = datetime.now(UTC) + self._execution_lease
|
|
843
1024
|
return True
|
|
844
1025
|
|
|
1026
|
+
async def process(self, message: ClaimedInboxMessage) -> InboxProcessOutcome:
|
|
1027
|
+
"""Process one claimed inbox message and map outcome to dispatcher semantics."""
|
|
1028
|
+
saga_id = message.saga_id
|
|
1029
|
+
token: UUID | None = None
|
|
1030
|
+
|
|
1031
|
+
if saga_id is not None:
|
|
1032
|
+
async with self._session_maker() as session:
|
|
1033
|
+
async with session.begin():
|
|
1034
|
+
try:
|
|
1035
|
+
saga = await self._repository.get_for_update(session, saga_id)
|
|
1036
|
+
except SagaNotFoundError:
|
|
1037
|
+
return InboxProcessOutcome(
|
|
1038
|
+
status=InboxProcessStatus.IGNORED,
|
|
1039
|
+
reason="Saga not found",
|
|
1040
|
+
)
|
|
1041
|
+
token = saga.step_execution_token
|
|
1042
|
+
elif message.aggregation_id is not None:
|
|
1043
|
+
async with self._session_maker() as session:
|
|
1044
|
+
async with session.begin():
|
|
1045
|
+
saga = (
|
|
1046
|
+
await self._repository.get_active_by_aggregation_id_for_update(
|
|
1047
|
+
session,
|
|
1048
|
+
message.aggregation_id,
|
|
1049
|
+
)
|
|
1050
|
+
)
|
|
1051
|
+
if saga is None:
|
|
1052
|
+
return InboxProcessOutcome(
|
|
1053
|
+
status=InboxProcessStatus.RETRY,
|
|
1054
|
+
reason="Active saga for aggregation_id not found",
|
|
1055
|
+
)
|
|
1056
|
+
saga_id = saga.id
|
|
1057
|
+
token = saga.step_execution_token
|
|
1058
|
+
else:
|
|
1059
|
+
return InboxProcessOutcome(
|
|
1060
|
+
status=InboxProcessStatus.IGNORED,
|
|
1061
|
+
reason="Inbox message has no saga_id or aggregation_id",
|
|
1062
|
+
)
|
|
1063
|
+
|
|
1064
|
+
if saga_id is None or token is None:
|
|
1065
|
+
return InboxProcessOutcome(
|
|
1066
|
+
status=InboxProcessStatus.RETRY,
|
|
1067
|
+
reason="Saga execution token is not available yet",
|
|
1068
|
+
)
|
|
1069
|
+
|
|
1070
|
+
notify_result = await self.notify_detailed(
|
|
1071
|
+
saga_id=saga_id,
|
|
1072
|
+
token=token,
|
|
1073
|
+
event=NotifyEvent(
|
|
1074
|
+
event_id=message.event_id,
|
|
1075
|
+
event_type=message.event_type,
|
|
1076
|
+
correlation_id=message.correlation_id,
|
|
1077
|
+
payload=message.payload,
|
|
1078
|
+
source=message.source,
|
|
1079
|
+
occurred_at=message.occurred_at,
|
|
1080
|
+
),
|
|
1081
|
+
)
|
|
1082
|
+
if notify_result in {NotifyResult.ACCEPTED, NotifyResult.DUPLICATE}:
|
|
1083
|
+
return InboxProcessOutcome(status=InboxProcessStatus.APPLIED)
|
|
1084
|
+
if notify_result == NotifyResult.NOT_SUSPENDED:
|
|
1085
|
+
return InboxProcessOutcome(
|
|
1086
|
+
status=InboxProcessStatus.IGNORED,
|
|
1087
|
+
reason=notify_result.value,
|
|
1088
|
+
)
|
|
1089
|
+
if notify_result in {
|
|
1090
|
+
NotifyResult.STALE_TOKEN,
|
|
1091
|
+
NotifyResult.EVENT_TYPE_MISMATCH,
|
|
1092
|
+
NotifyResult.CORRELATION_MISMATCH,
|
|
1093
|
+
NotifyResult.EXPIRED,
|
|
1094
|
+
}:
|
|
1095
|
+
return InboxProcessOutcome(
|
|
1096
|
+
status=InboxProcessStatus.IGNORED,
|
|
1097
|
+
reason=notify_result.value,
|
|
1098
|
+
)
|
|
1099
|
+
return InboxProcessOutcome(
|
|
1100
|
+
status=InboxProcessStatus.IGNORED,
|
|
1101
|
+
reason=notify_result.value,
|
|
1102
|
+
)
|
|
1103
|
+
|
|
845
1104
|
@staticmethod
|
|
846
1105
|
def _build_step_input(
|
|
847
1106
|
step_def: StepDefinition[Any, Any],
|
|
@@ -15,6 +15,9 @@ from ..domain.models import (
|
|
|
15
15
|
SagaDefinition,
|
|
16
16
|
SagaSnapshot,
|
|
17
17
|
)
|
|
18
|
+
from ..inbox.contracts import InboxWriter
|
|
19
|
+
from ..inbox.models import InboxMessageMixin
|
|
20
|
+
from ..inbox.repository import InboxRepository
|
|
18
21
|
from ..outbox.contracts import OutboxWriter
|
|
19
22
|
from ..outbox.factory import OutboxMessageFactory
|
|
20
23
|
from ..outbox.models import OutboxMessageMixin
|
|
@@ -34,6 +37,8 @@ class SagaOrchestrator(Generic[ModelT]):
|
|
|
34
37
|
*,
|
|
35
38
|
model_class: type[ModelT],
|
|
36
39
|
session_maker: async_sessionmaker[AsyncSession],
|
|
40
|
+
inbox_model_class: type[InboxMessageMixin] | None = None,
|
|
41
|
+
inbox_writer: InboxWriter | None = None,
|
|
37
42
|
outbox_model_class: type[OutboxMessageMixin] | None = None,
|
|
38
43
|
outbox_writer: OutboxWriter | None = None,
|
|
39
44
|
outbox_serializer: OutboxSerializer | None = None,
|
|
@@ -44,6 +49,8 @@ class SagaOrchestrator(Generic[ModelT]):
|
|
|
44
49
|
self._engine = SagaEngine(
|
|
45
50
|
model_class=model_class,
|
|
46
51
|
session_maker=session_maker,
|
|
52
|
+
inbox_model_class=inbox_model_class,
|
|
53
|
+
inbox_writer=inbox_writer,
|
|
47
54
|
outbox_model_class=outbox_model_class,
|
|
48
55
|
outbox_writer=outbox_writer,
|
|
49
56
|
outbox_serializer=outbox_serializer,
|
|
@@ -66,6 +73,16 @@ class SagaOrchestrator(Generic[ModelT]):
|
|
|
66
73
|
"""Return the outbox repository used by the engine."""
|
|
67
74
|
return self._engine.outbox_repository
|
|
68
75
|
|
|
76
|
+
@property
|
|
77
|
+
def inbox_repository(self) -> InboxRepository[InboxMessageMixin] | None:
|
|
78
|
+
"""Return the inbox repository used by the engine."""
|
|
79
|
+
return self._engine.inbox_repository
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def inbox_writer(self) -> InboxWriter | None:
|
|
83
|
+
"""Return the inbox writer used by the engine."""
|
|
84
|
+
return self._engine.inbox_writer
|
|
85
|
+
|
|
69
86
|
@property
|
|
70
87
|
def outbox_writer(self) -> OutboxWriter | None:
|
|
71
88
|
"""Return the outbox writer used by the engine."""
|
|
@@ -101,6 +118,20 @@ class SagaOrchestrator(Generic[ModelT]):
|
|
|
101
118
|
"""Resume a suspended saga when the provided execution token matches."""
|
|
102
119
|
return await self._engine.notify(saga_id=saga_id, token=token, event=event)
|
|
103
120
|
|
|
121
|
+
async def ingest_event(
|
|
122
|
+
self,
|
|
123
|
+
*,
|
|
124
|
+
event: NotifyEvent | dict[str, Any] | Any,
|
|
125
|
+
saga_id: UUID | None = None,
|
|
126
|
+
aggregation_id: str | None = None,
|
|
127
|
+
) -> bool:
|
|
128
|
+
"""Persist one inbound event into inbox storage for asynchronous processing."""
|
|
129
|
+
return await self._engine.ingest_event(
|
|
130
|
+
event=event,
|
|
131
|
+
saga_id=saga_id,
|
|
132
|
+
aggregation_id=aggregation_id,
|
|
133
|
+
)
|
|
134
|
+
|
|
104
135
|
async def notify_detailed(
|
|
105
136
|
self,
|
|
106
137
|
*,
|
|
@@ -128,6 +159,10 @@ class SagaOrchestrator(Generic[ModelT]):
|
|
|
128
159
|
"""Resume due running, suspended, and compensating sagas."""
|
|
129
160
|
return await self._engine.run_due(limit=limit)
|
|
130
161
|
|
|
162
|
+
async def run_inbox_due(self, *, limit: int = 100) -> int:
|
|
163
|
+
"""Process due inbox events through the configured inbox dispatcher."""
|
|
164
|
+
return await self._engine.run_inbox_due(limit=limit)
|
|
165
|
+
|
|
131
166
|
async def get_snapshot(self, saga_id: UUID) -> SagaSnapshot:
|
|
132
167
|
"""Return the snapshot view of one saga."""
|
|
133
168
|
return await self._engine.get_snapshot(saga_id)
|
|
@@ -8,6 +8,7 @@ from .step import (
|
|
|
8
8
|
BaseStep,
|
|
9
9
|
InputContext,
|
|
10
10
|
OutboxMap,
|
|
11
|
+
StepAwaitEvent,
|
|
11
12
|
StepDefinition,
|
|
12
13
|
StepInputMap,
|
|
13
14
|
StepRef,
|
|
@@ -27,6 +28,7 @@ __all__ = [
|
|
|
27
28
|
"StepRef",
|
|
28
29
|
"InputContext",
|
|
29
30
|
"OutboxMap",
|
|
31
|
+
"StepAwaitEvent",
|
|
30
32
|
"StepInputMap",
|
|
31
33
|
"StepDefinition",
|
|
32
34
|
"BaseStep",
|