python-saga-orchestrator 0.5.0__tar.gz → 0.7.0__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.7.0/Makefile +16 -0
- {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/PKG-INFO +17 -7
- {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/README.md +16 -6
- {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/examples/common.py +38 -6
- {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/examples/http_and_queue.py +34 -27
- {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/examples/llm_deploy.py +13 -2
- {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/python_saga_orchestrator.egg-info/PKG-INFO +17 -7
- {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/_version.py +2 -2
- {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/core/engine.py +48 -31
- {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/domain/models/context.py +0 -3
- {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/domain/models/step.py +11 -2
- {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/tests/integration/helpers.py +183 -46
- {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/tests/integration/test_context_persistence.py +8 -3
- {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/tests/integration/test_core_flow.py +126 -12
- {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/tests/integration/test_notification_flow.py +4 -3
- python_saga_orchestrator-0.5.0/Makefile +0 -12
- {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/.github/workflows/ci.yml +0 -0
- {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/.github/workflows/publish.yml +0 -0
- {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/.gitignore +0 -0
- {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/Dockerfile +0 -0
- {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/LICENSE +0 -0
- {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/docker-compose.yaml +0 -0
- {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/examples/admin_skip.py +0 -0
- {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/examples/compensation_flow.py +0 -0
- {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/examples/retry_recovery.py +0 -0
- {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/pyproject.toml +0 -0
- {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/python_saga_orchestrator.egg-info/SOURCES.txt +0 -0
- {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/python_saga_orchestrator.egg-info/dependency_links.txt +0 -0
- {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/python_saga_orchestrator.egg-info/requires.txt +0 -0
- {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/python_saga_orchestrator.egg-info/top_level.txt +0 -0
- {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/__init__.py +0 -0
- {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/admin/__init__.py +0 -0
- {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/admin/api.py +0 -0
- {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/core/__init__.py +0 -0
- {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/core/builder.py +0 -0
- {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/core/orchestrator.py +0 -0
- {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/core/repository.py +0 -0
- {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/domain/__init__.py +0 -0
- {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/domain/exceptions/__init__.py +0 -0
- {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/domain/exceptions/saga.py +0 -0
- {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/domain/mixins/__init__.py +0 -0
- {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/domain/mixins/saga_state.py +0 -0
- {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/domain/mixins/saga_step_histrory.py +0 -0
- {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/domain/mixins/types.py +0 -0
- {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/domain/models/__init__.py +0 -0
- {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/domain/models/builder.py +0 -0
- {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/domain/models/enums/__init__.py +0 -0
- {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/domain/models/enums/base_str_enum.py +0 -0
- {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/domain/models/enums/saga_status.py +0 -0
- {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/domain/models/enums/saga_step_phase.py +0 -0
- {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/domain/models/enums/saga_step_status.py +0 -0
- {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/domain/models/notify.py +0 -0
- {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/domain/models/retry.py +0 -0
- {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/domain/models/saga_snapshot.py +0 -0
- {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/inbox/__init__.py +0 -0
- {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/inbox/contracts.py +0 -0
- {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/inbox/dispatcher.py +0 -0
- {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/inbox/models.py +0 -0
- {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/inbox/repository.py +0 -0
- {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/inbox/retry.py +0 -0
- {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/outbox/__init__.py +0 -0
- {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/outbox/contracts.py +0 -0
- {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/outbox/dispatcher.py +0 -0
- {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/outbox/event.py +0 -0
- {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/outbox/factory.py +0 -0
- {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/outbox/models.py +0 -0
- {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/outbox/repository.py +0 -0
- {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/outbox/retry.py +0 -0
- {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/outbox/serialization.py +0 -0
- {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/setup.cfg +0 -0
- {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/task.md +0 -0
- {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/tests/__init__.py +0 -0
- {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/tests/conftest.py +0 -0
- {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/tests/integration/__init__.py +0 -0
- {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/tests/integration/conftest.py +0 -0
- {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/tests/integration/models.py +0 -0
- {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/tests/integration/test_admin_api.py +0 -0
- {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/tests/integration/test_compensation_flow.py +0 -0
- {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/tests/integration/test_inbox_flow.py +0 -0
- {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/tests/integration/test_lifecycle_hooks.py +0 -0
- {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/tests/integration/test_outbox_flow.py +0 -0
- {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/tests/integration/test_repository.py +0 -0
- {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/tests/unit/__init__.py +0 -0
- {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/tests/unit/test_builder.py +0 -0
- {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/tests/unit/test_inbox_extensibility.py +0 -0
- {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/tests/unit/test_input_context.py +0 -0
- {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/tests/unit/test_orchestrator_helpers.py +0 -0
- {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/tests/unit/test_outbox_extensibility.py +0 -0
- {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/tests/unit/test_retry.py +0 -0
- {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/tests/unit/test_step_type_resolution.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-saga-orchestrator
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.0
|
|
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
|
|
@@ -54,6 +54,7 @@ Unlike external workflow platforms, this library runs inside your service and st
|
|
|
54
54
|
- async queue-style steps through `StepAwaitEvent` and `notify(...)`
|
|
55
55
|
- administrative operations through `SagaAdmin`
|
|
56
56
|
- PostgreSQL-first reliability using `SELECT ... FOR UPDATE`
|
|
57
|
+
- strict CQRS-style separation of historical step data (`InputModel`) and async triggers (`Events`)
|
|
57
58
|
|
|
58
59
|
## Installation
|
|
59
60
|
|
|
@@ -90,8 +91,10 @@ pip install '.[dev]'
|
|
|
90
91
|
### `BaseStep`
|
|
91
92
|
|
|
92
93
|
Each saga step is a class with:
|
|
93
|
-
- `execute(inp) -> out`
|
|
94
|
-
- optional `compensate(inp, out) -> None`
|
|
94
|
+
- `execute(self, inp, event_type=None, event_payload=None) -> out`
|
|
95
|
+
- optional `compensate(self, inp, out, event_type=None, event_payload=None) -> None`
|
|
96
|
+
|
|
97
|
+
The `inp` object represents the immutable historical command parameters (mapped via `input_map`), while `event_type` and `event_payload` receive dynamic asynchronous continuation signals.
|
|
95
98
|
|
|
96
99
|
Steps are regular Python objects. In practice they are created once at application startup and reused.
|
|
97
100
|
|
|
@@ -139,6 +142,7 @@ Your SQLAlchemy model inherits `SagaStateMixin` to store:
|
|
|
139
142
|
```python
|
|
140
143
|
import uuid
|
|
141
144
|
from datetime import timedelta
|
|
145
|
+
from typing import Any
|
|
142
146
|
|
|
143
147
|
from pydantic import BaseModel
|
|
144
148
|
from sqlalchemy import ForeignKey
|
|
@@ -196,15 +200,21 @@ class ChargeOutput(BaseModel):
|
|
|
196
200
|
|
|
197
201
|
|
|
198
202
|
class ReserveInventoryStep(BaseStep[ReserveInput, ReserveOutput]):
|
|
199
|
-
async def execute(
|
|
203
|
+
async def execute(
|
|
204
|
+
self, inp: ReserveInput, event_type: str | None = None, event_payload: Any | None = None
|
|
205
|
+
) -> ReserveOutput:
|
|
200
206
|
return ReserveOutput(reservation_id=f"res-{inp.order_id}")
|
|
201
207
|
|
|
202
|
-
async def compensate(
|
|
208
|
+
async def compensate(
|
|
209
|
+
self, inp: ReserveInput, out: ReserveOutput, event_type: str | None = None, event_payload: Any | None = None
|
|
210
|
+
) -> None:
|
|
203
211
|
return None
|
|
204
212
|
|
|
205
213
|
|
|
206
214
|
class ChargePaymentStep(BaseStep[ChargeInput, ChargeOutput]):
|
|
207
|
-
async def execute(
|
|
215
|
+
async def execute(
|
|
216
|
+
self, inp: ChargeInput, event_type: str | None = None, event_payload: Any | None = None
|
|
217
|
+
) -> ChargeOutput:
|
|
208
218
|
return ChargeOutput(payment_id=f"pay-{inp.reservation_id}")
|
|
209
219
|
|
|
210
220
|
|
|
@@ -299,7 +309,7 @@ token = await orchestrator.await_event(
|
|
|
299
309
|
)
|
|
300
310
|
```
|
|
301
311
|
|
|
302
|
-
|
|
312
|
+
When a suspended saga is awakened by an event, the orchestrator passes the event directly to the step's `execute` or `compensate` method via the `event_type` and `event_payload` arguments. This strictly separates historical context (`InputModel`) from dynamic asynchronous triggers (`Events`).
|
|
303
313
|
|
|
304
314
|
For distributed consumers, use transactional inbox ingestion first, then process inbox rows:
|
|
305
315
|
|
|
@@ -20,6 +20,7 @@ Unlike external workflow platforms, this library runs inside your service and st
|
|
|
20
20
|
- async queue-style steps through `StepAwaitEvent` and `notify(...)`
|
|
21
21
|
- administrative operations through `SagaAdmin`
|
|
22
22
|
- PostgreSQL-first reliability using `SELECT ... FOR UPDATE`
|
|
23
|
+
- strict CQRS-style separation of historical step data (`InputModel`) and async triggers (`Events`)
|
|
23
24
|
|
|
24
25
|
## Installation
|
|
25
26
|
|
|
@@ -56,8 +57,10 @@ pip install '.[dev]'
|
|
|
56
57
|
### `BaseStep`
|
|
57
58
|
|
|
58
59
|
Each saga step is a class with:
|
|
59
|
-
- `execute(inp) -> out`
|
|
60
|
-
- optional `compensate(inp, out) -> None`
|
|
60
|
+
- `execute(self, inp, event_type=None, event_payload=None) -> out`
|
|
61
|
+
- optional `compensate(self, inp, out, event_type=None, event_payload=None) -> None`
|
|
62
|
+
|
|
63
|
+
The `inp` object represents the immutable historical command parameters (mapped via `input_map`), while `event_type` and `event_payload` receive dynamic asynchronous continuation signals.
|
|
61
64
|
|
|
62
65
|
Steps are regular Python objects. In practice they are created once at application startup and reused.
|
|
63
66
|
|
|
@@ -105,6 +108,7 @@ Your SQLAlchemy model inherits `SagaStateMixin` to store:
|
|
|
105
108
|
```python
|
|
106
109
|
import uuid
|
|
107
110
|
from datetime import timedelta
|
|
111
|
+
from typing import Any
|
|
108
112
|
|
|
109
113
|
from pydantic import BaseModel
|
|
110
114
|
from sqlalchemy import ForeignKey
|
|
@@ -162,15 +166,21 @@ class ChargeOutput(BaseModel):
|
|
|
162
166
|
|
|
163
167
|
|
|
164
168
|
class ReserveInventoryStep(BaseStep[ReserveInput, ReserveOutput]):
|
|
165
|
-
async def execute(
|
|
169
|
+
async def execute(
|
|
170
|
+
self, inp: ReserveInput, event_type: str | None = None, event_payload: Any | None = None
|
|
171
|
+
) -> ReserveOutput:
|
|
166
172
|
return ReserveOutput(reservation_id=f"res-{inp.order_id}")
|
|
167
173
|
|
|
168
|
-
async def compensate(
|
|
174
|
+
async def compensate(
|
|
175
|
+
self, inp: ReserveInput, out: ReserveOutput, event_type: str | None = None, event_payload: Any | None = None
|
|
176
|
+
) -> None:
|
|
169
177
|
return None
|
|
170
178
|
|
|
171
179
|
|
|
172
180
|
class ChargePaymentStep(BaseStep[ChargeInput, ChargeOutput]):
|
|
173
|
-
async def execute(
|
|
181
|
+
async def execute(
|
|
182
|
+
self, inp: ChargeInput, event_type: str | None = None, event_payload: Any | None = None
|
|
183
|
+
) -> ChargeOutput:
|
|
174
184
|
return ChargeOutput(payment_id=f"pay-{inp.reservation_id}")
|
|
175
185
|
|
|
176
186
|
|
|
@@ -265,7 +275,7 @@ token = await orchestrator.await_event(
|
|
|
265
275
|
)
|
|
266
276
|
```
|
|
267
277
|
|
|
268
|
-
|
|
278
|
+
When a suspended saga is awakened by an event, the orchestrator passes the event directly to the step's `execute` or `compensate` method via the `event_type` and `event_payload` arguments. This strictly separates historical context (`InputModel`) from dynamic asynchronous triggers (`Events`).
|
|
269
279
|
|
|
270
280
|
For distributed consumers, use transactional inbox ingestion first, then process inbox rows:
|
|
271
281
|
|
|
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import os
|
|
4
4
|
from datetime import timedelta
|
|
5
|
+
from typing import Any
|
|
5
6
|
from uuid import UUID
|
|
6
7
|
|
|
7
8
|
from pydantic import BaseModel
|
|
@@ -73,14 +74,25 @@ class FinalizeOutput(BaseModel):
|
|
|
73
74
|
|
|
74
75
|
|
|
75
76
|
class ReserveResourcesStep(BaseStep[StartInput, StartOutput]):
|
|
76
|
-
async def execute(
|
|
77
|
+
async def execute(
|
|
78
|
+
self,
|
|
79
|
+
inp: StartInput,
|
|
80
|
+
event_type: str | None = None,
|
|
81
|
+
event_payload: Any | None = None,
|
|
82
|
+
) -> StartOutput:
|
|
77
83
|
print(f"[reserve] reserving resources for {inp.model_name}")
|
|
78
84
|
return StartOutput(
|
|
79
85
|
model_name=inp.model_name,
|
|
80
86
|
reservation_id=f"reservation-{inp.model_name}",
|
|
81
87
|
)
|
|
82
88
|
|
|
83
|
-
async def compensate(
|
|
89
|
+
async def compensate(
|
|
90
|
+
self,
|
|
91
|
+
inp: StartInput,
|
|
92
|
+
out: StartOutput,
|
|
93
|
+
event_type: str | None = None,
|
|
94
|
+
event_payload: Any | None = None,
|
|
95
|
+
) -> None:
|
|
84
96
|
print(f"[reserve] compensating reservation {out.reservation_id}")
|
|
85
97
|
|
|
86
98
|
|
|
@@ -88,7 +100,12 @@ class DeployModelStep(BaseStep[DeployInput, DeployOutput]):
|
|
|
88
100
|
def __init__(self) -> None:
|
|
89
101
|
self.calls = 0
|
|
90
102
|
|
|
91
|
-
async def execute(
|
|
103
|
+
async def execute(
|
|
104
|
+
self,
|
|
105
|
+
inp: DeployInput,
|
|
106
|
+
event_type: str | None = None,
|
|
107
|
+
event_payload: Any | None = None,
|
|
108
|
+
) -> DeployOutput:
|
|
92
109
|
self.calls += 1
|
|
93
110
|
print(f"[deploy] attempt={self.calls} model={inp.model_name}")
|
|
94
111
|
if self.calls == 1:
|
|
@@ -97,19 +114,34 @@ class DeployModelStep(BaseStep[DeployInput, DeployOutput]):
|
|
|
97
114
|
|
|
98
115
|
|
|
99
116
|
class FinalizeStep(BaseStep[FinalizeInput, FinalizeOutput]):
|
|
100
|
-
async def execute(
|
|
117
|
+
async def execute(
|
|
118
|
+
self,
|
|
119
|
+
inp: FinalizeInput,
|
|
120
|
+
event_type: str | None = None,
|
|
121
|
+
event_payload: Any | None = None,
|
|
122
|
+
) -> FinalizeOutput:
|
|
101
123
|
print(f"[finalize] model is available at {inp.endpoint}")
|
|
102
124
|
return FinalizeOutput(status="COMPLETED")
|
|
103
125
|
|
|
104
126
|
|
|
105
127
|
class FailingPublishStep(BaseStep[DeployInput, DeployOutput]):
|
|
106
|
-
async def execute(
|
|
128
|
+
async def execute(
|
|
129
|
+
self,
|
|
130
|
+
inp: DeployInput,
|
|
131
|
+
event_type: str | None = None,
|
|
132
|
+
event_payload: Any | None = None,
|
|
133
|
+
) -> DeployOutput:
|
|
107
134
|
print(f"[publish] forcing failure for {inp.model_name}")
|
|
108
135
|
raise RuntimeError("publish failed")
|
|
109
136
|
|
|
110
137
|
|
|
111
138
|
class ManualApprovalStep(BaseStep[StartInput, DeployOutput]):
|
|
112
|
-
async def execute(
|
|
139
|
+
async def execute(
|
|
140
|
+
self,
|
|
141
|
+
inp: StartInput,
|
|
142
|
+
event_type: str | None = None,
|
|
143
|
+
event_payload: Any | None = None,
|
|
144
|
+
) -> DeployOutput:
|
|
113
145
|
print(f"[approval] waiting for manual approval for {inp.model_name}")
|
|
114
146
|
raise RuntimeError("approval is pending")
|
|
115
147
|
|
{python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/examples/http_and_queue.py
RENAMED
|
@@ -5,6 +5,7 @@ import contextlib
|
|
|
5
5
|
import json
|
|
6
6
|
import os
|
|
7
7
|
from datetime import timedelta
|
|
8
|
+
from typing import Any
|
|
8
9
|
from uuid import uuid4
|
|
9
10
|
|
|
10
11
|
from aio_pika import DeliveryMode, IncomingMessage, Message, connect_robust
|
|
@@ -70,8 +71,6 @@ class ReserveInput(BaseModel):
|
|
|
70
71
|
order_id: str
|
|
71
72
|
gateway_url: str
|
|
72
73
|
correlation_id: str
|
|
73
|
-
response_event_type: str | None = None
|
|
74
|
-
response_payload: dict | None = None
|
|
75
74
|
|
|
76
75
|
|
|
77
76
|
class ReserveOutput(BaseModel):
|
|
@@ -82,8 +81,6 @@ class ActivateInput(BaseModel):
|
|
|
82
81
|
aggregation_id: str
|
|
83
82
|
reservation_id: str
|
|
84
83
|
correlation_id: str
|
|
85
|
-
response_event_type: str | None = None
|
|
86
|
-
response_payload: dict | None = None
|
|
87
84
|
|
|
88
85
|
|
|
89
86
|
class ActivateOutput(BaseModel):
|
|
@@ -91,7 +88,12 @@ class ActivateOutput(BaseModel):
|
|
|
91
88
|
|
|
92
89
|
|
|
93
90
|
class PrepareStep(BaseStep[PrepareInput, PrepareOutput]):
|
|
94
|
-
async def execute(
|
|
91
|
+
async def execute(
|
|
92
|
+
self,
|
|
93
|
+
inp: PrepareInput,
|
|
94
|
+
event_type: str | None = None,
|
|
95
|
+
event_payload: Any | None = None,
|
|
96
|
+
) -> PrepareOutput:
|
|
95
97
|
print(f"[http] request: prepare order={inp.order_id}")
|
|
96
98
|
await asyncio.sleep(0.05)
|
|
97
99
|
return PrepareOutput(
|
|
@@ -101,8 +103,13 @@ class PrepareStep(BaseStep[PrepareInput, PrepareOutput]):
|
|
|
101
103
|
|
|
102
104
|
|
|
103
105
|
class ReserveQueueStep(BaseStep[ReserveInput, ReserveOutput]):
|
|
104
|
-
async def execute(
|
|
105
|
-
|
|
106
|
+
async def execute(
|
|
107
|
+
self,
|
|
108
|
+
inp: ReserveInput,
|
|
109
|
+
event_type: str | None = None,
|
|
110
|
+
event_payload: Any | None = None,
|
|
111
|
+
) -> ReserveOutput | StepAwaitEvent:
|
|
112
|
+
if event_type is None:
|
|
106
113
|
print(f"[queue] send reserve command for order={inp.order_id}")
|
|
107
114
|
return StepAwaitEvent(
|
|
108
115
|
event_types=("reserve.success", "reserve.failed"),
|
|
@@ -121,18 +128,24 @@ class ReserveQueueStep(BaseStep[ReserveInput, ReserveOutput]):
|
|
|
121
128
|
),
|
|
122
129
|
),
|
|
123
130
|
)
|
|
124
|
-
if
|
|
125
|
-
reason = (
|
|
131
|
+
if event_type == "reserve.failed":
|
|
132
|
+
reason = (event_payload or {}).get("reason", "unknown")
|
|
126
133
|
raise RuntimeError(f"reserve failed: {reason}")
|
|
127
|
-
if
|
|
128
|
-
raise RuntimeError(f"unexpected reserve event: {
|
|
129
|
-
|
|
134
|
+
if event_type != "reserve.success":
|
|
135
|
+
raise RuntimeError(f"unexpected reserve event: {event_type}")
|
|
136
|
+
|
|
137
|
+
payload = event_payload or {}
|
|
130
138
|
return ReserveOutput(reservation_id=payload["reservation_id"])
|
|
131
139
|
|
|
132
140
|
|
|
133
141
|
class ActivateQueueStep(BaseStep[ActivateInput, ActivateOutput]):
|
|
134
|
-
async def execute(
|
|
135
|
-
|
|
142
|
+
async def execute(
|
|
143
|
+
self,
|
|
144
|
+
inp: ActivateInput,
|
|
145
|
+
event_type: str | None = None,
|
|
146
|
+
event_payload: Any | None = None,
|
|
147
|
+
) -> ActivateOutput | StepAwaitEvent:
|
|
148
|
+
if event_type is None:
|
|
136
149
|
print(f"[queue] send activate command for reservation={inp.reservation_id}")
|
|
137
150
|
return StepAwaitEvent(
|
|
138
151
|
event_types=("activate.success", "activate.failed"),
|
|
@@ -150,15 +163,17 @@ class ActivateQueueStep(BaseStep[ActivateInput, ActivateOutput]):
|
|
|
150
163
|
),
|
|
151
164
|
),
|
|
152
165
|
)
|
|
153
|
-
if
|
|
154
|
-
reason = (
|
|
166
|
+
if event_type == "activate.failed":
|
|
167
|
+
reason = (event_payload or {}).get("reason", "unknown")
|
|
155
168
|
raise RuntimeError(f"activate failed: {reason}")
|
|
156
|
-
if
|
|
157
|
-
raise RuntimeError(f"unexpected activate event: {
|
|
158
|
-
|
|
169
|
+
if event_type != "activate.success":
|
|
170
|
+
raise RuntimeError(f"unexpected activate event: {event_type}")
|
|
171
|
+
|
|
172
|
+
payload = event_payload or {}
|
|
159
173
|
return ActivateOutput(deployment_id=payload["deployment_id"])
|
|
160
174
|
|
|
161
175
|
|
|
176
|
+
# ... (RabbitMqPublisher остается без изменений) ...
|
|
162
177
|
class RabbitMqPublisher:
|
|
163
178
|
def __init__(self, channel) -> None:
|
|
164
179
|
self._channel = channel
|
|
@@ -214,10 +229,6 @@ async def main() -> None:
|
|
|
214
229
|
order_id=ctx.initial_data["order_id"],
|
|
215
230
|
gateway_url=ctx.step_outputs["step_0"]["gateway_url"],
|
|
216
231
|
correlation_id=f"reserve-{ctx.initial_data['order_id']}",
|
|
217
|
-
response_event_type=(ctx.context.get("latest_event_meta") or {}).get(
|
|
218
|
-
"event_type"
|
|
219
|
-
),
|
|
220
|
-
response_payload=ctx.latest_event,
|
|
221
232
|
),
|
|
222
233
|
)
|
|
223
234
|
builder.add_step(
|
|
@@ -226,10 +237,6 @@ async def main() -> None:
|
|
|
226
237
|
aggregation_id=ctx.initial_data["order_id"],
|
|
227
238
|
reservation_id=ctx.step_outputs["step_1"]["reservation_id"],
|
|
228
239
|
correlation_id=f"activate-{ctx.step_outputs['step_1']['reservation_id']}",
|
|
229
|
-
response_event_type=(ctx.context.get("latest_event_meta") or {}).get(
|
|
230
|
-
"event_type"
|
|
231
|
-
),
|
|
232
|
-
response_payload=ctx.latest_event,
|
|
233
240
|
),
|
|
234
241
|
)
|
|
235
242
|
orchestrator.register("http_queue_3_steps", builder.build())
|
|
@@ -4,6 +4,7 @@ import asyncio
|
|
|
4
4
|
import os
|
|
5
5
|
import uuid
|
|
6
6
|
from datetime import timedelta
|
|
7
|
+
from typing import Any
|
|
7
8
|
|
|
8
9
|
from pydantic import BaseModel
|
|
9
10
|
from sqlalchemy import ForeignKey
|
|
@@ -65,12 +66,22 @@ class DeployOutput(BaseModel):
|
|
|
65
66
|
|
|
66
67
|
|
|
67
68
|
class CheckModelStep(BaseStep[CheckModelInput, CheckModelOutput]):
|
|
68
|
-
async def execute(
|
|
69
|
+
async def execute(
|
|
70
|
+
self,
|
|
71
|
+
inp: CheckModelInput,
|
|
72
|
+
event_type: str | None = None,
|
|
73
|
+
event_payload: Any | None = None,
|
|
74
|
+
) -> CheckModelOutput:
|
|
69
75
|
return CheckModelOutput(exists=inp.model_name in {"llama-2"})
|
|
70
76
|
|
|
71
77
|
|
|
72
78
|
class DeployStep(BaseStep[DeployInput, DeployOutput]):
|
|
73
|
-
async def execute(
|
|
79
|
+
async def execute(
|
|
80
|
+
self,
|
|
81
|
+
inp: DeployInput,
|
|
82
|
+
event_type: str | None = None,
|
|
83
|
+
event_payload: Any | None = None,
|
|
84
|
+
) -> DeployOutput:
|
|
74
85
|
await asyncio.sleep(0.01)
|
|
75
86
|
return DeployOutput(endpoint=f"https://models.local/{inp.model_name}")
|
|
76
87
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-saga-orchestrator
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.0
|
|
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
|
|
@@ -54,6 +54,7 @@ Unlike external workflow platforms, this library runs inside your service and st
|
|
|
54
54
|
- async queue-style steps through `StepAwaitEvent` and `notify(...)`
|
|
55
55
|
- administrative operations through `SagaAdmin`
|
|
56
56
|
- PostgreSQL-first reliability using `SELECT ... FOR UPDATE`
|
|
57
|
+
- strict CQRS-style separation of historical step data (`InputModel`) and async triggers (`Events`)
|
|
57
58
|
|
|
58
59
|
## Installation
|
|
59
60
|
|
|
@@ -90,8 +91,10 @@ pip install '.[dev]'
|
|
|
90
91
|
### `BaseStep`
|
|
91
92
|
|
|
92
93
|
Each saga step is a class with:
|
|
93
|
-
- `execute(inp) -> out`
|
|
94
|
-
- optional `compensate(inp, out) -> None`
|
|
94
|
+
- `execute(self, inp, event_type=None, event_payload=None) -> out`
|
|
95
|
+
- optional `compensate(self, inp, out, event_type=None, event_payload=None) -> None`
|
|
96
|
+
|
|
97
|
+
The `inp` object represents the immutable historical command parameters (mapped via `input_map`), while `event_type` and `event_payload` receive dynamic asynchronous continuation signals.
|
|
95
98
|
|
|
96
99
|
Steps are regular Python objects. In practice they are created once at application startup and reused.
|
|
97
100
|
|
|
@@ -139,6 +142,7 @@ Your SQLAlchemy model inherits `SagaStateMixin` to store:
|
|
|
139
142
|
```python
|
|
140
143
|
import uuid
|
|
141
144
|
from datetime import timedelta
|
|
145
|
+
from typing import Any
|
|
142
146
|
|
|
143
147
|
from pydantic import BaseModel
|
|
144
148
|
from sqlalchemy import ForeignKey
|
|
@@ -196,15 +200,21 @@ class ChargeOutput(BaseModel):
|
|
|
196
200
|
|
|
197
201
|
|
|
198
202
|
class ReserveInventoryStep(BaseStep[ReserveInput, ReserveOutput]):
|
|
199
|
-
async def execute(
|
|
203
|
+
async def execute(
|
|
204
|
+
self, inp: ReserveInput, event_type: str | None = None, event_payload: Any | None = None
|
|
205
|
+
) -> ReserveOutput:
|
|
200
206
|
return ReserveOutput(reservation_id=f"res-{inp.order_id}")
|
|
201
207
|
|
|
202
|
-
async def compensate(
|
|
208
|
+
async def compensate(
|
|
209
|
+
self, inp: ReserveInput, out: ReserveOutput, event_type: str | None = None, event_payload: Any | None = None
|
|
210
|
+
) -> None:
|
|
203
211
|
return None
|
|
204
212
|
|
|
205
213
|
|
|
206
214
|
class ChargePaymentStep(BaseStep[ChargeInput, ChargeOutput]):
|
|
207
|
-
async def execute(
|
|
215
|
+
async def execute(
|
|
216
|
+
self, inp: ChargeInput, event_type: str | None = None, event_payload: Any | None = None
|
|
217
|
+
) -> ChargeOutput:
|
|
208
218
|
return ChargeOutput(payment_id=f"pay-{inp.reservation_id}")
|
|
209
219
|
|
|
210
220
|
|
|
@@ -299,7 +309,7 @@ token = await orchestrator.await_event(
|
|
|
299
309
|
)
|
|
300
310
|
```
|
|
301
311
|
|
|
302
|
-
|
|
312
|
+
When a suspended saga is awakened by an event, the orchestrator passes the event directly to the step's `execute` or `compensate` method via the `event_type` and `event_payload` arguments. This strictly separates historical context (`InputModel`) from dynamic asynchronous triggers (`Events`).
|
|
303
313
|
|
|
304
314
|
For distributed consumers, use transactional inbox ingestion first, then process inbox rows:
|
|
305
315
|
|
{python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/_version.py
RENAMED
|
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
|
|
|
18
18
|
commit_id: str | None
|
|
19
19
|
__commit_id__: str | None
|
|
20
20
|
|
|
21
|
-
__version__ = version = '0.
|
|
22
|
-
__version_tuple__ = version_tuple = (0,
|
|
21
|
+
__version__ = version = '0.7.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 7, 0)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|