python-saga-orchestrator 0.6.0__tar.gz → 0.7.1__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 (89) hide show
  1. {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.1}/Makefile +1 -1
  2. {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.1}/PKG-INFO +17 -7
  3. {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.1}/README.md +16 -6
  4. {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.1}/examples/common.py +38 -6
  5. {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.1}/examples/http_and_queue.py +34 -27
  6. {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.1}/examples/llm_deploy.py +13 -2
  7. {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.1}/python_saga_orchestrator.egg-info/PKG-INFO +17 -7
  8. {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.1}/saga_orchestrator/_version.py +2 -2
  9. {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.1}/saga_orchestrator/core/engine.py +33 -4
  10. {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.1}/saga_orchestrator/domain/models/step.py +11 -2
  11. {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.1}/tests/integration/helpers.py +231 -49
  12. {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.1}/tests/integration/test_core_flow.py +63 -5
  13. {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.1}/.github/workflows/ci.yml +0 -0
  14. {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.1}/.github/workflows/publish.yml +0 -0
  15. {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.1}/.gitignore +0 -0
  16. {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.1}/Dockerfile +0 -0
  17. {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.1}/LICENSE +0 -0
  18. {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.1}/docker-compose.yaml +0 -0
  19. {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.1}/examples/admin_skip.py +0 -0
  20. {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.1}/examples/compensation_flow.py +0 -0
  21. {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.1}/examples/retry_recovery.py +0 -0
  22. {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.1}/pyproject.toml +0 -0
  23. {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.1}/python_saga_orchestrator.egg-info/SOURCES.txt +0 -0
  24. {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.1}/python_saga_orchestrator.egg-info/dependency_links.txt +0 -0
  25. {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.1}/python_saga_orchestrator.egg-info/requires.txt +0 -0
  26. {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.1}/python_saga_orchestrator.egg-info/top_level.txt +0 -0
  27. {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.1}/saga_orchestrator/__init__.py +0 -0
  28. {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.1}/saga_orchestrator/admin/__init__.py +0 -0
  29. {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.1}/saga_orchestrator/admin/api.py +0 -0
  30. {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.1}/saga_orchestrator/core/__init__.py +0 -0
  31. {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.1}/saga_orchestrator/core/builder.py +0 -0
  32. {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.1}/saga_orchestrator/core/orchestrator.py +0 -0
  33. {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.1}/saga_orchestrator/core/repository.py +0 -0
  34. {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.1}/saga_orchestrator/domain/__init__.py +0 -0
  35. {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.1}/saga_orchestrator/domain/exceptions/__init__.py +0 -0
  36. {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.1}/saga_orchestrator/domain/exceptions/saga.py +0 -0
  37. {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.1}/saga_orchestrator/domain/mixins/__init__.py +0 -0
  38. {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.1}/saga_orchestrator/domain/mixins/saga_state.py +0 -0
  39. {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.1}/saga_orchestrator/domain/mixins/saga_step_histrory.py +0 -0
  40. {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.1}/saga_orchestrator/domain/mixins/types.py +0 -0
  41. {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.1}/saga_orchestrator/domain/models/__init__.py +0 -0
  42. {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.1}/saga_orchestrator/domain/models/builder.py +0 -0
  43. {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.1}/saga_orchestrator/domain/models/context.py +0 -0
  44. {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.1}/saga_orchestrator/domain/models/enums/__init__.py +0 -0
  45. {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.1}/saga_orchestrator/domain/models/enums/base_str_enum.py +0 -0
  46. {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.1}/saga_orchestrator/domain/models/enums/saga_status.py +0 -0
  47. {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.1}/saga_orchestrator/domain/models/enums/saga_step_phase.py +0 -0
  48. {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.1}/saga_orchestrator/domain/models/enums/saga_step_status.py +0 -0
  49. {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.1}/saga_orchestrator/domain/models/notify.py +0 -0
  50. {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.1}/saga_orchestrator/domain/models/retry.py +0 -0
  51. {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.1}/saga_orchestrator/domain/models/saga_snapshot.py +0 -0
  52. {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.1}/saga_orchestrator/inbox/__init__.py +0 -0
  53. {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.1}/saga_orchestrator/inbox/contracts.py +0 -0
  54. {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.1}/saga_orchestrator/inbox/dispatcher.py +0 -0
  55. {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.1}/saga_orchestrator/inbox/models.py +0 -0
  56. {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.1}/saga_orchestrator/inbox/repository.py +0 -0
  57. {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.1}/saga_orchestrator/inbox/retry.py +0 -0
  58. {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.1}/saga_orchestrator/outbox/__init__.py +0 -0
  59. {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.1}/saga_orchestrator/outbox/contracts.py +0 -0
  60. {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.1}/saga_orchestrator/outbox/dispatcher.py +0 -0
  61. {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.1}/saga_orchestrator/outbox/event.py +0 -0
  62. {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.1}/saga_orchestrator/outbox/factory.py +0 -0
  63. {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.1}/saga_orchestrator/outbox/models.py +0 -0
  64. {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.1}/saga_orchestrator/outbox/repository.py +0 -0
  65. {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.1}/saga_orchestrator/outbox/retry.py +0 -0
  66. {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.1}/saga_orchestrator/outbox/serialization.py +0 -0
  67. {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.1}/setup.cfg +0 -0
  68. {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.1}/task.md +0 -0
  69. {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.1}/tests/__init__.py +0 -0
  70. {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.1}/tests/conftest.py +0 -0
  71. {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.1}/tests/integration/__init__.py +0 -0
  72. {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.1}/tests/integration/conftest.py +0 -0
  73. {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.1}/tests/integration/models.py +0 -0
  74. {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.1}/tests/integration/test_admin_api.py +0 -0
  75. {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.1}/tests/integration/test_compensation_flow.py +0 -0
  76. {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.1}/tests/integration/test_context_persistence.py +0 -0
  77. {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.1}/tests/integration/test_inbox_flow.py +0 -0
  78. {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.1}/tests/integration/test_lifecycle_hooks.py +0 -0
  79. {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.1}/tests/integration/test_notification_flow.py +0 -0
  80. {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.1}/tests/integration/test_outbox_flow.py +0 -0
  81. {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.1}/tests/integration/test_repository.py +0 -0
  82. {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.1}/tests/unit/__init__.py +0 -0
  83. {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.1}/tests/unit/test_builder.py +0 -0
  84. {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.1}/tests/unit/test_inbox_extensibility.py +0 -0
  85. {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.1}/tests/unit/test_input_context.py +0 -0
  86. {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.1}/tests/unit/test_orchestrator_helpers.py +0 -0
  87. {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.1}/tests/unit/test_outbox_extensibility.py +0 -0
  88. {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.1}/tests/unit/test_retry.py +0 -0
  89. {python_saga_orchestrator-0.6.0 → python_saga_orchestrator-0.7.1}/tests/unit/test_step_type_resolution.py +0 -0
@@ -13,4 +13,4 @@ tests:
13
13
  --build \
14
14
  --abort-on-container-exit \
15
15
  --exit-code-from tests
16
- docker compose down -v
16
+ docker compose down -v --rmi local
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-saga-orchestrator
3
- Version: 0.6.0
3
+ Version: 0.7.1
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.6.0
3
+ Version: 0.7.1
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.6.0'
22
- __version_tuple__ = version_tuple = (0, 6, 0)
21
+ __version__ = version = '0.7.1'
22
+ __version_tuple__ = version_tuple = (0, 7, 1)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -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(step_input)
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(step_input),
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(
@@ -1032,6 +1046,7 @@ class SagaEngine(Generic[ModelT, HistoryModelT]):
1032
1046
  )
1033
1047
 
1034
1048
  saga.last_error = repr(error)
1049
+ context.clear_latest_event()
1035
1050
  next_attempt = saga.retry_counter + 1
1036
1051
  delay = step_def.retry_policy.next_delay(next_attempt)
1037
1052
  saga.retry_counter = next_attempt
@@ -1068,13 +1083,18 @@ class SagaEngine(Generic[ModelT, HistoryModelT]):
1068
1083
  original_input = comp_prep["original_input"]
1069
1084
  original_output = comp_prep["original_output"]
1070
1085
  attempt_number = comp_prep["attempt_number"]
1086
+ event_type = comp_prep["event_type"]
1087
+ event_payload = comp_prep["event_payload"]
1071
1088
 
1072
1089
  error: Exception | None = None
1073
1090
  wait_spec: StepAwaitEvent | None = None
1074
1091
 
1075
1092
  try:
1076
1093
  comp_result = await step_def.step.compensate(
1077
- original_input, original_output
1094
+ original_input,
1095
+ original_output,
1096
+ event_type=event_type,
1097
+ event_payload=event_payload,
1078
1098
  )
1079
1099
  if isinstance(comp_result, StepAwaitEvent):
1080
1100
  wait_spec = comp_result
@@ -1130,6 +1150,12 @@ class SagaEngine(Generic[ModelT, HistoryModelT]):
1130
1150
  saga.step_execution_token = token
1131
1151
  saga.deadline_at = datetime.now(UTC) + self._execution_lease
1132
1152
  attempt_number = saga.retry_counter + 1
1153
+
1154
+ event_payload = saga.context.latest_event
1155
+ event_type = None
1156
+ if saga.context.latest_event_meta:
1157
+ event_type = saga.context.latest_event_meta.get("event_type")
1158
+
1133
1159
  return {
1134
1160
  "step_def": step_def,
1135
1161
  "token": token,
@@ -1140,6 +1166,8 @@ class SagaEngine(Generic[ModelT, HistoryModelT]):
1140
1166
  "original_output": step_def.output_model.model_validate(
1141
1167
  execution_entry.output
1142
1168
  ),
1169
+ "event_type": event_type,
1170
+ "event_payload": event_payload,
1143
1171
  }
1144
1172
 
1145
1173
  async def _finalize_compensation(
@@ -1198,6 +1226,7 @@ class SagaEngine(Generic[ModelT, HistoryModelT]):
1198
1226
  saga.status = SagaStatus.COMPENSATING_SUSPENDED
1199
1227
  saga.last_error = None
1200
1228
  saga.step_execution_token = uuid.uuid4()
1229
+ context.clear_latest_event()
1201
1230
  return False
1202
1231
 
1203
1232
  if error is not None:
@@ -1249,7 +1278,7 @@ class SagaEngine(Generic[ModelT, HistoryModelT]):
1249
1278
  saga.retry_counter = 0
1250
1279
  saga.last_error = None
1251
1280
  saga.step_execution_token = uuid.uuid4()
1252
-
1281
+ context.clear_latest_event()
1253
1282
  if saga.current_step_index <= 0:
1254
1283
  saga.status = SagaStatus.COMPENSATED
1255
1284
  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(self, inp: InputModelT) -> OutputModelT | StepAwaitEvent:
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, inp: InputModelT, out: OutputModelT
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