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.
Files changed (90) hide show
  1. python_saga_orchestrator-0.7.0/Makefile +16 -0
  2. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/PKG-INFO +17 -7
  3. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/README.md +16 -6
  4. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/examples/common.py +38 -6
  5. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/examples/http_and_queue.py +34 -27
  6. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/examples/llm_deploy.py +13 -2
  7. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/python_saga_orchestrator.egg-info/PKG-INFO +17 -7
  8. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/_version.py +2 -2
  9. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/core/engine.py +48 -31
  10. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/domain/models/context.py +0 -3
  11. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/domain/models/step.py +11 -2
  12. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/tests/integration/helpers.py +183 -46
  13. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/tests/integration/test_context_persistence.py +8 -3
  14. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/tests/integration/test_core_flow.py +126 -12
  15. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/tests/integration/test_notification_flow.py +4 -3
  16. python_saga_orchestrator-0.5.0/Makefile +0 -12
  17. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/.github/workflows/ci.yml +0 -0
  18. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/.github/workflows/publish.yml +0 -0
  19. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/.gitignore +0 -0
  20. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/Dockerfile +0 -0
  21. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/LICENSE +0 -0
  22. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/docker-compose.yaml +0 -0
  23. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/examples/admin_skip.py +0 -0
  24. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/examples/compensation_flow.py +0 -0
  25. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/examples/retry_recovery.py +0 -0
  26. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/pyproject.toml +0 -0
  27. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/python_saga_orchestrator.egg-info/SOURCES.txt +0 -0
  28. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/python_saga_orchestrator.egg-info/dependency_links.txt +0 -0
  29. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/python_saga_orchestrator.egg-info/requires.txt +0 -0
  30. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/python_saga_orchestrator.egg-info/top_level.txt +0 -0
  31. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/__init__.py +0 -0
  32. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/admin/__init__.py +0 -0
  33. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/admin/api.py +0 -0
  34. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/core/__init__.py +0 -0
  35. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/core/builder.py +0 -0
  36. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/core/orchestrator.py +0 -0
  37. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/core/repository.py +0 -0
  38. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/domain/__init__.py +0 -0
  39. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/domain/exceptions/__init__.py +0 -0
  40. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/domain/exceptions/saga.py +0 -0
  41. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/domain/mixins/__init__.py +0 -0
  42. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/domain/mixins/saga_state.py +0 -0
  43. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/domain/mixins/saga_step_histrory.py +0 -0
  44. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/domain/mixins/types.py +0 -0
  45. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/domain/models/__init__.py +0 -0
  46. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/domain/models/builder.py +0 -0
  47. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/domain/models/enums/__init__.py +0 -0
  48. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/domain/models/enums/base_str_enum.py +0 -0
  49. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/domain/models/enums/saga_status.py +0 -0
  50. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/domain/models/enums/saga_step_phase.py +0 -0
  51. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/domain/models/enums/saga_step_status.py +0 -0
  52. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/domain/models/notify.py +0 -0
  53. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/domain/models/retry.py +0 -0
  54. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/domain/models/saga_snapshot.py +0 -0
  55. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/inbox/__init__.py +0 -0
  56. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/inbox/contracts.py +0 -0
  57. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/inbox/dispatcher.py +0 -0
  58. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/inbox/models.py +0 -0
  59. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/inbox/repository.py +0 -0
  60. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/inbox/retry.py +0 -0
  61. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/outbox/__init__.py +0 -0
  62. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/outbox/contracts.py +0 -0
  63. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/outbox/dispatcher.py +0 -0
  64. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/outbox/event.py +0 -0
  65. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/outbox/factory.py +0 -0
  66. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/outbox/models.py +0 -0
  67. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/outbox/repository.py +0 -0
  68. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/outbox/retry.py +0 -0
  69. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/saga_orchestrator/outbox/serialization.py +0 -0
  70. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/setup.cfg +0 -0
  71. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/task.md +0 -0
  72. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/tests/__init__.py +0 -0
  73. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/tests/conftest.py +0 -0
  74. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/tests/integration/__init__.py +0 -0
  75. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/tests/integration/conftest.py +0 -0
  76. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/tests/integration/models.py +0 -0
  77. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/tests/integration/test_admin_api.py +0 -0
  78. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/tests/integration/test_compensation_flow.py +0 -0
  79. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/tests/integration/test_inbox_flow.py +0 -0
  80. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/tests/integration/test_lifecycle_hooks.py +0 -0
  81. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/tests/integration/test_outbox_flow.py +0 -0
  82. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/tests/integration/test_repository.py +0 -0
  83. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/tests/unit/__init__.py +0 -0
  84. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/tests/unit/test_builder.py +0 -0
  85. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/tests/unit/test_inbox_extensibility.py +0 -0
  86. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/tests/unit/test_input_context.py +0 -0
  87. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/tests/unit/test_orchestrator_helpers.py +0 -0
  88. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/tests/unit/test_outbox_extensibility.py +0 -0
  89. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/tests/unit/test_retry.py +0 -0
  90. {python_saga_orchestrator-0.5.0 → python_saga_orchestrator-0.7.0}/tests/unit/test_step_type_resolution.py +0 -0
@@ -0,0 +1,16 @@
1
+ .PHONY: format tests lint
2
+
3
+ lint: format
4
+ test: tests
5
+
6
+
7
+ format:
8
+ @ruff format .
9
+ @ruff check . --fix
10
+
11
+ tests:
12
+ docker compose up \
13
+ --build \
14
+ --abort-on-container-exit \
15
+ --exit-code-from tests
16
+ docker compose down -v
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-saga-orchestrator
3
- Version: 0.5.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(self, inp: ReserveInput) -> ReserveOutput:
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(self, inp: ReserveInput, out: ReserveOutput) -> None:
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(self, inp: ChargeInput) -> ChargeOutput:
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
- The event payload is stored in saga context and can be used by root-step `input_map` functions through `InputContext`.
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(self, inp: ReserveInput) -> ReserveOutput:
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(self, inp: ReserveInput, out: ReserveOutput) -> None:
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(self, inp: ChargeInput) -> ChargeOutput:
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
- The event payload is stored in saga context and can be used by root-step `input_map` functions through `InputContext`.
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(self, inp: StartInput) -> StartOutput:
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(self, inp: StartInput, out: StartOutput) -> None:
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(self, inp: DeployInput) -> DeployOutput:
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(self, inp: FinalizeInput) -> FinalizeOutput:
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(self, inp: DeployInput) -> DeployOutput:
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(self, inp: StartInput) -> DeployOutput:
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
 
@@ -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(self, inp: PrepareInput) -> PrepareOutput:
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(self, inp: ReserveInput) -> ReserveOutput | StepAwaitEvent:
105
- if inp.response_event_type is None:
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 inp.response_event_type == "reserve.failed":
125
- reason = (inp.response_payload or {}).get("reason", "unknown")
131
+ if event_type == "reserve.failed":
132
+ reason = (event_payload or {}).get("reason", "unknown")
126
133
  raise RuntimeError(f"reserve failed: {reason}")
127
- if inp.response_event_type != "reserve.success":
128
- raise RuntimeError(f"unexpected reserve event: {inp.response_event_type}")
129
- payload = inp.response_payload or {}
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(self, inp: ActivateInput) -> ActivateOutput | StepAwaitEvent:
135
- if inp.response_event_type is None:
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 inp.response_event_type == "activate.failed":
154
- reason = (inp.response_payload or {}).get("reason", "unknown")
166
+ if event_type == "activate.failed":
167
+ reason = (event_payload or {}).get("reason", "unknown")
155
168
  raise RuntimeError(f"activate failed: {reason}")
156
- if inp.response_event_type != "activate.success":
157
- raise RuntimeError(f"unexpected activate event: {inp.response_event_type}")
158
- payload = inp.response_payload or {}
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(self, inp: CheckModelInput) -> CheckModelOutput:
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(self, inp: DeployInput) -> DeployOutput:
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.5.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(self, inp: ReserveInput) -> ReserveOutput:
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(self, inp: ReserveInput, out: ReserveOutput) -> None:
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(self, inp: ChargeInput) -> ChargeOutput:
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
- The event payload is stored in saga context and can be used by root-step `input_map` functions through `InputContext`.
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
 
@@ -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.5.0'
22
- __version_tuple__ = version_tuple = (0, 5, 0)
21
+ __version__ = version = '0.7.0'
22
+ __version_tuple__ = version_tuple = (0, 7, 0)
23
23
 
24
24
  __commit_id__ = commit_id = None