python-saga-orchestrator 0.6.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.6.0 → python_saga_orchestrator-0.7.0}/PKG-INFO +17 -7
- {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.0}/README.md +16 -6
- {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.0}/examples/common.py +38 -6
- {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.0}/examples/http_and_queue.py +34 -27
- {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.0}/examples/llm_deploy.py +13 -2
- {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.0}/python_saga_orchestrator.egg-info/PKG-INFO +17 -7
- {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/_version.py +2 -2
- {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/core/engine.py +32 -4
- {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/domain/models/step.py +11 -2
- {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.0}/tests/integration/helpers.py +171 -49
- {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.0}/tests/integration/test_core_flow.py +0 -5
- {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.0}/.github/workflows/ci.yml +0 -0
- {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.0}/.github/workflows/publish.yml +0 -0
- {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.0}/.gitignore +0 -0
- {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.0}/Dockerfile +0 -0
- {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.0}/LICENSE +0 -0
- {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.0}/Makefile +0 -0
- {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.0}/docker-compose.yaml +0 -0
- {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.0}/examples/admin_skip.py +0 -0
- {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.0}/examples/compensation_flow.py +0 -0
- {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.0}/examples/retry_recovery.py +0 -0
- {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.0}/pyproject.toml +0 -0
- {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.0}/python_saga_orchestrator.egg-info/SOURCES.txt +0 -0
- {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.0}/python_saga_orchestrator.egg-info/dependency_links.txt +0 -0
- {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.0}/python_saga_orchestrator.egg-info/requires.txt +0 -0
- {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.0}/python_saga_orchestrator.egg-info/top_level.txt +0 -0
- {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/__init__.py +0 -0
- {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/admin/__init__.py +0 -0
- {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/admin/api.py +0 -0
- {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/core/__init__.py +0 -0
- {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/core/builder.py +0 -0
- {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/core/orchestrator.py +0 -0
- {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/core/repository.py +0 -0
- {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/domain/__init__.py +0 -0
- {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/domain/exceptions/__init__.py +0 -0
- {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/domain/exceptions/saga.py +0 -0
- {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/domain/mixins/__init__.py +0 -0
- {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/domain/mixins/saga_state.py +0 -0
- {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/domain/mixins/saga_step_histrory.py +0 -0
- {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/domain/mixins/types.py +0 -0
- {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/domain/models/__init__.py +0 -0
- {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/domain/models/builder.py +0 -0
- {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/domain/models/context.py +0 -0
- {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/domain/models/enums/__init__.py +0 -0
- {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/domain/models/enums/base_str_enum.py +0 -0
- {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/domain/models/enums/saga_status.py +0 -0
- {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/domain/models/enums/saga_step_phase.py +0 -0
- {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/domain/models/enums/saga_step_status.py +0 -0
- {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/domain/models/notify.py +0 -0
- {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/domain/models/retry.py +0 -0
- {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/domain/models/saga_snapshot.py +0 -0
- {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/inbox/__init__.py +0 -0
- {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/inbox/contracts.py +0 -0
- {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/inbox/dispatcher.py +0 -0
- {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/inbox/models.py +0 -0
- {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/inbox/repository.py +0 -0
- {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/inbox/retry.py +0 -0
- {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/outbox/__init__.py +0 -0
- {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/outbox/contracts.py +0 -0
- {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/outbox/dispatcher.py +0 -0
- {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/outbox/event.py +0 -0
- {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/outbox/factory.py +0 -0
- {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/outbox/models.py +0 -0
- {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/outbox/repository.py +0 -0
- {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/outbox/retry.py +0 -0
- {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/outbox/serialization.py +0 -0
- {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.0}/setup.cfg +0 -0
- {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.0}/task.md +0 -0
- {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.0}/tests/__init__.py +0 -0
- {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.0}/tests/conftest.py +0 -0
- {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.0}/tests/integration/__init__.py +0 -0
- {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.0}/tests/integration/conftest.py +0 -0
- {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.0}/tests/integration/models.py +0 -0
- {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.0}/tests/integration/test_admin_api.py +0 -0
- {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.0}/tests/integration/test_compensation_flow.py +0 -0
- {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.0}/tests/integration/test_context_persistence.py +0 -0
- {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.0}/tests/integration/test_inbox_flow.py +0 -0
- {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.0}/tests/integration/test_lifecycle_hooks.py +0 -0
- {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.0}/tests/integration/test_notification_flow.py +0 -0
- {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.0}/tests/integration/test_outbox_flow.py +0 -0
- {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.0}/tests/integration/test_repository.py +0 -0
- {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.0}/tests/unit/__init__.py +0 -0
- {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.0}/tests/unit/test_builder.py +0 -0
- {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.0}/tests/unit/test_inbox_extensibility.py +0 -0
- {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.0}/tests/unit/test_input_context.py +0 -0
- {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.0}/tests/unit/test_orchestrator_helpers.py +0 -0
- {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.0}/tests/unit/test_outbox_extensibility.py +0 -0
- {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.0}/tests/unit/test_retry.py +0 -0
- {python_saga_orchestrator-0.6.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.6.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.6.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
|
{python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/core/engine.py
RENAMED
|
@@ -795,6 +795,8 @@ class SagaEngine(Generic[ModelT, HistoryModelT]):
|
|
|
795
795
|
step_token = prep["step_token"]
|
|
796
796
|
step_input = prep["step_input"]
|
|
797
797
|
attempt_number = prep["attempt_number"]
|
|
798
|
+
event_type = prep["event_type"]
|
|
799
|
+
event_payload = prep["event_payload"]
|
|
798
800
|
|
|
799
801
|
success = False
|
|
800
802
|
step_output: BaseModel | None = None
|
|
@@ -803,10 +805,16 @@ class SagaEngine(Generic[ModelT, HistoryModelT]):
|
|
|
803
805
|
|
|
804
806
|
try:
|
|
805
807
|
if step_def.timeout is None:
|
|
806
|
-
step_result = await step_def.step.execute(
|
|
808
|
+
step_result = await step_def.step.execute(
|
|
809
|
+
step_input, event_type=event_type, event_payload=event_payload
|
|
810
|
+
)
|
|
807
811
|
else:
|
|
808
812
|
step_result = await asyncio.wait_for(
|
|
809
|
-
step_def.step.execute(
|
|
813
|
+
step_def.step.execute(
|
|
814
|
+
step_input,
|
|
815
|
+
event_type=event_type,
|
|
816
|
+
event_payload=event_payload,
|
|
817
|
+
),
|
|
810
818
|
timeout=step_def.timeout.total_seconds(),
|
|
811
819
|
)
|
|
812
820
|
if isinstance(step_result, StepAwaitEvent):
|
|
@@ -861,12 +869,18 @@ class SagaEngine(Generic[ModelT, HistoryModelT]):
|
|
|
861
869
|
)
|
|
862
870
|
attempt_number = saga.retry_counter + 1
|
|
863
871
|
step_input = self._build_step_input(step_def, saga)
|
|
872
|
+
event_payload = saga.context.latest_event
|
|
873
|
+
event_type = None
|
|
874
|
+
if saga.context.latest_event_meta:
|
|
875
|
+
event_type = saga.context.latest_event_meta.get("event_type")
|
|
864
876
|
|
|
865
877
|
return {
|
|
866
878
|
"step_def": step_def,
|
|
867
879
|
"step_token": step_token,
|
|
868
880
|
"step_input": step_input,
|
|
869
881
|
"attempt_number": attempt_number,
|
|
882
|
+
"event_type": event_type,
|
|
883
|
+
"event_payload": event_payload,
|
|
870
884
|
}
|
|
871
885
|
|
|
872
886
|
async def _finalize_step(
|
|
@@ -1068,13 +1082,18 @@ class SagaEngine(Generic[ModelT, HistoryModelT]):
|
|
|
1068
1082
|
original_input = comp_prep["original_input"]
|
|
1069
1083
|
original_output = comp_prep["original_output"]
|
|
1070
1084
|
attempt_number = comp_prep["attempt_number"]
|
|
1085
|
+
event_type = comp_prep["event_type"]
|
|
1086
|
+
event_payload = comp_prep["event_payload"]
|
|
1071
1087
|
|
|
1072
1088
|
error: Exception | None = None
|
|
1073
1089
|
wait_spec: StepAwaitEvent | None = None
|
|
1074
1090
|
|
|
1075
1091
|
try:
|
|
1076
1092
|
comp_result = await step_def.step.compensate(
|
|
1077
|
-
original_input,
|
|
1093
|
+
original_input,
|
|
1094
|
+
original_output,
|
|
1095
|
+
event_type=event_type,
|
|
1096
|
+
event_payload=event_payload,
|
|
1078
1097
|
)
|
|
1079
1098
|
if isinstance(comp_result, StepAwaitEvent):
|
|
1080
1099
|
wait_spec = comp_result
|
|
@@ -1130,6 +1149,12 @@ class SagaEngine(Generic[ModelT, HistoryModelT]):
|
|
|
1130
1149
|
saga.step_execution_token = token
|
|
1131
1150
|
saga.deadline_at = datetime.now(UTC) + self._execution_lease
|
|
1132
1151
|
attempt_number = saga.retry_counter + 1
|
|
1152
|
+
|
|
1153
|
+
event_payload = saga.context.latest_event
|
|
1154
|
+
event_type = None
|
|
1155
|
+
if saga.context.latest_event_meta:
|
|
1156
|
+
event_type = saga.context.latest_event_meta.get("event_type")
|
|
1157
|
+
|
|
1133
1158
|
return {
|
|
1134
1159
|
"step_def": step_def,
|
|
1135
1160
|
"token": token,
|
|
@@ -1140,6 +1165,8 @@ class SagaEngine(Generic[ModelT, HistoryModelT]):
|
|
|
1140
1165
|
"original_output": step_def.output_model.model_validate(
|
|
1141
1166
|
execution_entry.output
|
|
1142
1167
|
),
|
|
1168
|
+
"event_type": event_type,
|
|
1169
|
+
"event_payload": event_payload,
|
|
1143
1170
|
}
|
|
1144
1171
|
|
|
1145
1172
|
async def _finalize_compensation(
|
|
@@ -1198,6 +1225,7 @@ class SagaEngine(Generic[ModelT, HistoryModelT]):
|
|
|
1198
1225
|
saga.status = SagaStatus.COMPENSATING_SUSPENDED
|
|
1199
1226
|
saga.last_error = None
|
|
1200
1227
|
saga.step_execution_token = uuid.uuid4()
|
|
1228
|
+
context.clear_latest_event()
|
|
1201
1229
|
return False
|
|
1202
1230
|
|
|
1203
1231
|
if error is not None:
|
|
@@ -1249,7 +1277,7 @@ class SagaEngine(Generic[ModelT, HistoryModelT]):
|
|
|
1249
1277
|
saga.retry_counter = 0
|
|
1250
1278
|
saga.last_error = None
|
|
1251
1279
|
saga.step_execution_token = uuid.uuid4()
|
|
1252
|
-
|
|
1280
|
+
context.clear_latest_event()
|
|
1253
1281
|
if saga.current_step_index <= 0:
|
|
1254
1282
|
saga.status = SagaStatus.COMPENSATED
|
|
1255
1283
|
saga.last_error = "Compensation completed successfully"
|
|
@@ -194,10 +194,19 @@ class BaseStep(Generic[InputModelT, OutputModelT]):
|
|
|
194
194
|
f"Found await events: {len(await_candidates)}."
|
|
195
195
|
)
|
|
196
196
|
|
|
197
|
-
async def execute(
|
|
197
|
+
async def execute(
|
|
198
|
+
self,
|
|
199
|
+
inp: InputModelT,
|
|
200
|
+
event_type: str | None = None,
|
|
201
|
+
event_payload: Any | None = None,
|
|
202
|
+
) -> OutputModelT | StepAwaitEvent:
|
|
198
203
|
raise NotImplementedError
|
|
199
204
|
|
|
200
205
|
async def compensate(
|
|
201
|
-
self,
|
|
206
|
+
self,
|
|
207
|
+
inp: InputModelT,
|
|
208
|
+
out: OutputModelT,
|
|
209
|
+
event_type: str | None = None,
|
|
210
|
+
event_payload: Any | None = None,
|
|
202
211
|
) -> StepAwaitEvent | None:
|
|
203
212
|
raise NotImplementedError
|