python-saga-orchestrator 0.1.0__tar.gz → 0.1.2__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.0/python_saga_orchestrator.egg-info → python_saga_orchestrator-0.1.2}/PKG-INFO +34 -6
- {python_saga_orchestrator-0.1.0 → python_saga_orchestrator-0.1.2}/README.md +33 -5
- {python_saga_orchestrator-0.1.0 → python_saga_orchestrator-0.1.2}/pyproject.toml +1 -1
- {python_saga_orchestrator-0.1.0 → python_saga_orchestrator-0.1.2/python_saga_orchestrator.egg-info}/PKG-INFO +34 -6
- {python_saga_orchestrator-0.1.0 → python_saga_orchestrator-0.1.2}/python_saga_orchestrator.egg-info/SOURCES.txt +7 -1
- {python_saga_orchestrator-0.1.0 → python_saga_orchestrator-0.1.2}/saga_orchestrator/__init__.py +22 -3
- {python_saga_orchestrator-0.1.0 → python_saga_orchestrator-0.1.2}/saga_orchestrator/core/builder.py +5 -0
- {python_saga_orchestrator-0.1.0 → python_saga_orchestrator-0.1.2}/saga_orchestrator/core/engine.py +244 -8
- {python_saga_orchestrator-0.1.0 → python_saga_orchestrator-0.1.2}/saga_orchestrator/core/orchestrator.py +44 -2
- {python_saga_orchestrator-0.1.0 → python_saga_orchestrator-0.1.2}/saga_orchestrator/domain/models/__init__.py +13 -1
- python_saga_orchestrator-0.1.2/saga_orchestrator/domain/models/notify.py +32 -0
- {python_saga_orchestrator-0.1.0 → python_saga_orchestrator-0.1.2}/saga_orchestrator/domain/models/step.py +3 -0
- python_saga_orchestrator-0.1.2/saga_orchestrator/outbox/__init__.py +15 -0
- python_saga_orchestrator-0.1.2/saga_orchestrator/outbox/dispatcher.py +95 -0
- python_saga_orchestrator-0.1.2/saga_orchestrator/outbox/event.py +12 -0
- python_saga_orchestrator-0.1.2/saga_orchestrator/outbox/models.py +71 -0
- python_saga_orchestrator-0.1.2/saga_orchestrator/outbox/repository.py +73 -0
- {python_saga_orchestrator-0.1.0 → python_saga_orchestrator-0.1.2}/LICENSE +0 -0
- {python_saga_orchestrator-0.1.0 → python_saga_orchestrator-0.1.2}/python_saga_orchestrator.egg-info/dependency_links.txt +0 -0
- {python_saga_orchestrator-0.1.0 → python_saga_orchestrator-0.1.2}/python_saga_orchestrator.egg-info/requires.txt +0 -0
- {python_saga_orchestrator-0.1.0 → python_saga_orchestrator-0.1.2}/python_saga_orchestrator.egg-info/top_level.txt +0 -0
- {python_saga_orchestrator-0.1.0 → python_saga_orchestrator-0.1.2}/saga_orchestrator/admin/__init__.py +0 -0
- {python_saga_orchestrator-0.1.0 → python_saga_orchestrator-0.1.2}/saga_orchestrator/admin/api.py +0 -0
- {python_saga_orchestrator-0.1.0 → python_saga_orchestrator-0.1.2}/saga_orchestrator/core/__init__.py +0 -0
- {python_saga_orchestrator-0.1.0 → python_saga_orchestrator-0.1.2}/saga_orchestrator/core/repository.py +0 -0
- {python_saga_orchestrator-0.1.0 → python_saga_orchestrator-0.1.2}/saga_orchestrator/domain/__init__.py +0 -0
- {python_saga_orchestrator-0.1.0 → python_saga_orchestrator-0.1.2}/saga_orchestrator/domain/exceptions/__init__.py +0 -0
- {python_saga_orchestrator-0.1.0 → python_saga_orchestrator-0.1.2}/saga_orchestrator/domain/exceptions/saga.py +0 -0
- {python_saga_orchestrator-0.1.0 → python_saga_orchestrator-0.1.2}/saga_orchestrator/domain/mixins/__init__.py +0 -0
- {python_saga_orchestrator-0.1.0 → python_saga_orchestrator-0.1.2}/saga_orchestrator/domain/mixins/saga_state.py +0 -0
- {python_saga_orchestrator-0.1.0 → python_saga_orchestrator-0.1.2}/saga_orchestrator/domain/models/builder.py +0 -0
- {python_saga_orchestrator-0.1.0 → python_saga_orchestrator-0.1.2}/saga_orchestrator/domain/models/enums/__init__.py +0 -0
- {python_saga_orchestrator-0.1.0 → python_saga_orchestrator-0.1.2}/saga_orchestrator/domain/models/enums/saga_status.py +0 -0
- {python_saga_orchestrator-0.1.0 → python_saga_orchestrator-0.1.2}/saga_orchestrator/domain/models/retry.py +0 -0
- {python_saga_orchestrator-0.1.0 → python_saga_orchestrator-0.1.2}/saga_orchestrator/domain/models/saga_snapshot.py +0 -0
- {python_saga_orchestrator-0.1.0 → python_saga_orchestrator-0.1.2}/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.2
|
|
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
|
|
@@ -60,16 +60,28 @@ Requirements:
|
|
|
60
60
|
- Python 3.12+
|
|
61
61
|
- PostgreSQL for production-grade execution semantics
|
|
62
62
|
|
|
63
|
-
Install from
|
|
63
|
+
Install from PyPI:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
pip install python-saga-orchestrator
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Or with `uv`:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
uv pip install python-saga-orchestrator
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Install from the repository source:
|
|
64
76
|
|
|
65
77
|
```bash
|
|
66
78
|
pip install .
|
|
67
79
|
```
|
|
68
80
|
|
|
69
|
-
|
|
81
|
+
For local development:
|
|
70
82
|
|
|
71
83
|
```bash
|
|
72
|
-
pip install .[dev]
|
|
84
|
+
pip install '.[dev]'
|
|
73
85
|
```
|
|
74
86
|
|
|
75
87
|
## Core concepts
|
|
@@ -251,6 +263,18 @@ accepted = await orchestrator.notify(
|
|
|
251
263
|
)
|
|
252
264
|
```
|
|
253
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
|
+
|
|
254
278
|
The event payload is stored in saga context and can be used by root-step `input_map` functions through `InputContext`.
|
|
255
279
|
|
|
256
280
|
## Administrative operations
|
|
@@ -306,9 +330,13 @@ SQLite may be sufficient for local experiments, but PostgreSQL should be used fo
|
|
|
306
330
|
|
|
307
331
|
A runnable end-to-end example is available in:
|
|
308
332
|
|
|
309
|
-
- [`
|
|
333
|
+
- [`examples/llm_deploy.py`](./examples/llm_deploy.py)
|
|
334
|
+
- [`examples/retry_recovery.py`](./examples/retry_recovery.py)
|
|
335
|
+
- [`examples/compensation_flow.py`](./examples/compensation_flow.py)
|
|
336
|
+
- [`examples/admin_skip.py`](./examples/admin_skip.py)
|
|
310
337
|
|
|
311
|
-
|
|
338
|
+
These examples demonstrate:
|
|
339
|
+
- basic model deployment
|
|
312
340
|
- retry and recovery through `run_due()`
|
|
313
341
|
- compensation after failure
|
|
314
342
|
- admin-driven step skipping
|
|
@@ -26,16 +26,28 @@ Requirements:
|
|
|
26
26
|
- Python 3.12+
|
|
27
27
|
- PostgreSQL for production-grade execution semantics
|
|
28
28
|
|
|
29
|
-
Install from
|
|
29
|
+
Install from PyPI:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install python-saga-orchestrator
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Or with `uv`:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
uv pip install python-saga-orchestrator
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Install from the repository source:
|
|
30
42
|
|
|
31
43
|
```bash
|
|
32
44
|
pip install .
|
|
33
45
|
```
|
|
34
46
|
|
|
35
|
-
|
|
47
|
+
For local development:
|
|
36
48
|
|
|
37
49
|
```bash
|
|
38
|
-
pip install .[dev]
|
|
50
|
+
pip install '.[dev]'
|
|
39
51
|
```
|
|
40
52
|
|
|
41
53
|
## Core concepts
|
|
@@ -217,6 +229,18 @@ accepted = await orchestrator.notify(
|
|
|
217
229
|
)
|
|
218
230
|
```
|
|
219
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
|
+
|
|
220
244
|
The event payload is stored in saga context and can be used by root-step `input_map` functions through `InputContext`.
|
|
221
245
|
|
|
222
246
|
## Administrative operations
|
|
@@ -272,9 +296,13 @@ SQLite may be sufficient for local experiments, but PostgreSQL should be used fo
|
|
|
272
296
|
|
|
273
297
|
A runnable end-to-end example is available in:
|
|
274
298
|
|
|
275
|
-
- [`
|
|
299
|
+
- [`examples/llm_deploy.py`](./examples/llm_deploy.py)
|
|
300
|
+
- [`examples/retry_recovery.py`](./examples/retry_recovery.py)
|
|
301
|
+
- [`examples/compensation_flow.py`](./examples/compensation_flow.py)
|
|
302
|
+
- [`examples/admin_skip.py`](./examples/admin_skip.py)
|
|
276
303
|
|
|
277
|
-
|
|
304
|
+
These examples demonstrate:
|
|
305
|
+
- basic model deployment
|
|
278
306
|
- retry and recovery through `run_due()`
|
|
279
307
|
- compensation after failure
|
|
280
308
|
- admin-driven step skipping
|
|
@@ -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.2"
|
|
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.2
|
|
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
|
|
@@ -60,16 +60,28 @@ Requirements:
|
|
|
60
60
|
- Python 3.12+
|
|
61
61
|
- PostgreSQL for production-grade execution semantics
|
|
62
62
|
|
|
63
|
-
Install from
|
|
63
|
+
Install from PyPI:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
pip install python-saga-orchestrator
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Or with `uv`:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
uv pip install python-saga-orchestrator
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Install from the repository source:
|
|
64
76
|
|
|
65
77
|
```bash
|
|
66
78
|
pip install .
|
|
67
79
|
```
|
|
68
80
|
|
|
69
|
-
|
|
81
|
+
For local development:
|
|
70
82
|
|
|
71
83
|
```bash
|
|
72
|
-
pip install .[dev]
|
|
84
|
+
pip install '.[dev]'
|
|
73
85
|
```
|
|
74
86
|
|
|
75
87
|
## Core concepts
|
|
@@ -251,6 +263,18 @@ accepted = await orchestrator.notify(
|
|
|
251
263
|
)
|
|
252
264
|
```
|
|
253
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
|
+
|
|
254
278
|
The event payload is stored in saga context and can be used by root-step `input_map` functions through `InputContext`.
|
|
255
279
|
|
|
256
280
|
## Administrative operations
|
|
@@ -306,9 +330,13 @@ SQLite may be sufficient for local experiments, but PostgreSQL should be used fo
|
|
|
306
330
|
|
|
307
331
|
A runnable end-to-end example is available in:
|
|
308
332
|
|
|
309
|
-
- [`
|
|
333
|
+
- [`examples/llm_deploy.py`](./examples/llm_deploy.py)
|
|
334
|
+
- [`examples/retry_recovery.py`](./examples/retry_recovery.py)
|
|
335
|
+
- [`examples/compensation_flow.py`](./examples/compensation_flow.py)
|
|
336
|
+
- [`examples/admin_skip.py`](./examples/admin_skip.py)
|
|
310
337
|
|
|
311
|
-
|
|
338
|
+
These examples demonstrate:
|
|
339
|
+
- basic model deployment
|
|
312
340
|
- retry and recovery through `run_due()`
|
|
313
341
|
- compensation after failure
|
|
314
342
|
- admin-driven step skipping
|
|
@@ -21,8 +21,14 @@ 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/dispatcher.py
|
|
32
|
+
saga_orchestrator/outbox/event.py
|
|
33
|
+
saga_orchestrator/outbox/models.py
|
|
34
|
+
saga_orchestrator/outbox/repository.py
|
{python_saga_orchestrator-0.1.0 → python_saga_orchestrator-0.1.2}/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,16 +29,32 @@ from .domain.models import (
|
|
|
25
29
|
StepRef,
|
|
26
30
|
)
|
|
27
31
|
from .domain.models.enums import SagaStatus
|
|
28
|
-
|
|
29
|
-
|
|
32
|
+
from .outbox import (
|
|
33
|
+
OutboxDispatcher,
|
|
34
|
+
OutboxEvent,
|
|
35
|
+
OutboxMessageMixin,
|
|
36
|
+
OutboxPublisher,
|
|
37
|
+
OutboxRepository,
|
|
38
|
+
OutboxStatus,
|
|
39
|
+
)
|
|
30
40
|
|
|
31
41
|
__all__ = [
|
|
32
42
|
"ActiveSagaAlreadyExistsError",
|
|
43
|
+
"AwaitingEvent",
|
|
33
44
|
"BaseStep",
|
|
34
45
|
"ExponentialRetry",
|
|
35
46
|
"FixedRetry",
|
|
36
47
|
"InputContext",
|
|
48
|
+
"NotifyEvent",
|
|
49
|
+
"NotifyResult",
|
|
37
50
|
"NoRetry",
|
|
51
|
+
"OutboxDispatcher",
|
|
52
|
+
"OutboxEvent",
|
|
53
|
+
"OutboxMap",
|
|
54
|
+
"OutboxMessageMixin",
|
|
55
|
+
"OutboxPublisher",
|
|
56
|
+
"OutboxRepository",
|
|
57
|
+
"OutboxStatus",
|
|
38
58
|
"RetryPolicy",
|
|
39
59
|
"SagaAdmin",
|
|
40
60
|
"SagaAdminSnapshot",
|
|
@@ -53,5 +73,4 @@ __all__ = [
|
|
|
53
73
|
"StepInputMap",
|
|
54
74
|
"StepRef",
|
|
55
75
|
"TypeValidationError",
|
|
56
|
-
"__version__",
|
|
57
76
|
]
|
{python_saga_orchestrator-0.1.0 → python_saga_orchestrator-0.1.2}/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.0 → python_saga_orchestrator-0.1.2}/saga_orchestrator/core/engine.py
RENAMED
|
@@ -13,13 +13,18 @@ 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.models import OutboxMessageMixin, OutboxStatus
|
|
27
|
+
from ..outbox.repository import OutboxRepository
|
|
23
28
|
from .repository import SagaRepository
|
|
24
29
|
|
|
25
30
|
ModelT = TypeVar("ModelT", bound=SagaStateMixin)
|
|
@@ -33,6 +38,7 @@ class SagaEngine(Generic[ModelT]):
|
|
|
33
38
|
*,
|
|
34
39
|
model_class: type[ModelT],
|
|
35
40
|
session_maker: async_sessionmaker[AsyncSession],
|
|
41
|
+
outbox_model_class: type[OutboxMessageMixin] | None = None,
|
|
36
42
|
execution_lease: timedelta = timedelta(minutes=5),
|
|
37
43
|
) -> None:
|
|
38
44
|
"""Initialize the engine dependencies and execution lease."""
|
|
@@ -40,6 +46,10 @@ class SagaEngine(Generic[ModelT]):
|
|
|
40
46
|
self._session_maker = session_maker
|
|
41
47
|
self._execution_lease = execution_lease
|
|
42
48
|
self._repository = SagaRepository(model_class)
|
|
49
|
+
self._outbox_model_class = outbox_model_class
|
|
50
|
+
self._outbox_repository: OutboxRepository[OutboxMessageMixin] | None = None
|
|
51
|
+
if outbox_model_class is not None:
|
|
52
|
+
self._outbox_repository = OutboxRepository(outbox_model_class)
|
|
43
53
|
self._registry: dict[str, SagaDefinition] = {}
|
|
44
54
|
|
|
45
55
|
@property
|
|
@@ -47,6 +57,11 @@ class SagaEngine(Generic[ModelT]):
|
|
|
47
57
|
"""Return the repository used by the engine."""
|
|
48
58
|
return self._repository
|
|
49
59
|
|
|
60
|
+
@property
|
|
61
|
+
def outbox_repository(self) -> OutboxRepository[OutboxMessageMixin] | None:
|
|
62
|
+
"""Return the outbox repository used by the engine."""
|
|
63
|
+
return self._outbox_repository
|
|
64
|
+
|
|
50
65
|
def register(self, name: str, saga_definition: SagaDefinition) -> None:
|
|
51
66
|
"""Register a saga definition under a runtime name."""
|
|
52
67
|
if name in self._registry:
|
|
@@ -102,21 +117,110 @@ class SagaEngine(Generic[ModelT]):
|
|
|
102
117
|
return saga_id
|
|
103
118
|
|
|
104
119
|
async def notify(
|
|
105
|
-
self,
|
|
120
|
+
self,
|
|
121
|
+
*,
|
|
122
|
+
saga_id: UUID,
|
|
123
|
+
token: UUID,
|
|
124
|
+
event: NotifyEvent | dict[str, Any] | Any | None = None,
|
|
106
125
|
) -> bool:
|
|
107
126
|
"""Resume a suspended saga when the provided execution token matches."""
|
|
127
|
+
result = await self.notify_detailed(
|
|
128
|
+
saga_id=saga_id,
|
|
129
|
+
token=token,
|
|
130
|
+
event=event,
|
|
131
|
+
)
|
|
132
|
+
return result == NotifyResult.ACCEPTED
|
|
133
|
+
|
|
134
|
+
async def notify_detailed(
|
|
135
|
+
self,
|
|
136
|
+
*,
|
|
137
|
+
saga_id: UUID,
|
|
138
|
+
token: UUID,
|
|
139
|
+
event: NotifyEvent | dict[str, Any] | Any | None = None,
|
|
140
|
+
) -> NotifyResult:
|
|
141
|
+
"""Resume a suspended saga and return a detailed notify outcome."""
|
|
142
|
+
normalized_event, idempotency_key = self._normalize_notify_event(event)
|
|
143
|
+
|
|
108
144
|
async with self._session_maker() as session:
|
|
109
145
|
async with session.begin():
|
|
110
146
|
saga = await self._repository.get_for_update(session, saga_id)
|
|
111
147
|
if saga.status != SagaStatus.SUSPENDED:
|
|
112
|
-
|
|
148
|
+
self._append_notify_log(
|
|
149
|
+
saga=saga,
|
|
150
|
+
event=normalized_event,
|
|
151
|
+
result=NotifyResult.NOT_SUSPENDED,
|
|
152
|
+
)
|
|
153
|
+
return NotifyResult.NOT_SUSPENDED
|
|
113
154
|
if saga.step_execution_token != token:
|
|
114
155
|
logger.info("Ignoring stale notify for saga_id=%s", saga_id)
|
|
115
|
-
|
|
116
|
-
|
|
156
|
+
self._append_notify_log(
|
|
157
|
+
saga=saga,
|
|
158
|
+
event=normalized_event,
|
|
159
|
+
result=NotifyResult.STALE_TOKEN,
|
|
160
|
+
)
|
|
161
|
+
return NotifyResult.STALE_TOKEN
|
|
162
|
+
|
|
163
|
+
processed_ids = saga.context.setdefault("processed_event_ids", [])
|
|
164
|
+
if idempotency_key is not None and idempotency_key in processed_ids:
|
|
165
|
+
self._append_notify_log(
|
|
166
|
+
saga=saga,
|
|
167
|
+
event=normalized_event,
|
|
168
|
+
result=NotifyResult.DUPLICATE,
|
|
169
|
+
)
|
|
170
|
+
return NotifyResult.DUPLICATE
|
|
171
|
+
|
|
172
|
+
expected_type = saga.context.get("awaiting_event_type")
|
|
173
|
+
if (
|
|
174
|
+
expected_type is not None
|
|
175
|
+
and normalized_event is not None
|
|
176
|
+
and normalized_event.event_type != expected_type
|
|
177
|
+
):
|
|
178
|
+
self._append_notify_log(
|
|
179
|
+
saga=saga,
|
|
180
|
+
event=normalized_event,
|
|
181
|
+
result=NotifyResult.EVENT_TYPE_MISMATCH,
|
|
182
|
+
)
|
|
183
|
+
return NotifyResult.EVENT_TYPE_MISMATCH
|
|
184
|
+
|
|
185
|
+
expected_correlation = saga.context.get("awaiting_correlation_id")
|
|
186
|
+
if (
|
|
187
|
+
expected_correlation is not None
|
|
188
|
+
and normalized_event is not None
|
|
189
|
+
and normalized_event.correlation_id != expected_correlation
|
|
190
|
+
):
|
|
191
|
+
self._append_notify_log(
|
|
192
|
+
saga=saga,
|
|
193
|
+
event=normalized_event,
|
|
194
|
+
result=NotifyResult.CORRELATION_MISMATCH,
|
|
195
|
+
)
|
|
196
|
+
return NotifyResult.CORRELATION_MISMATCH
|
|
197
|
+
|
|
198
|
+
awaiting_until = self._parse_iso_datetime(
|
|
199
|
+
saga.context.get("awaiting_until")
|
|
200
|
+
)
|
|
201
|
+
if awaiting_until is not None and datetime.now(UTC) > awaiting_until:
|
|
202
|
+
self._append_notify_log(
|
|
203
|
+
saga=saga,
|
|
204
|
+
event=normalized_event,
|
|
205
|
+
result=NotifyResult.EXPIRED,
|
|
206
|
+
)
|
|
207
|
+
return NotifyResult.EXPIRED
|
|
208
|
+
|
|
209
|
+
if normalized_event is not None:
|
|
117
210
|
events = saga.context.setdefault("events", [])
|
|
118
|
-
events.append(self._serialize_value(
|
|
119
|
-
saga.context["latest_event"] = self._serialize_value(
|
|
211
|
+
events.append(self._serialize_value(normalized_event.payload))
|
|
212
|
+
saga.context["latest_event"] = self._serialize_value(
|
|
213
|
+
normalized_event.payload
|
|
214
|
+
)
|
|
215
|
+
saga.context["latest_event_meta"] = self._serialize_value(
|
|
216
|
+
normalized_event.model_dump(mode="json")
|
|
217
|
+
)
|
|
218
|
+
if idempotency_key is not None:
|
|
219
|
+
processed_ids.append(idempotency_key)
|
|
220
|
+
|
|
221
|
+
saga.context.pop("awaiting_event_type", None)
|
|
222
|
+
saga.context.pop("awaiting_correlation_id", None)
|
|
223
|
+
saga.context.pop("awaiting_until", None)
|
|
120
224
|
saga.status = SagaStatus.RUNNING
|
|
121
225
|
step_def = self._registry[saga.context["saga_name"]].steps[
|
|
122
226
|
saga.current_step_index
|
|
@@ -126,9 +230,50 @@ class SagaEngine(Generic[ModelT]):
|
|
|
126
230
|
now=datetime.now(UTC),
|
|
127
231
|
)
|
|
128
232
|
saga.step_execution_token = uuid.uuid4()
|
|
233
|
+
self._append_notify_log(
|
|
234
|
+
saga=saga,
|
|
235
|
+
event=normalized_event,
|
|
236
|
+
result=NotifyResult.ACCEPTED,
|
|
237
|
+
)
|
|
129
238
|
|
|
130
239
|
await self._drive(saga_id)
|
|
131
|
-
return
|
|
240
|
+
return NotifyResult.ACCEPTED
|
|
241
|
+
|
|
242
|
+
async def await_event(
|
|
243
|
+
self,
|
|
244
|
+
*,
|
|
245
|
+
saga_id: UUID,
|
|
246
|
+
event: AwaitingEvent,
|
|
247
|
+
) -> UUID:
|
|
248
|
+
"""Configure a suspended saga to wait for a specific external event."""
|
|
249
|
+
async with self._session_maker() as session:
|
|
250
|
+
async with session.begin():
|
|
251
|
+
saga = await self._repository.get_for_update(session, saga_id)
|
|
252
|
+
if saga.status != SagaStatus.SUSPENDED:
|
|
253
|
+
raise SagaStateError(
|
|
254
|
+
"Cannot configure external wait unless saga is suspended "
|
|
255
|
+
f"(status={saga.status.value})"
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
if event.event_type is None:
|
|
259
|
+
saga.context.pop("awaiting_event_type", None)
|
|
260
|
+
else:
|
|
261
|
+
saga.context["awaiting_event_type"] = event.event_type
|
|
262
|
+
|
|
263
|
+
if event.correlation_id is None:
|
|
264
|
+
saga.context.pop("awaiting_correlation_id", None)
|
|
265
|
+
else:
|
|
266
|
+
saga.context["awaiting_correlation_id"] = event.correlation_id
|
|
267
|
+
|
|
268
|
+
if event.until is None:
|
|
269
|
+
saga.context.pop("awaiting_until", None)
|
|
270
|
+
else:
|
|
271
|
+
saga.context["awaiting_until"] = event.until.isoformat()
|
|
272
|
+
|
|
273
|
+
# External event waits should not be auto-resumed by run_due.
|
|
274
|
+
saga.deadline_at = None
|
|
275
|
+
saga.step_execution_token = uuid.uuid4()
|
|
276
|
+
return saga.step_execution_token
|
|
132
277
|
|
|
133
278
|
async def run_due(self, *, limit: int = 100) -> int:
|
|
134
279
|
"""Resume due running, suspended, and compensating sagas."""
|
|
@@ -444,6 +589,39 @@ class SagaEngine(Generic[ModelT]):
|
|
|
444
589
|
return False
|
|
445
590
|
|
|
446
591
|
if error is None and step_output is not None:
|
|
592
|
+
if step_def.outbox_map is not None:
|
|
593
|
+
if (
|
|
594
|
+
self._outbox_model_class is None
|
|
595
|
+
or self._outbox_repository is None
|
|
596
|
+
):
|
|
597
|
+
raise SagaStateError(
|
|
598
|
+
"outbox_map is configured for step "
|
|
599
|
+
f"'{step_def.step_id}', but outbox_model_class is not configured in SagaEngine"
|
|
600
|
+
)
|
|
601
|
+
outbox_events = (
|
|
602
|
+
step_def.outbox_map(step_input, step_output) or []
|
|
603
|
+
)
|
|
604
|
+
now = datetime.now(UTC)
|
|
605
|
+
outbox_messages = [
|
|
606
|
+
self._outbox_model_class(
|
|
607
|
+
saga_id=saga.id,
|
|
608
|
+
aggregation_id=saga.aggregation_id,
|
|
609
|
+
step_id=step_def.step_id,
|
|
610
|
+
trace_id=saga.trace_id,
|
|
611
|
+
topic=event.topic,
|
|
612
|
+
message_key=event.key,
|
|
613
|
+
payload=self._serialize_value(event.payload),
|
|
614
|
+
headers=self._serialize_value(event.headers),
|
|
615
|
+
status=OutboxStatus.PENDING,
|
|
616
|
+
next_attempt_at=now,
|
|
617
|
+
)
|
|
618
|
+
for event in outbox_events
|
|
619
|
+
]
|
|
620
|
+
if outbox_messages:
|
|
621
|
+
await self._outbox_repository.create_many(
|
|
622
|
+
session,
|
|
623
|
+
outbox_messages,
|
|
624
|
+
)
|
|
447
625
|
saga.step_history.append(
|
|
448
626
|
self._history_entry(
|
|
449
627
|
phase="execute",
|
|
@@ -471,7 +649,11 @@ class SagaEngine(Generic[ModelT]):
|
|
|
471
649
|
saga.status = SagaStatus.COMPLETED
|
|
472
650
|
return saga.status == SagaStatus.RUNNING
|
|
473
651
|
|
|
474
|
-
|
|
652
|
+
if error is None:
|
|
653
|
+
raise SagaStateError(
|
|
654
|
+
"Step finalization expected either a successful output "
|
|
655
|
+
"or an execution error"
|
|
656
|
+
)
|
|
475
657
|
saga.step_history.append(
|
|
476
658
|
self._history_entry(
|
|
477
659
|
phase="execute",
|
|
@@ -693,6 +875,60 @@ class SagaEngine(Generic[ModelT]):
|
|
|
693
875
|
return [self._serialize_value(item) for item in value]
|
|
694
876
|
return value
|
|
695
877
|
|
|
878
|
+
def _normalize_notify_event(
|
|
879
|
+
self,
|
|
880
|
+
event: NotifyEvent | dict[str, Any] | Any | None,
|
|
881
|
+
) -> tuple[NotifyEvent | None, str | None]:
|
|
882
|
+
if event is None:
|
|
883
|
+
return None, None
|
|
884
|
+
if isinstance(event, NotifyEvent):
|
|
885
|
+
return event, event.event_id
|
|
886
|
+
if isinstance(event, dict):
|
|
887
|
+
envelope_keys = {
|
|
888
|
+
"event_id",
|
|
889
|
+
"event_type",
|
|
890
|
+
"correlation_id",
|
|
891
|
+
"payload",
|
|
892
|
+
"source",
|
|
893
|
+
"occurred_at",
|
|
894
|
+
}
|
|
895
|
+
if any(key in event for key in envelope_keys):
|
|
896
|
+
notify_event = NotifyEvent.model_validate(event)
|
|
897
|
+
return notify_event, notify_event.event_id
|
|
898
|
+
return NotifyEvent(payload=self._serialize_value(event)), None
|
|
899
|
+
return NotifyEvent(payload=self._serialize_value(event)), None
|
|
900
|
+
|
|
901
|
+
@staticmethod
|
|
902
|
+
def _parse_iso_datetime(value: Any) -> datetime | None:
|
|
903
|
+
if not isinstance(value, str):
|
|
904
|
+
return None
|
|
905
|
+
normalized = value.replace("Z", "+00:00")
|
|
906
|
+
try:
|
|
907
|
+
parsed = datetime.fromisoformat(normalized)
|
|
908
|
+
except ValueError:
|
|
909
|
+
return None
|
|
910
|
+
if parsed.tzinfo is None:
|
|
911
|
+
return parsed.replace(tzinfo=UTC)
|
|
912
|
+
return parsed
|
|
913
|
+
|
|
914
|
+
def _append_notify_log(
|
|
915
|
+
self,
|
|
916
|
+
*,
|
|
917
|
+
saga: ModelT,
|
|
918
|
+
event: NotifyEvent | None,
|
|
919
|
+
result: NotifyResult,
|
|
920
|
+
) -> None:
|
|
921
|
+
inbox = saga.context.setdefault("notify_inbox", [])
|
|
922
|
+
inbox.append(
|
|
923
|
+
{
|
|
924
|
+
"timestamp": datetime.now(UTC).isoformat(),
|
|
925
|
+
"result": result.value,
|
|
926
|
+
"event_id": event.event_id if event is not None else None,
|
|
927
|
+
"event_type": event.event_type if event is not None else None,
|
|
928
|
+
"correlation_id": (event.correlation_id if event is not None else None),
|
|
929
|
+
}
|
|
930
|
+
)
|
|
931
|
+
|
|
696
932
|
def _history_entry(
|
|
697
933
|
self,
|
|
698
934
|
*,
|
|
@@ -8,7 +8,15 @@ 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.models import OutboxMessageMixin
|
|
19
|
+
from ..outbox.repository import OutboxRepository
|
|
12
20
|
from .engine import SagaEngine
|
|
13
21
|
from .repository import SagaRepository
|
|
14
22
|
|
|
@@ -23,12 +31,14 @@ class SagaOrchestrator(Generic[ModelT]):
|
|
|
23
31
|
*,
|
|
24
32
|
model_class: type[ModelT],
|
|
25
33
|
session_maker: async_sessionmaker[AsyncSession],
|
|
34
|
+
outbox_model_class: type[OutboxMessageMixin] | None = None,
|
|
26
35
|
execution_lease: timedelta = timedelta(minutes=5),
|
|
27
36
|
) -> None:
|
|
28
37
|
"""Initialize the orchestrator facade."""
|
|
29
38
|
self._engine = SagaEngine(
|
|
30
39
|
model_class=model_class,
|
|
31
40
|
session_maker=session_maker,
|
|
41
|
+
outbox_model_class=outbox_model_class,
|
|
32
42
|
execution_lease=execution_lease,
|
|
33
43
|
)
|
|
34
44
|
|
|
@@ -42,6 +52,11 @@ class SagaOrchestrator(Generic[ModelT]):
|
|
|
42
52
|
"""Return the repository used by the engine."""
|
|
43
53
|
return self._engine.repository
|
|
44
54
|
|
|
55
|
+
@property
|
|
56
|
+
def outbox_repository(self) -> OutboxRepository[OutboxMessageMixin] | None:
|
|
57
|
+
"""Return the outbox repository used by the engine."""
|
|
58
|
+
return self._engine.outbox_repository
|
|
59
|
+
|
|
45
60
|
def register(self, name: str, saga_definition: SagaDefinition) -> None:
|
|
46
61
|
"""Register a saga definition under a runtime name."""
|
|
47
62
|
self._engine.register(name, saga_definition)
|
|
@@ -63,11 +78,38 @@ class SagaOrchestrator(Generic[ModelT]):
|
|
|
63
78
|
)
|
|
64
79
|
|
|
65
80
|
async def notify(
|
|
66
|
-
self,
|
|
81
|
+
self,
|
|
82
|
+
*,
|
|
83
|
+
saga_id: UUID,
|
|
84
|
+
token: UUID,
|
|
85
|
+
event: NotifyEvent | dict[str, Any] | Any | None = None,
|
|
67
86
|
) -> bool:
|
|
68
87
|
"""Resume a suspended saga when the provided execution token matches."""
|
|
69
88
|
return await self._engine.notify(saga_id=saga_id, token=token, event=event)
|
|
70
89
|
|
|
90
|
+
async def notify_detailed(
|
|
91
|
+
self,
|
|
92
|
+
*,
|
|
93
|
+
saga_id: UUID,
|
|
94
|
+
token: UUID,
|
|
95
|
+
event: NotifyEvent | dict[str, Any] | Any | None = None,
|
|
96
|
+
) -> NotifyResult:
|
|
97
|
+
"""Resume a suspended saga and return a detailed notify outcome."""
|
|
98
|
+
return await self._engine.notify_detailed(
|
|
99
|
+
saga_id=saga_id,
|
|
100
|
+
token=token,
|
|
101
|
+
event=event,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
async def await_event(
|
|
105
|
+
self,
|
|
106
|
+
*,
|
|
107
|
+
saga_id: UUID,
|
|
108
|
+
event: AwaitingEvent,
|
|
109
|
+
) -> UUID:
|
|
110
|
+
"""Configure a suspended saga to wait for an external event."""
|
|
111
|
+
return await self._engine.await_event(saga_id=saga_id, event=event)
|
|
112
|
+
|
|
71
113
|
async def run_due(self, *, limit: int = 100) -> int:
|
|
72
114
|
"""Resume due running, suspended, and compensating sagas."""
|
|
73
115
|
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",
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from enum import Enum
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class NotifyResult(str, Enum):
|
|
11
|
+
ACCEPTED = "ACCEPTED"
|
|
12
|
+
NOT_SUSPENDED = "NOT_SUSPENDED"
|
|
13
|
+
STALE_TOKEN = "STALE_TOKEN"
|
|
14
|
+
DUPLICATE = "DUPLICATE"
|
|
15
|
+
EVENT_TYPE_MISMATCH = "EVENT_TYPE_MISMATCH"
|
|
16
|
+
CORRELATION_MISMATCH = "CORRELATION_MISMATCH"
|
|
17
|
+
EXPIRED = "EXPIRED"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class NotifyEvent(BaseModel):
|
|
21
|
+
event_id: str | None = None
|
|
22
|
+
event_type: str | None = None
|
|
23
|
+
correlation_id: str | None = None
|
|
24
|
+
payload: Any = None
|
|
25
|
+
source: str | None = None
|
|
26
|
+
occurred_at: datetime | None = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class AwaitingEvent(BaseModel):
|
|
30
|
+
event_type: str | None = None
|
|
31
|
+
correlation_id: str | None = None
|
|
32
|
+
until: datetime | None = None
|
|
@@ -8,6 +8,7 @@ from typing import Any, Generic, TypeAlias, TypeVar, get_type_hints
|
|
|
8
8
|
|
|
9
9
|
from pydantic import BaseModel
|
|
10
10
|
|
|
11
|
+
from ...outbox.event import OutboxEvent
|
|
11
12
|
from ..exceptions import TypeValidationError
|
|
12
13
|
from .retry import RetryPolicy
|
|
13
14
|
|
|
@@ -33,6 +34,7 @@ class InputContext:
|
|
|
33
34
|
|
|
34
35
|
RootInputMap: TypeAlias = Callable[[InputContext], InputModelT | dict[str, Any]]
|
|
35
36
|
StepInputMap: TypeAlias = RootInputMap | Callable[[Any], InputModelT | dict[str, Any]]
|
|
37
|
+
OutboxMap: TypeAlias = Callable[[InputModelT, OutputModelT], list[OutboxEvent] | None]
|
|
36
38
|
|
|
37
39
|
|
|
38
40
|
@dataclass
|
|
@@ -43,6 +45,7 @@ class StepDefinition(Generic[InputModelT, OutputModelT]):
|
|
|
43
45
|
timeout: timedelta | None
|
|
44
46
|
retry_policy: RetryPolicy
|
|
45
47
|
depends_on: StepRef[Any] | None = None
|
|
48
|
+
outbox_map: OutboxMap[InputModelT, OutputModelT] | None = None
|
|
46
49
|
|
|
47
50
|
@property
|
|
48
51
|
def input_model(self) -> type[InputModelT]:
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Outbox module."""
|
|
2
|
+
|
|
3
|
+
from .dispatcher import OutboxDispatcher, OutboxPublisher
|
|
4
|
+
from .event import OutboxEvent
|
|
5
|
+
from .models import OutboxMessageMixin, OutboxStatus
|
|
6
|
+
from .repository import OutboxRepository
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"OutboxDispatcher",
|
|
10
|
+
"OutboxEvent",
|
|
11
|
+
"OutboxMessageMixin",
|
|
12
|
+
"OutboxPublisher",
|
|
13
|
+
"OutboxRepository",
|
|
14
|
+
"OutboxStatus",
|
|
15
|
+
]
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import UTC, datetime, timedelta
|
|
4
|
+
from typing import Any, Protocol, TypeVar
|
|
5
|
+
from uuid import UUID
|
|
6
|
+
|
|
7
|
+
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
|
8
|
+
|
|
9
|
+
from .models import OutboxMessageMixin, OutboxStatus
|
|
10
|
+
from .repository import OutboxRepository
|
|
11
|
+
|
|
12
|
+
OutboxModelT = TypeVar("OutboxModelT", bound=OutboxMessageMixin)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class OutboxPublisher(Protocol):
|
|
16
|
+
async def publish(
|
|
17
|
+
self,
|
|
18
|
+
*,
|
|
19
|
+
topic: str,
|
|
20
|
+
payload: dict[str, Any],
|
|
21
|
+
key: str | None = None,
|
|
22
|
+
headers: dict[str, Any] | None = None,
|
|
23
|
+
) -> None: ...
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class OutboxDispatcher:
|
|
27
|
+
"""Dispatch outbox rows to an external transport."""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
*,
|
|
32
|
+
session_maker: async_sessionmaker[AsyncSession],
|
|
33
|
+
model_class: type[OutboxModelT],
|
|
34
|
+
publisher: OutboxPublisher,
|
|
35
|
+
failure_backoff: timedelta = timedelta(seconds=30),
|
|
36
|
+
) -> None:
|
|
37
|
+
self._session_maker = session_maker
|
|
38
|
+
self._repository = OutboxRepository(model_class)
|
|
39
|
+
self._publisher = publisher
|
|
40
|
+
self._failure_backoff = failure_backoff
|
|
41
|
+
|
|
42
|
+
async def run_once(self, *, limit: int = 100) -> int:
|
|
43
|
+
"""Claim due outbox messages and attempt to publish them once."""
|
|
44
|
+
now = datetime.now(UTC)
|
|
45
|
+
claimed: list[tuple[UUID, str, dict[str, Any], str | None, dict[str, Any]]] = []
|
|
46
|
+
|
|
47
|
+
async with self._session_maker() as session:
|
|
48
|
+
async with session.begin():
|
|
49
|
+
due = await self._repository.due_for_dispatch(
|
|
50
|
+
session,
|
|
51
|
+
now=now,
|
|
52
|
+
limit=limit,
|
|
53
|
+
)
|
|
54
|
+
for message in due:
|
|
55
|
+
message.status = OutboxStatus.DISPATCHING
|
|
56
|
+
claimed.append(
|
|
57
|
+
(
|
|
58
|
+
message.id,
|
|
59
|
+
message.topic,
|
|
60
|
+
message.payload,
|
|
61
|
+
message.message_key,
|
|
62
|
+
message.headers,
|
|
63
|
+
)
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
for message_id, topic, payload, key, headers in claimed:
|
|
67
|
+
try:
|
|
68
|
+
await self._publisher.publish(
|
|
69
|
+
topic=topic,
|
|
70
|
+
payload=payload,
|
|
71
|
+
key=key,
|
|
72
|
+
headers=headers,
|
|
73
|
+
)
|
|
74
|
+
except Exception as exc: # noqa: BLE001
|
|
75
|
+
async with self._session_maker() as session:
|
|
76
|
+
async with session.begin():
|
|
77
|
+
row = await self._repository.get_for_update(session, message_id)
|
|
78
|
+
if row is None or row.status != OutboxStatus.DISPATCHING:
|
|
79
|
+
continue
|
|
80
|
+
row.status = OutboxStatus.FAILED
|
|
81
|
+
row.attempts += 1
|
|
82
|
+
row.last_error = repr(exc)
|
|
83
|
+
row.next_attempt_at = datetime.now(UTC) + self._failure_backoff
|
|
84
|
+
continue
|
|
85
|
+
|
|
86
|
+
async with self._session_maker() as session:
|
|
87
|
+
async with session.begin():
|
|
88
|
+
row = await self._repository.get_for_update(session, message_id)
|
|
89
|
+
if row is None or row.status != OutboxStatus.DISPATCHING:
|
|
90
|
+
continue
|
|
91
|
+
row.status = OutboxStatus.SENT
|
|
92
|
+
row.sent_at = datetime.now(UTC)
|
|
93
|
+
row.last_error = None
|
|
94
|
+
|
|
95
|
+
return len(claimed)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class OutboxEvent:
|
|
9
|
+
topic: str
|
|
10
|
+
payload: dict[str, Any]
|
|
11
|
+
key: str | None = None
|
|
12
|
+
headers: dict[str, Any] = field(default_factory=dict)
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from sqlalchemy import JSON, DateTime
|
|
9
|
+
from sqlalchemy import Enum as SqlEnum
|
|
10
|
+
from sqlalchemy import Integer, String, Text, func
|
|
11
|
+
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
|
12
|
+
from sqlalchemy.ext.mutable import MutableDict
|
|
13
|
+
from sqlalchemy.orm import Mapped, declarative_mixin, mapped_column
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _json_type() -> JSON:
|
|
17
|
+
return JSON().with_variant(JSONB, "postgresql")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class OutboxStatus(str, Enum):
|
|
21
|
+
PENDING = "PENDING"
|
|
22
|
+
DISPATCHING = "DISPATCHING"
|
|
23
|
+
SENT = "SENT"
|
|
24
|
+
FAILED = "FAILED"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@declarative_mixin
|
|
28
|
+
class OutboxMessageMixin:
|
|
29
|
+
id: Mapped[uuid.UUID] = mapped_column(
|
|
30
|
+
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
|
31
|
+
)
|
|
32
|
+
saga_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True)
|
|
33
|
+
aggregation_id: Mapped[str] = mapped_column(String(255), index=True)
|
|
34
|
+
step_id: Mapped[str] = mapped_column(String(255), index=True)
|
|
35
|
+
trace_id: Mapped[str] = mapped_column(String(255), index=True)
|
|
36
|
+
topic: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
37
|
+
message_key: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
38
|
+
payload: Mapped[dict[str, Any]] = mapped_column(
|
|
39
|
+
MutableDict.as_mutable(_json_type()),
|
|
40
|
+
default=dict,
|
|
41
|
+
)
|
|
42
|
+
headers: Mapped[dict[str, Any]] = mapped_column(
|
|
43
|
+
MutableDict.as_mutable(_json_type()),
|
|
44
|
+
default=dict,
|
|
45
|
+
)
|
|
46
|
+
status: Mapped[OutboxStatus] = mapped_column(
|
|
47
|
+
SqlEnum(OutboxStatus),
|
|
48
|
+
default=OutboxStatus.PENDING,
|
|
49
|
+
index=True,
|
|
50
|
+
)
|
|
51
|
+
attempts: Mapped[int] = mapped_column(Integer, default=0)
|
|
52
|
+
next_attempt_at: Mapped[datetime] = mapped_column(
|
|
53
|
+
DateTime(timezone=True),
|
|
54
|
+
nullable=False,
|
|
55
|
+
server_default=func.now(),
|
|
56
|
+
index=True,
|
|
57
|
+
)
|
|
58
|
+
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
59
|
+
sent_at: Mapped[datetime | None] = mapped_column(
|
|
60
|
+
DateTime(timezone=True),
|
|
61
|
+
nullable=True,
|
|
62
|
+
)
|
|
63
|
+
created_at: Mapped[datetime] = mapped_column(
|
|
64
|
+
DateTime(timezone=True), nullable=False, server_default=func.now()
|
|
65
|
+
)
|
|
66
|
+
updated_at: Mapped[datetime] = mapped_column(
|
|
67
|
+
DateTime(timezone=True),
|
|
68
|
+
nullable=False,
|
|
69
|
+
server_default=func.now(),
|
|
70
|
+
onupdate=func.now(),
|
|
71
|
+
)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Generic, TypeVar
|
|
5
|
+
from uuid import UUID
|
|
6
|
+
|
|
7
|
+
from sqlalchemy import Select, select
|
|
8
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
9
|
+
|
|
10
|
+
from .models import OutboxMessageMixin, OutboxStatus
|
|
11
|
+
|
|
12
|
+
OutboxModelT = TypeVar("OutboxModelT", bound=OutboxMessageMixin)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class OutboxRepository(Generic[OutboxModelT]):
|
|
16
|
+
"""Provide persistence operations for outbox rows."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, model_class: type[OutboxModelT]) -> None:
|
|
19
|
+
self.model_class = model_class
|
|
20
|
+
|
|
21
|
+
async def create_many(
|
|
22
|
+
self,
|
|
23
|
+
session: AsyncSession,
|
|
24
|
+
messages: list[OutboxModelT],
|
|
25
|
+
) -> None:
|
|
26
|
+
session.add_all(messages)
|
|
27
|
+
await session.flush()
|
|
28
|
+
|
|
29
|
+
async def due_for_dispatch(
|
|
30
|
+
self,
|
|
31
|
+
session: AsyncSession,
|
|
32
|
+
*,
|
|
33
|
+
now: datetime,
|
|
34
|
+
limit: int,
|
|
35
|
+
) -> list[OutboxModelT]:
|
|
36
|
+
stmt: Select[tuple[OutboxModelT]] = (
|
|
37
|
+
select(self.model_class)
|
|
38
|
+
.where(
|
|
39
|
+
self.model_class.status.in_(
|
|
40
|
+
(OutboxStatus.PENDING, OutboxStatus.FAILED),
|
|
41
|
+
),
|
|
42
|
+
self.model_class.next_attempt_at <= now,
|
|
43
|
+
)
|
|
44
|
+
.order_by(
|
|
45
|
+
self.model_class.next_attempt_at.asc(),
|
|
46
|
+
self.model_class.created_at.asc(),
|
|
47
|
+
)
|
|
48
|
+
.limit(limit)
|
|
49
|
+
)
|
|
50
|
+
if self._supports_skip_locked(session):
|
|
51
|
+
stmt = stmt.with_for_update(skip_locked=True)
|
|
52
|
+
else:
|
|
53
|
+
stmt = stmt.with_for_update(nowait=False)
|
|
54
|
+
result = await session.execute(stmt)
|
|
55
|
+
return list(result.scalars().all())
|
|
56
|
+
|
|
57
|
+
async def get_for_update(
|
|
58
|
+
self,
|
|
59
|
+
session: AsyncSession,
|
|
60
|
+
message_id: UUID,
|
|
61
|
+
) -> OutboxModelT | None:
|
|
62
|
+
stmt: Select[tuple[OutboxModelT]] = (
|
|
63
|
+
select(self.model_class)
|
|
64
|
+
.where(self.model_class.id == message_id)
|
|
65
|
+
.with_for_update(nowait=False)
|
|
66
|
+
)
|
|
67
|
+
result = await session.execute(stmt)
|
|
68
|
+
return result.scalar_one_or_none()
|
|
69
|
+
|
|
70
|
+
@staticmethod
|
|
71
|
+
def _supports_skip_locked(session: AsyncSession) -> bool:
|
|
72
|
+
bind = session.get_bind()
|
|
73
|
+
return bind is not None and bind.dialect.name == "postgresql"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_saga_orchestrator-0.1.0 → python_saga_orchestrator-0.1.2}/saga_orchestrator/admin/api.py
RENAMED
|
File without changes
|
{python_saga_orchestrator-0.1.0 → python_saga_orchestrator-0.1.2}/saga_orchestrator/core/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|