python-saga-orchestrator 0.1.3__py3-none-any.whl → 0.2.3.dev0__py3-none-any.whl

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 (34) hide show
  1. {python_saga_orchestrator-0.1.3.dist-info → python_saga_orchestrator-0.2.3.dev0.dist-info}/METADATA +48 -6
  2. python_saga_orchestrator-0.2.3.dev0.dist-info/RECORD +47 -0
  3. saga_orchestrator/__init__.py +33 -2
  4. saga_orchestrator/_version.py +24 -0
  5. saga_orchestrator/admin/api.py +4 -2
  6. saga_orchestrator/core/builder.py +40 -0
  7. saga_orchestrator/core/engine.py +575 -145
  8. saga_orchestrator/core/orchestrator.py +40 -1
  9. saga_orchestrator/core/repository.py +24 -27
  10. saga_orchestrator/domain/mixins/__init__.py +2 -0
  11. saga_orchestrator/domain/mixins/saga_state.py +26 -21
  12. saga_orchestrator/domain/mixins/saga_step_histrory.py +45 -0
  13. saga_orchestrator/domain/mixins/types.py +78 -0
  14. saga_orchestrator/domain/models/__init__.py +6 -1
  15. saga_orchestrator/domain/models/builder.py +13 -2
  16. saga_orchestrator/domain/models/context.py +126 -0
  17. saga_orchestrator/domain/models/enums/__init__.py +4 -0
  18. saga_orchestrator/domain/models/enums/saga_status.py +5 -3
  19. saga_orchestrator/domain/models/enums/saga_step_phase.py +7 -0
  20. saga_orchestrator/domain/models/enums/saga_step_status.py +7 -0
  21. saga_orchestrator/domain/models/notify.py +1 -0
  22. saga_orchestrator/domain/models/saga_snapshot.py +5 -3
  23. saga_orchestrator/domain/models/step.py +66 -9
  24. saga_orchestrator/inbox/__init__.py +27 -0
  25. saga_orchestrator/inbox/contracts.py +81 -0
  26. saga_orchestrator/inbox/dispatcher.py +120 -0
  27. saga_orchestrator/inbox/models.py +84 -0
  28. saga_orchestrator/inbox/repository.py +165 -0
  29. saga_orchestrator/inbox/retry.py +20 -0
  30. saga_orchestrator/outbox/factory.py +4 -4
  31. python_saga_orchestrator-0.1.3.dist-info/RECORD +0 -35
  32. {python_saga_orchestrator-0.1.3.dist-info → python_saga_orchestrator-0.2.3.dev0.dist-info}/WHEEL +0 -0
  33. {python_saga_orchestrator-0.1.3.dist-info → python_saga_orchestrator-0.2.3.dev0.dist-info}/licenses/LICENSE +0 -0
  34. {python_saga_orchestrator-0.1.3.dist-info → python_saga_orchestrator-0.2.3.dev0.dist-info}/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-saga-orchestrator
3
- Version: 0.1.3
3
+ Version: 0.2.3.dev0
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
@@ -51,6 +51,7 @@ Unlike external workflow platforms, this library runs inside your service and st
51
51
  - persisted saga state through `SagaStateMixin`
52
52
  - runtime execution through `SagaOrchestrator` and `SagaEngine`
53
53
  - retry, timeout, recovery, and compensation
54
+ - async queue-style steps through `StepAwaitEvent` and `notify(...)`
54
55
  - administrative operations through `SagaAdmin`
55
56
  - PostgreSQL-first reliability using `SELECT ... FOR UPDATE`
56
57
 
@@ -136,11 +137,13 @@ Your SQLAlchemy model inherits `SagaStateMixin` to store:
136
137
  ## Quick start
137
138
 
138
139
  ```python
140
+ import uuid
139
141
  from datetime import timedelta
140
142
 
141
143
  from pydantic import BaseModel
144
+ from sqlalchemy import ForeignKey
142
145
  from sqlalchemy.ext.asyncio import async_sessionmaker
143
- from sqlalchemy.orm import DeclarativeBase
146
+ from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
144
147
 
145
148
  from saga_orchestrator import (
146
149
  BaseStep,
@@ -149,6 +152,7 @@ from saga_orchestrator import (
149
152
  SagaBuilder,
150
153
  SagaOrchestrator,
151
154
  SagaStateMixin,
155
+ SagaStepHistoryMixin,
152
156
  )
153
157
 
154
158
 
@@ -156,9 +160,24 @@ class Base(DeclarativeBase):
156
160
  pass
157
161
 
158
162
 
163
+ class OrderSagaHistory(Base, SagaStepHistoryMixin):
164
+ __tablename__ = "order_saga_history"
165
+
166
+ saga_id: Mapped[uuid.UUID] = mapped_column(
167
+ ForeignKey("order_saga_state.id", ondelete="CASCADE"),
168
+ index=True,
169
+ )
170
+
171
+
159
172
  class OrderSagaState(Base, SagaStateMixin):
160
173
  __tablename__ = "order_saga_state"
161
174
 
175
+ step_history: Mapped[list[OrderSagaHistory]] = relationship(
176
+ "OrderSagaHistory",
177
+ cascade="all, delete-orphan",
178
+ order_by="OrderSagaHistory.id",
179
+ )
180
+
162
181
 
163
182
  class ReserveInput(BaseModel):
164
183
  order_id: str
@@ -211,15 +230,20 @@ def build_order_saga():
211
230
 
212
231
 
213
232
  def setup_saga(
214
- session_maker: async_sessionmaker,
215
- ) -> tuple[SagaOrchestrator[OrderSagaState], SagaAdmin[OrderSagaState]]:
216
- orchestrator = SagaOrchestrator[OrderSagaState](
233
+ session_maker: async_sessionmaker,
234
+ ) -> tuple[
235
+ SagaOrchestrator[OrderSagaState, OrderSagaHistory],
236
+ SagaAdmin[OrderSagaState, OrderSagaHistory]
237
+ ]:
238
+ orchestrator = SagaOrchestrator[OrderSagaState, OrderSagaHistory](
217
239
  model_class=OrderSagaState,
240
+ history_model_class=OrderSagaHistory,
218
241
  session_maker=session_maker,
219
242
  )
220
243
  orchestrator.register("create_order_v1", build_order_saga())
221
244
 
222
- admin = SagaAdmin[OrderSagaState](engine=orchestrator.engine)
245
+ admin = SagaAdmin[OrderSagaState, OrderSagaHistory](engine=orchestrator.engine)
246
+
223
247
  return orchestrator, admin
224
248
  ```
225
249
 
@@ -277,6 +301,23 @@ token = await orchestrator.await_event(
277
301
 
278
302
  The event payload is stored in saga context and can be used by root-step `input_map` functions through `InputContext`.
279
303
 
304
+ For distributed consumers, use transactional inbox ingestion first, then process inbox rows:
305
+
306
+ ```python
307
+ stored = await orchestrator.ingest_event(
308
+ aggregation_id="order-123",
309
+ event={
310
+ "event_id": "evt-123",
311
+ "event_type": "payment.completed",
312
+ "correlation_id": "corr-123",
313
+ "payload": {"payment_id": "pay-1"},
314
+ },
315
+ )
316
+
317
+ if stored:
318
+ await orchestrator.run_inbox_due(limit=100)
319
+ ```
320
+
280
321
  ## Administrative operations
281
322
 
282
323
  Get the full persisted state:
@@ -334,6 +375,7 @@ A runnable end-to-end example is available in:
334
375
  - [`examples/retry_recovery.py`](./examples/retry_recovery.py)
335
376
  - [`examples/compensation_flow.py`](./examples/compensation_flow.py)
336
377
  - [`examples/admin_skip.py`](./examples/admin_skip.py)
378
+ - [`examples/http_and_queue.py`](./examples/http_and_queue.py)
337
379
 
338
380
  These examples demonstrate:
339
381
  - basic model deployment
@@ -0,0 +1,47 @@
1
+ python_saga_orchestrator-0.2.3.dev0.dist-info/licenses/LICENSE,sha256=ESYyLizI0WWtxMeS7rGVcX3ivMezm-HOd5WdeOh-9oU,1056
2
+ saga_orchestrator/__init__.py,sha256=FG7zoYhCzpZC_JEsy3ebDraH2ZZ9q3IGIjTGKqBzcF4,2836
3
+ saga_orchestrator/_version.py,sha256=b8lE-OZpo51B0ODVlQA6zcNoIpJ5YIH4JrzxWeFFTE0,533
4
+ saga_orchestrator/admin/__init__.py,sha256=TKwKTM7IieI4nlMAbJ0O0OI0KPKfwbclVffNjRtIyAg,80
5
+ saga_orchestrator/admin/api.py,sha256=u_eLELUlaHpEiuHqweNHzBwSYCtotERAiOxyVtUMe2I,1715
6
+ saga_orchestrator/core/__init__.py,sha256=EsUqhbO_CgCYZz0yBnf6OUXH3N-_uxMod4A7yGzvbMY,264
7
+ saga_orchestrator/core/builder.py,sha256=5G1kdA9UoMqZy7IT81YGz2FX1xkK9JPBhF9OMGqvrQs,5931
8
+ saga_orchestrator/core/engine.py,sha256=ntloRjKod4zl9iQmt1zEOYBcm8KJPqP1FgDcPN33XPU,59879
9
+ saga_orchestrator/core/orchestrator.py,sha256=5HvesuKh1STx4H2aSH8cQSlaFTmdQ_Y0aFBzuGN-6vw,6197
10
+ saga_orchestrator/core/repository.py,sha256=TWBuhc0lLRpR6O2NvdyqhuD2RxgnLbG_JcKFxfc4yVA,5698
11
+ saga_orchestrator/domain/__init__.py,sha256=ECVisQXiPSwx974Dbei_Ze_6SWqPVaQrTZPj_qESpYA,21
12
+ saga_orchestrator/domain/exceptions/__init__.py,sha256=smKhoR_G-PLtqvDkxgzCzDE7zfoztpUoFc0x4prdX4U,334
13
+ saga_orchestrator/domain/exceptions/saga.py,sha256=GeqxHJkHxobM8e8sVq69skP75_Sklo6mqFS9gFvTx4Y,567
14
+ saga_orchestrator/domain/mixins/__init__.py,sha256=ibsuLmZf0g8zAnQWs2MpDOPA0b0poptpEhMDnmbAXsI,186
15
+ saga_orchestrator/domain/mixins/saga_state.py,sha256=Gc7cozW0dHzKSJoP9-yTusof0H__INQ3BUxBO6Z72l0,2334
16
+ saga_orchestrator/domain/mixins/saga_step_histrory.py,sha256=phB_oue8ksMMtDiNSERP3r_KGIQYMwH7Ahob0NbGD4A,1975
17
+ saga_orchestrator/domain/mixins/types.py,sha256=FlKee4yNq6y1lXX3W_SeE385udNNg-oZqMxqVXZoEpo,2526
18
+ saga_orchestrator/domain/models/__init__.py,sha256=vgUSmwVdPhKNijFZsfmZ7mQZD8DJgCV4s4hMVQZNlwE,853
19
+ saga_orchestrator/domain/models/builder.py,sha256=D3mVYEJT7QJ0e2dNrR2UNHT5xhRK4te6s5WchU57EIA,816
20
+ saga_orchestrator/domain/models/context.py,sha256=a0nNYTW0S73Qk5ln85Ji9_siEe38qlb9GPH8XpL0Gi8,3886
21
+ saga_orchestrator/domain/models/notify.py,sha256=SuZILSFNAWcUQtjL-I_c4K6VFJXXhD5LLLC2KoMq8dw,837
22
+ saga_orchestrator/domain/models/retry.py,sha256=UM2ZrSGKDZRiPMj0qOGp36xiTr78CVKsceXFFxtiOug,1362
23
+ saga_orchestrator/domain/models/saga_snapshot.py,sha256=ysnBVB2rw059pC07Py_xzAA-Bv7d1h6ZZuubM_Fv0_4,867
24
+ saga_orchestrator/domain/models/step.py,sha256=-4hWbH2xZpGSI-xFQYHI1SoucXe4Xyq5vQ3t0DH0rQ4,4854
25
+ saga_orchestrator/domain/models/enums/__init__.py,sha256=IFgCFvhHiQl1MSCN_I_w3O8yR94tZvN5Az8M3V3bXEQ,234
26
+ saga_orchestrator/domain/models/enums/saga_status.py,sha256=Evz7Uy4-KrqGWFkb6RHhONCWLePd5Gjezw5u-FLmxw0,397
27
+ saga_orchestrator/domain/models/enums/saga_step_phase.py,sha256=L73jg8q-IdDXPpMjuMRwhfVZbAMYW_42FkdXNKqwiqI,129
28
+ saga_orchestrator/domain/models/enums/saga_step_status.py,sha256=DZN5qgDorhdFbhI6t6WXAu4xMpj7DE53OJ707wqj4RM,125
29
+ saga_orchestrator/inbox/__init__.py,sha256=uoLytCCT-2-zr369ZpGmQOB_D63fmwjV9T6sCHYGk64,656
30
+ saga_orchestrator/inbox/contracts.py,sha256=unV7h1Eqil6sZhmpGVE_-9c_voRQhYA3KdE63T79s4U,1768
31
+ saga_orchestrator/inbox/dispatcher.py,sha256=O4Zzi5fRAZIUQqlk3oJof6rl7_CPzvqSVQ_Un-1WkOY,4323
32
+ saga_orchestrator/inbox/models.py,sha256=i6ANKTqDTanmpA2hp5V5pky06u5DoHfo2oCWCsTVQUI,2654
33
+ saga_orchestrator/inbox/repository.py,sha256=BdcVlBW9zvM7WrsL2S3Rmihmn-Ri6gr69L8ji7qqip8,5286
34
+ saga_orchestrator/inbox/retry.py,sha256=jTxg6tZBl7Vllt89Yw-QdOSObYWXrZWjAyW36mCJHdg,590
35
+ saga_orchestrator/outbox/__init__.py,sha256=g-isr2D737wMV1Mf_vEPFoS34YE6n6l09iKLLAL86uU,917
36
+ saga_orchestrator/outbox/contracts.py,sha256=KhqIDIDvwA26xu4VQJpU6qg2ySmsRAORestIk1JqpiY,2028
37
+ saga_orchestrator/outbox/dispatcher.py,sha256=BX9WTuTLx1gfu-5jV8_ejpi9nnEilnKLYY_BljJkxC8,3196
38
+ saga_orchestrator/outbox/event.py,sha256=Kj-u_JO55jx3YMTpA9arIvmTgKEAxhH2-WQE1eTi4KU,273
39
+ saga_orchestrator/outbox/factory.py,sha256=_p7XT_ulouBhl9J9fcgWsZkhkjmzkpfIs9m6E55UPdA,1743
40
+ saga_orchestrator/outbox/models.py,sha256=ei8Q1b9NnjAWq5qm0vAIeuj22yWVOFx0UsqGszZA0Q0,2355
41
+ saga_orchestrator/outbox/repository.py,sha256=A3nCi5UOwQT9mlEY03BrLidyO2sFOQGgYcE4pFeY-pA,4786
42
+ saga_orchestrator/outbox/retry.py,sha256=9Ygz3I0HK0r9imTYevBgz14TkAZphKiOSf0grJpH99c,599
43
+ saga_orchestrator/outbox/serialization.py,sha256=CDicJS95CHhLP47XukJAgfm0baZ83daWXQQF3MyczDo,1687
44
+ python_saga_orchestrator-0.2.3.dev0.dist-info/METADATA,sha256=TKDQ42mOx7sTrACtJtxs60bcDE0AhWQNH8JZz-6c7Lo,10293
45
+ python_saga_orchestrator-0.2.3.dev0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
46
+ python_saga_orchestrator-0.2.3.dev0.dist-info/top_level.txt,sha256=XBp_2J8dacJGCoVxIDaUYhSEuOusCN3BD_uhEjBEEBA,18
47
+ python_saga_orchestrator-0.2.3.dev0.dist-info/RECORD,,
@@ -9,7 +9,7 @@ from .domain.exceptions import (
9
9
  SagaStateError,
10
10
  TypeValidationError,
11
11
  )
12
- from .domain.mixins import SagaStateMixin
12
+ from .domain.mixins import SagaStateMixin, SagaStepHistoryMixin
13
13
  from .domain.models import (
14
14
  AwaitingEvent,
15
15
  BaseStep,
@@ -24,11 +24,26 @@ from .domain.models import (
24
24
  SagaAdminSnapshot,
25
25
  SagaDefinition,
26
26
  SagaSnapshot,
27
+ StepAwaitEvent,
27
28
  StepDefinition,
28
29
  StepInputMap,
29
30
  StepRef,
30
31
  )
31
- from .domain.models.enums import SagaStatus
32
+ from .domain.models.enums import SagaStatus, SagaStepPhase, SagaStepStatus
33
+ from .inbox import (
34
+ ClaimedInboxMessage,
35
+ FixedInboxRetry,
36
+ InboxDispatcher,
37
+ InboxMessageMixin,
38
+ InboxProcessor,
39
+ InboxProcessOutcome,
40
+ InboxProcessStatus,
41
+ InboxRepository,
42
+ InboxRetryPolicy,
43
+ InboxStatus,
44
+ InboxWriteMessage,
45
+ InboxWriter,
46
+ )
32
47
  from .outbox import (
33
48
  ClaimedOutboxMessage,
34
49
  DefaultOutboxMessageFactory,
@@ -51,11 +66,23 @@ __all__ = [
51
66
  "ActiveSagaAlreadyExistsError",
52
67
  "AwaitingEvent",
53
68
  "BaseStep",
69
+ "ClaimedInboxMessage",
54
70
  "ClaimedOutboxMessage",
55
71
  "DefaultOutboxMessageFactory",
56
72
  "ExponentialRetry",
73
+ "FixedInboxRetry",
57
74
  "FixedOutboxDispatchRetry",
58
75
  "FixedRetry",
76
+ "InboxDispatcher",
77
+ "InboxMessageMixin",
78
+ "InboxProcessOutcome",
79
+ "InboxProcessStatus",
80
+ "InboxProcessor",
81
+ "InboxRepository",
82
+ "InboxRetryPolicy",
83
+ "InboxStatus",
84
+ "InboxWriteMessage",
85
+ "InboxWriter",
59
86
  "InputContext",
60
87
  "JsonOutboxSerializer",
61
88
  "NotifyEvent",
@@ -85,9 +112,13 @@ __all__ = [
85
112
  "SagaRepository",
86
113
  "SagaSnapshot",
87
114
  "SagaStateError",
115
+ "SagaStepHistoryMixin",
88
116
  "SagaStateMixin",
89
117
  "SagaStatus",
118
+ "SagaStepStatus",
119
+ "SagaStepPhase",
90
120
  "StepDefinition",
121
+ "StepAwaitEvent",
91
122
  "StepInputMap",
92
123
  "StepRef",
93
124
  "TypeValidationError",
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.2.3.dev0'
22
+ __version_tuple__ = version_tuple = (0, 2, 3, 'dev0')
23
+
24
+ __commit_id__ = commit_id = None
@@ -7,17 +7,19 @@ from pydantic import BaseModel
7
7
 
8
8
  from ..core.engine import SagaEngine
9
9
  from ..domain.mixins import SagaStateMixin
10
+ from ..domain.mixins.saga_step_histrory import SagaStepHistoryMixin
10
11
  from ..domain.models import SagaAdminSnapshot
11
12
 
12
13
  ModelT = TypeVar("ModelT", bound=SagaStateMixin)
14
+ HistoryModelT = TypeVar("HistoryModelT", bound=SagaStepHistoryMixin)
13
15
 
14
16
 
15
- class SagaAdmin(Generic[ModelT]):
17
+ class SagaAdmin(Generic[ModelT, HistoryModelT]):
16
18
  """Provide administrative operations for persisted saga instances."""
17
19
 
18
20
  def __init__(
19
21
  self,
20
- engine: SagaEngine[ModelT],
22
+ engine: SagaEngine[ModelT, HistoryModelT],
21
23
  ) -> None:
22
24
  """Initialize the admin API facade."""
23
25
  self._engine = engine
@@ -11,6 +11,9 @@ from ..domain.models import (
11
11
  BaseStep,
12
12
  InputContext,
13
13
  NoRetry,
14
+ OnFailedMap,
15
+ OnStartMap,
16
+ OnTerminalStateMap,
14
17
  OutboxMap,
15
18
  RetryPolicy,
16
19
  SagaDefinition,
@@ -28,6 +31,11 @@ class SagaBuilder:
28
31
  self._steps: list[StepDefinition[Any, Any]] = []
29
32
  self._compensate_on_failure = compensate_on_failure
30
33
 
34
+ self._on_start_map: OnStartMap | None = None
35
+ self._on_completed_map: OnTerminalStateMap | None = None
36
+ self._on_failed_map: OnFailedMap | None = None
37
+ self._on_compensated_map: OnTerminalStateMap | None = None
38
+
31
39
  def add_step(
32
40
  self,
33
41
  *,
@@ -62,6 +70,34 @@ class SagaBuilder:
62
70
  self._steps.append(definition)
63
71
  return StepRef(step_id=normalized_step_id, output_model=step.output_model)
64
72
 
73
+ def on_start(self, on_start_map: OnStartMap) -> SagaBuilder:
74
+ """Define actions to be taken when the saga starts."""
75
+ if not callable(on_start_map):
76
+ raise SagaDefinitionError("on_start_map must be callable")
77
+ self._on_start_map = on_start_map
78
+ return self
79
+
80
+ def on_completed(self, on_completed_map: OnTerminalStateMap) -> SagaBuilder:
81
+ """Define actions to be taken when the saga completes successfully."""
82
+ if not callable(on_completed_map):
83
+ raise SagaDefinitionError("on_completed_map must be callable")
84
+ self._on_completed_map = on_completed_map
85
+ return self
86
+
87
+ def on_failed(self, on_failed_map: OnFailedMap) -> SagaBuilder:
88
+ """Define actions to be taken when the saga fails."""
89
+ if not callable(on_failed_map):
90
+ raise SagaDefinitionError("on_failed_map must be callable")
91
+ self._on_failed_map = on_failed_map
92
+ return self
93
+
94
+ def on_compensated(self, on_compensated_map: OnTerminalStateMap) -> SagaBuilder:
95
+ """Define actions to be taken when the saga is fully compensated."""
96
+ if not callable(on_compensated_map):
97
+ raise SagaDefinitionError("on_compensated_map must be callable")
98
+ self._on_compensated_map = on_compensated_map
99
+ return self
100
+
65
101
  def build(self) -> SagaDefinition:
66
102
  """Return the final saga definition."""
67
103
  if not self._steps:
@@ -69,6 +105,10 @@ class SagaBuilder:
69
105
  return SagaDefinition(
70
106
  steps=tuple(self._steps),
71
107
  compensate_on_failure=self._compensate_on_failure,
108
+ on_start_map=self._on_start_map,
109
+ on_completed_map=self._on_completed_map,
110
+ on_failed_map=self._on_failed_map,
111
+ on_compensated_map=self._on_compensated_map,
72
112
  )
73
113
 
74
114
  @staticmethod