python-cqrs 4.7.1__tar.gz → 4.7.3__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_cqrs-4.7.1/src/python_cqrs.egg-info → python_cqrs-4.7.3}/PKG-INFO +1 -1
- {python_cqrs-4.7.1 → python_cqrs-4.7.3}/pyproject.toml +1 -4
- {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/dispatcher/saga.py +9 -1
- {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/dispatcher/streaming.py +9 -4
- {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/mediator.py +18 -5
- {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/requests/request_handler.py +7 -1
- {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/saga/bootstrap.py +1 -1
- {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/saga/storage/memory.py +14 -0
- python_cqrs-4.7.3/src/cqrs/saga/storage/protocol.py +216 -0
- {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/saga/storage/sqlalchemy.py +25 -0
- {python_cqrs-4.7.1 → python_cqrs-4.7.3/src/python_cqrs.egg-info}/PKG-INFO +1 -1
- python_cqrs-4.7.1/src/cqrs/saga/storage/protocol.py +0 -113
- {python_cqrs-4.7.1 → python_cqrs-4.7.3}/LICENSE +0 -0
- {python_cqrs-4.7.1 → python_cqrs-4.7.3}/README.md +0 -0
- {python_cqrs-4.7.1 → python_cqrs-4.7.3}/setup.cfg +0 -0
- {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/__init__.py +0 -0
- {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/adapters/__init__.py +0 -0
- {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/adapters/amqp.py +0 -0
- {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/adapters/circuit_breaker.py +0 -0
- {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/adapters/kafka.py +0 -0
- {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/adapters/protocol.py +0 -0
- {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/compressors/__init__.py +0 -0
- {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/compressors/protocol.py +0 -0
- {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/compressors/zlib.py +0 -0
- {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/container/__init__.py +0 -0
- {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/container/dependency_injector.py +0 -0
- {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/container/di.py +0 -0
- {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/container/protocol.py +0 -0
- {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/deserializers/__init__.py +0 -0
- {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/deserializers/exceptions.py +0 -0
- {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/deserializers/json.py +0 -0
- {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/dispatcher/__init__.py +0 -0
- {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/dispatcher/event.py +0 -0
- {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/dispatcher/exceptions.py +0 -0
- {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/dispatcher/models.py +0 -0
- {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/dispatcher/request.py +0 -0
- {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/events/__init__.py +0 -0
- {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/events/bootstrap.py +0 -0
- {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/events/event.py +0 -0
- {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/events/event_emitter.py +0 -0
- {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/events/event_handler.py +0 -0
- {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/events/event_processor.py +0 -0
- {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/events/map.py +0 -0
- {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/message_brokers/__init__.py +0 -0
- {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/message_brokers/amqp.py +0 -0
- {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/message_brokers/devnull.py +0 -0
- {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/message_brokers/kafka.py +0 -0
- {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/message_brokers/protocol.py +0 -0
- {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/middlewares/__init__.py +0 -0
- {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/middlewares/base.py +0 -0
- {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/middlewares/logging.py +0 -0
- {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/outbox/__init__.py +0 -0
- {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/outbox/map.py +0 -0
- {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/outbox/mock.py +0 -0
- {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/outbox/repository.py +0 -0
- {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/outbox/sqlalchemy.py +0 -0
- {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/producer.py +0 -0
- {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/requests/__init__.py +0 -0
- {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/requests/bootstrap.py +0 -0
- {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/requests/cor_request_handler.py +0 -0
- {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/requests/map.py +0 -0
- {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/requests/mermaid.py +0 -0
- {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/requests/request.py +0 -0
- {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/response.py +0 -0
- {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/saga/__init__.py +0 -0
- {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/saga/circuit_breaker.py +0 -0
- {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/saga/compensation.py +0 -0
- {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/saga/execution.py +0 -0
- {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/saga/fallback.py +0 -0
- {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/saga/mermaid.py +0 -0
- {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/saga/models.py +0 -0
- {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/saga/recovery.py +0 -0
- {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/saga/saga.py +0 -0
- {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/saga/step.py +0 -0
- {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/saga/storage/__init__.py +0 -0
- {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/saga/storage/enums.py +0 -0
- {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/saga/storage/models.py +0 -0
- {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/saga/validation.py +0 -0
- {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/serializers/__init__.py +0 -0
- {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/serializers/default.py +0 -0
- {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/types.py +0 -0
- {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/python_cqrs.egg-info/SOURCES.txt +0 -0
- {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/python_cqrs.egg-info/dependency_links.txt +0 -0
- {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/python_cqrs.egg-info/requires.txt +0 -0
- {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/python_cqrs.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-cqrs
|
|
3
|
-
Version: 4.7.
|
|
3
|
+
Version: 4.7.3
|
|
4
4
|
Summary: Python CQRS pattern implementation
|
|
5
5
|
Author-email: Vadim Kozyrevskiy <vadikko2@mail.ru>, Dmitry Kutlubaev <kutlubaev00@mail.ru>
|
|
6
6
|
Maintainer-email: Vadim Kozyrevskiy <vadikko2@mail.ru>
|
|
@@ -31,7 +31,7 @@ maintainers = [{name = "Vadim Kozyrevskiy", email = "vadikko2@mail.ru"}]
|
|
|
31
31
|
name = "python-cqrs"
|
|
32
32
|
readme = "README.md"
|
|
33
33
|
requires-python = ">=3.10"
|
|
34
|
-
version = "4.7.
|
|
34
|
+
version = "4.7.3"
|
|
35
35
|
|
|
36
36
|
[project.optional-dependencies]
|
|
37
37
|
aiobreaker = ["aiobreaker>=0.3.0"]
|
|
@@ -79,8 +79,5 @@ asyncio_mode = "auto"
|
|
|
79
79
|
junit_family = "xunit1"
|
|
80
80
|
testpaths = ["tests"]
|
|
81
81
|
|
|
82
|
-
[tool.ruff]
|
|
83
|
-
target-version = "py310"
|
|
84
|
-
|
|
85
82
|
[tool.setuptools.packages.find]
|
|
86
83
|
where = ["src"]
|
|
@@ -53,7 +53,7 @@ class SagaDispatcher:
|
|
|
53
53
|
self._compensation_retry_delay = compensation_retry_delay
|
|
54
54
|
self._compensation_retry_backoff = compensation_retry_backoff
|
|
55
55
|
|
|
56
|
-
|
|
56
|
+
def dispatch(
|
|
57
57
|
self,
|
|
58
58
|
context: SagaContext,
|
|
59
59
|
saga_id: uuid.UUID | None = None,
|
|
@@ -61,6 +61,7 @@ class SagaDispatcher:
|
|
|
61
61
|
"""
|
|
62
62
|
Dispatch a saga execution for the given context.
|
|
63
63
|
|
|
64
|
+
Called without await; returns an AsyncIterator consumed with async for.
|
|
64
65
|
Yields result after each step execution. After each yield, events are collected
|
|
65
66
|
and included in the dispatch result.
|
|
66
67
|
|
|
@@ -75,6 +76,13 @@ class SagaDispatcher:
|
|
|
75
76
|
Raises:
|
|
76
77
|
SagaDoesNotExist: If no saga is registered for the context type
|
|
77
78
|
"""
|
|
79
|
+
return self._dispatch_impl(context, saga_id=saga_id)
|
|
80
|
+
|
|
81
|
+
async def _dispatch_impl(
|
|
82
|
+
self,
|
|
83
|
+
context: SagaContext,
|
|
84
|
+
saga_id: uuid.UUID | None = None,
|
|
85
|
+
) -> typing.AsyncIterator[SagaDispatchResult]:
|
|
78
86
|
# Find saga type by context type
|
|
79
87
|
saga_type = self._saga_map.get(type(context))
|
|
80
88
|
if not saga_type:
|
|
@@ -28,16 +28,23 @@ class StreamingRequestDispatcher:
|
|
|
28
28
|
self._container = container
|
|
29
29
|
self._middleware_chain = middleware_chain or MiddlewareChain()
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
def dispatch(
|
|
32
32
|
self,
|
|
33
33
|
request: IRequest,
|
|
34
34
|
) -> typing.AsyncIterator[RequestDispatchResult]:
|
|
35
35
|
"""
|
|
36
36
|
Dispatch a request to a streaming handler and yield results.
|
|
37
37
|
|
|
38
|
+
Called without await; returns an AsyncIterator consumed with async for.
|
|
38
39
|
After each yield from the handler, events are collected and included
|
|
39
40
|
in the dispatch result. The generator continues until StopIteration.
|
|
40
41
|
"""
|
|
42
|
+
return self._dispatch_impl(request)
|
|
43
|
+
|
|
44
|
+
async def _dispatch_impl(
|
|
45
|
+
self,
|
|
46
|
+
request: IRequest,
|
|
47
|
+
) -> typing.AsyncIterator[RequestDispatchResult]:
|
|
41
48
|
handler_type = self._request_map.get(type(request), None)
|
|
42
49
|
if handler_type is None:
|
|
43
50
|
raise RequestHandlerDoesNotExist(
|
|
@@ -62,9 +69,7 @@ class StreamingRequestDispatcher:
|
|
|
62
69
|
|
|
63
70
|
if not inspect.isasyncgenfunction(handler.handle):
|
|
64
71
|
handler_name = (
|
|
65
|
-
handler_type_typed.__name__
|
|
66
|
-
if hasattr(handler_type_typed, "__name__")
|
|
67
|
-
else str(handler_type_typed)
|
|
72
|
+
handler_type_typed.__name__ if hasattr(handler_type_typed, "__name__") else str(handler_type_typed)
|
|
68
73
|
)
|
|
69
74
|
raise TypeError(
|
|
70
75
|
f"Handler {handler_name}.handle must be an async generator function",
|
|
@@ -172,9 +172,7 @@ class StreamingRequestMediator:
|
|
|
172
172
|
max_concurrent_event_handlers: int = 1,
|
|
173
173
|
concurrent_event_handle_enable: bool = True,
|
|
174
174
|
*,
|
|
175
|
-
dispatcher_type: typing.Type[
|
|
176
|
-
StreamingRequestDispatcher
|
|
177
|
-
] = StreamingRequestDispatcher,
|
|
175
|
+
dispatcher_type: typing.Type[StreamingRequestDispatcher] = StreamingRequestDispatcher,
|
|
178
176
|
) -> None:
|
|
179
177
|
self._event_processor = EventProcessor(
|
|
180
178
|
event_map=event_map or EventMap(),
|
|
@@ -188,13 +186,14 @@ class StreamingRequestMediator:
|
|
|
188
186
|
middleware_chain=middleware_chain, # type: ignore
|
|
189
187
|
)
|
|
190
188
|
|
|
191
|
-
|
|
189
|
+
def stream(
|
|
192
190
|
self,
|
|
193
191
|
request: IRequest,
|
|
194
192
|
) -> typing.AsyncIterator[IResponse | None]:
|
|
195
193
|
"""
|
|
196
194
|
Stream results from a generator-based handler.
|
|
197
195
|
|
|
196
|
+
Called without await; returns an AsyncIterator consumed with async for.
|
|
198
197
|
After each yield from the handler:
|
|
199
198
|
1. Events are processed (in parallel with semaphore limit or sequentially
|
|
200
199
|
depending on concurrent_event_handle_enable) via event dispatcher
|
|
@@ -203,6 +202,12 @@ class StreamingRequestMediator:
|
|
|
203
202
|
|
|
204
203
|
The generator continues until StopIteration is raised.
|
|
205
204
|
"""
|
|
205
|
+
return self._stream_impl(request)
|
|
206
|
+
|
|
207
|
+
async def _stream_impl(
|
|
208
|
+
self,
|
|
209
|
+
request: IRequest,
|
|
210
|
+
) -> typing.AsyncIterator[IResponse | None]:
|
|
206
211
|
async for dispatch_result in self._dispatcher.dispatch(request):
|
|
207
212
|
await self._event_processor.emit_events(dispatch_result.events)
|
|
208
213
|
|
|
@@ -274,7 +279,7 @@ class SagaMediator:
|
|
|
274
279
|
compensation_retry_backoff=compensation_retry_backoff, # type: ignore
|
|
275
280
|
)
|
|
276
281
|
|
|
277
|
-
|
|
282
|
+
def stream(
|
|
278
283
|
self,
|
|
279
284
|
context: SagaContext,
|
|
280
285
|
saga_id: uuid.UUID | None = None,
|
|
@@ -282,6 +287,7 @@ class SagaMediator:
|
|
|
282
287
|
"""
|
|
283
288
|
Stream results from saga execution.
|
|
284
289
|
|
|
290
|
+
Called without await; returns an AsyncIterator consumed with async for.
|
|
285
291
|
After each step execution:
|
|
286
292
|
1. Events are processed (in parallel with semaphore limit or sequentially
|
|
287
293
|
depending on concurrent_event_handle_enable) via event dispatcher
|
|
@@ -298,6 +304,13 @@ class SagaMediator:
|
|
|
298
304
|
Yields:
|
|
299
305
|
SagaStepResult
|
|
300
306
|
"""
|
|
307
|
+
return self._stream_impl(context, saga_id=saga_id)
|
|
308
|
+
|
|
309
|
+
async def _stream_impl(
|
|
310
|
+
self,
|
|
311
|
+
context: SagaContext,
|
|
312
|
+
saga_id: uuid.UUID | None = None,
|
|
313
|
+
) -> typing.AsyncIterator[SagaStepResult]:
|
|
301
314
|
async for dispatch_result in self._dispatcher.dispatch(
|
|
302
315
|
context,
|
|
303
316
|
saga_id=saga_id,
|
|
@@ -79,7 +79,13 @@ class StreamingRequestHandler(abc.ABC, typing.Generic[ReqT, ResT]):
|
|
|
79
79
|
raise NotImplementedError
|
|
80
80
|
|
|
81
81
|
@abc.abstractmethod
|
|
82
|
-
|
|
82
|
+
def handle(self, request: ReqT) -> typing.AsyncIterator[ResT]:
|
|
83
|
+
"""
|
|
84
|
+
Handle the request by yielding results as an async generator.
|
|
85
|
+
|
|
86
|
+
Subclasses must implement this as an async generator (async def with
|
|
87
|
+
yield) so that callers receive an AsyncIterator when calling handle().
|
|
88
|
+
"""
|
|
83
89
|
raise NotImplementedError
|
|
84
90
|
|
|
85
91
|
@abc.abstractmethod
|
|
@@ -185,7 +185,7 @@ def bootstrap(
|
|
|
185
185
|
saga_storage=MemorySagaStorage(),
|
|
186
186
|
)
|
|
187
187
|
|
|
188
|
-
# Execute saga
|
|
188
|
+
# Execute saga (stream() returns AsyncIterator, consumed with async for)
|
|
189
189
|
async for result in mediator.stream(order_context):
|
|
190
190
|
print(f"Step: {result.step_result.step_type.__name__}")
|
|
191
191
|
|
|
@@ -122,6 +122,7 @@ class MemorySagaStorage(ISagaStorage):
|
|
|
122
122
|
limit: int,
|
|
123
123
|
max_recovery_attempts: int = 5,
|
|
124
124
|
stale_after_seconds: int | None = None,
|
|
125
|
+
saga_name: str | None = None,
|
|
125
126
|
) -> list[uuid.UUID]:
|
|
126
127
|
recoverable = (SagaStatus.RUNNING, SagaStatus.COMPENSATING)
|
|
127
128
|
now = datetime.datetime.now(datetime.timezone.utc)
|
|
@@ -136,6 +137,7 @@ class MemorySagaStorage(ISagaStorage):
|
|
|
136
137
|
if data["status"] in recoverable
|
|
137
138
|
and data.get("recovery_attempts", 0) < max_recovery_attempts
|
|
138
139
|
and (threshold is None or data["updated_at"] < threshold)
|
|
140
|
+
and (saga_name is None or data["name"] == saga_name)
|
|
139
141
|
]
|
|
140
142
|
candidates.sort(key=lambda sid: self._sagas[sid]["updated_at"])
|
|
141
143
|
return candidates[:limit]
|
|
@@ -153,3 +155,15 @@ class MemorySagaStorage(ISagaStorage):
|
|
|
153
155
|
data["version"] += 1
|
|
154
156
|
if new_status is not None:
|
|
155
157
|
data["status"] = new_status
|
|
158
|
+
|
|
159
|
+
async def set_recovery_attempts(
|
|
160
|
+
self,
|
|
161
|
+
saga_id: uuid.UUID,
|
|
162
|
+
attempts: int,
|
|
163
|
+
) -> None:
|
|
164
|
+
if saga_id not in self._sagas:
|
|
165
|
+
raise ValueError(f"Saga {saga_id} not found")
|
|
166
|
+
data = self._sagas[saga_id]
|
|
167
|
+
data["recovery_attempts"] = attempts
|
|
168
|
+
data["updated_at"] = datetime.datetime.now(datetime.timezone.utc)
|
|
169
|
+
data["version"] += 1
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import abc
|
|
2
|
+
import typing
|
|
3
|
+
import uuid
|
|
4
|
+
|
|
5
|
+
from cqrs.saga.storage.enums import SagaStatus, SagaStepStatus
|
|
6
|
+
from cqrs.saga.storage.models import SagaLogEntry
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ISagaStorage(abc.ABC):
|
|
10
|
+
"""Interface for saga persistence storage.
|
|
11
|
+
|
|
12
|
+
Storage is responsible for persisting saga execution state so that:
|
|
13
|
+
- Saga progress (status, context, step history) survives process restarts.
|
|
14
|
+
- Recovery jobs can find interrupted sagas (RUNNING/COMPENSATING) and retry them.
|
|
15
|
+
- Optimistic locking (version) prevents lost updates when multiple workers
|
|
16
|
+
touch the same saga.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
@abc.abstractmethod
|
|
20
|
+
async def create_saga(
|
|
21
|
+
self,
|
|
22
|
+
saga_id: uuid.UUID,
|
|
23
|
+
name: str,
|
|
24
|
+
context: dict[str, typing.Any],
|
|
25
|
+
) -> None:
|
|
26
|
+
"""Create a new saga record in storage (initial state).
|
|
27
|
+
|
|
28
|
+
Called when a saga is started for the first time. Creates the execution
|
|
29
|
+
record with PENDING status, initial context, and version 1.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
saga_id: Unique identifier of the saga (used as primary key).
|
|
33
|
+
name: Saga name (e.g. handler/type name) for diagnostics and filtering.
|
|
34
|
+
context: Initial context as a JSON-serializable dict (step inputs/outputs).
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
@abc.abstractmethod
|
|
38
|
+
async def update_context(
|
|
39
|
+
self,
|
|
40
|
+
saga_id: uuid.UUID,
|
|
41
|
+
context: dict[str, typing.Any],
|
|
42
|
+
current_version: int | None = None,
|
|
43
|
+
) -> None:
|
|
44
|
+
"""Save saga context snapshot (e.g. after a step completes).
|
|
45
|
+
|
|
46
|
+
Persists the current context so recovery can resume with up-to-date data.
|
|
47
|
+
When current_version is provided, implements optimistic locking: update
|
|
48
|
+
succeeds only if the stored version equals current_version (and version
|
|
49
|
+
is incremented), otherwise a concurrent update is detected.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
saga_id: The ID of the saga to update.
|
|
53
|
+
context: The new context data (full snapshot, JSON-serializable).
|
|
54
|
+
current_version: The expected current version of the saga execution.
|
|
55
|
+
If provided, optimistic locking is used; if the stored version
|
|
56
|
+
differs, the update is rejected.
|
|
57
|
+
|
|
58
|
+
Raises:
|
|
59
|
+
SagaConcurrencyError: If optimistic locking fails (version mismatch).
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
@abc.abstractmethod
|
|
63
|
+
async def update_status(
|
|
64
|
+
self,
|
|
65
|
+
saga_id: uuid.UUID,
|
|
66
|
+
status: SagaStatus,
|
|
67
|
+
) -> None:
|
|
68
|
+
"""Update the saga's global status.
|
|
69
|
+
|
|
70
|
+
Status drives lifecycle: PENDING → RUNNING → COMPLETED, or RUNNING →
|
|
71
|
+
COMPENSATING → FAILED. Used by execution and recovery to know whether
|
|
72
|
+
to run steps, compensate, or consider the saga finished.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
saga_id: The ID of the saga to update.
|
|
76
|
+
status: New status (e.g. SagaStatus.RUNNING, SagaStatus.COMPLETED,
|
|
77
|
+
SagaStatus.COMPENSATING, SagaStatus.FAILED).
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
@abc.abstractmethod
|
|
81
|
+
async def log_step(
|
|
82
|
+
self,
|
|
83
|
+
saga_id: uuid.UUID,
|
|
84
|
+
step_name: str,
|
|
85
|
+
action: typing.Literal["act", "compensate"],
|
|
86
|
+
status: SagaStepStatus,
|
|
87
|
+
details: str | None = None,
|
|
88
|
+
) -> None:
|
|
89
|
+
"""Append a step transition to the saga log.
|
|
90
|
+
|
|
91
|
+
Used to record each step's outcome (started/completed/failed/compensated)
|
|
92
|
+
so that recovery can determine which steps have already been executed
|
|
93
|
+
and which need to be run or compensated.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
saga_id: The ID of the saga this step belongs to.
|
|
97
|
+
step_name: Name of the step (must match the step handler name).
|
|
98
|
+
action: "act" for forward execution, "compensate" for compensation.
|
|
99
|
+
status: Step outcome: STARTED, COMPLETED, FAILED, or COMPENSATED.
|
|
100
|
+
details: Optional message (e.g. error text when status is FAILED).
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
@abc.abstractmethod
|
|
104
|
+
async def load_saga_state(
|
|
105
|
+
self,
|
|
106
|
+
saga_id: uuid.UUID,
|
|
107
|
+
*,
|
|
108
|
+
read_for_update: bool = False,
|
|
109
|
+
) -> tuple[SagaStatus, dict[str, typing.Any], int]:
|
|
110
|
+
"""Load current saga status, context, and version.
|
|
111
|
+
|
|
112
|
+
Used by execution and recovery to restore in-memory state. When
|
|
113
|
+
read_for_update is True, the implementation may lock the row (e.g.
|
|
114
|
+
SELECT FOR UPDATE) to avoid concurrent updates.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
saga_id: The ID of the saga to load.
|
|
118
|
+
read_for_update: If True, lock the row for update (e.g. for
|
|
119
|
+
subsequent update_context with optimistic locking).
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
Tuple of (status, context_dict, version). context_dict is the
|
|
123
|
+
last persisted context; version is used for optimistic locking.
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
@abc.abstractmethod
|
|
127
|
+
async def get_step_history(
|
|
128
|
+
self,
|
|
129
|
+
saga_id: uuid.UUID,
|
|
130
|
+
) -> list[SagaLogEntry]:
|
|
131
|
+
"""Return the ordered list of step log entries for the saga.
|
|
132
|
+
|
|
133
|
+
Used by recovery to determine which steps completed successfully
|
|
134
|
+
(and must be compensated in reverse order if compensating) and
|
|
135
|
+
which steps still need to be executed.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
saga_id: The ID of the saga whose step history to load.
|
|
139
|
+
|
|
140
|
+
Returns:
|
|
141
|
+
List of SagaLogEntry in chronological order (oldest first).
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
@abc.abstractmethod
|
|
145
|
+
async def get_sagas_for_recovery(
|
|
146
|
+
self,
|
|
147
|
+
limit: int,
|
|
148
|
+
max_recovery_attempts: int = 5,
|
|
149
|
+
stale_after_seconds: int | None = None,
|
|
150
|
+
saga_name: str | None = None,
|
|
151
|
+
) -> list[uuid.UUID]:
|
|
152
|
+
"""Return saga IDs that are candidates for recovery.
|
|
153
|
+
|
|
154
|
+
Used by a recovery job/scheduler to find sagas that were left in
|
|
155
|
+
RUNNING or COMPENSATING (e.g. process crash) and should be retried.
|
|
156
|
+
Excludes COMPLETED and optionally limits by recovery attempts,
|
|
157
|
+
staleness, and saga name to avoid re-processing fresh or repeatedly
|
|
158
|
+
failing sagas.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
limit: Maximum number of saga IDs to return per call.
|
|
162
|
+
max_recovery_attempts: Only include sagas with recovery_attempts
|
|
163
|
+
strictly less than this value. Sagas that have failed
|
|
164
|
+
recovery this many times can be excluded (e.g. marked FAILED).
|
|
165
|
+
Default 5.
|
|
166
|
+
stale_after_seconds: If set, only include sagas whose updated_at
|
|
167
|
+
is older than (now_utc - stale_after_seconds). Use this to
|
|
168
|
+
avoid picking sagas that are currently being executed (recently
|
|
169
|
+
updated). None means no staleness filter (backward compatible).
|
|
170
|
+
saga_name: If set, only include sagas with this name (e.g. handler
|
|
171
|
+
or type name). None means return all saga types (default).
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
List of saga IDs (RUNNING or COMPENSATING only; FAILED/COMPLETED
|
|
175
|
+
are not included), ordered by updated_at ascending, with
|
|
176
|
+
recovery_attempts < max_recovery_attempts, and optionally
|
|
177
|
+
updated_at older than the staleness threshold and name equal to
|
|
178
|
+
saga_name when saga_name is provided.
|
|
179
|
+
"""
|
|
180
|
+
|
|
181
|
+
@abc.abstractmethod
|
|
182
|
+
async def increment_recovery_attempts(
|
|
183
|
+
self,
|
|
184
|
+
saga_id: uuid.UUID,
|
|
185
|
+
new_status: SagaStatus | None = None,
|
|
186
|
+
) -> None:
|
|
187
|
+
"""Increment recovery attempt counter after a failed recovery run.
|
|
188
|
+
|
|
189
|
+
Called when recovery of a saga fails (e.g. exception). Increments
|
|
190
|
+
recovery_attempts and optionally sets status (e.g. to FAILED) so that
|
|
191
|
+
get_sagas_for_recovery can exclude this saga or limit retries.
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
saga_id: The saga that failed recovery.
|
|
195
|
+
new_status: If provided, set saga status to this value (e.g.
|
|
196
|
+
SagaStatus.FAILED) in the same atomic update.
|
|
197
|
+
"""
|
|
198
|
+
|
|
199
|
+
@abc.abstractmethod
|
|
200
|
+
async def set_recovery_attempts(
|
|
201
|
+
self,
|
|
202
|
+
saga_id: uuid.UUID,
|
|
203
|
+
attempts: int,
|
|
204
|
+
) -> None:
|
|
205
|
+
"""Set recovery attempt counter to an explicit value.
|
|
206
|
+
|
|
207
|
+
Used to reset the counter after successfully recovering one of the
|
|
208
|
+
steps (e.g. set to 0), or to set it to the maximum value so that
|
|
209
|
+
get_sagas_for_recovery excludes this saga from further recovery
|
|
210
|
+
(e.g. mark as permanently failed without changing status).
|
|
211
|
+
|
|
212
|
+
Args:
|
|
213
|
+
saga_id: The saga to update.
|
|
214
|
+
attempts: The value to set recovery_attempts to (e.g. 0 to reset,
|
|
215
|
+
or max_recovery_attempts to exclude from recovery).
|
|
216
|
+
"""
|
|
@@ -317,6 +317,7 @@ class SqlAlchemySagaStorage(ISagaStorage):
|
|
|
317
317
|
limit: int,
|
|
318
318
|
max_recovery_attempts: int = 5,
|
|
319
319
|
stale_after_seconds: int | None = None,
|
|
320
|
+
saga_name: str | None = None,
|
|
320
321
|
) -> list[uuid.UUID]:
|
|
321
322
|
recoverable = (
|
|
322
323
|
SagaStatus.RUNNING,
|
|
@@ -328,6 +329,8 @@ class SqlAlchemySagaStorage(ISagaStorage):
|
|
|
328
329
|
.where(SagaExecutionModel.status.in_(recoverable))
|
|
329
330
|
.where(SagaExecutionModel.recovery_attempts < max_recovery_attempts)
|
|
330
331
|
)
|
|
332
|
+
if saga_name is not None:
|
|
333
|
+
stmt = stmt.where(SagaExecutionModel.name == saga_name)
|
|
331
334
|
if stale_after_seconds is not None:
|
|
332
335
|
threshold = datetime.datetime.now(
|
|
333
336
|
datetime.timezone.utc,
|
|
@@ -364,3 +367,25 @@ class SqlAlchemySagaStorage(ISagaStorage):
|
|
|
364
367
|
except SQLAlchemyError:
|
|
365
368
|
await session.rollback()
|
|
366
369
|
raise
|
|
370
|
+
|
|
371
|
+
async def set_recovery_attempts(
|
|
372
|
+
self,
|
|
373
|
+
saga_id: uuid.UUID,
|
|
374
|
+
attempts: int,
|
|
375
|
+
) -> None:
|
|
376
|
+
async with self.session_factory() as session:
|
|
377
|
+
try:
|
|
378
|
+
result = await session.execute(
|
|
379
|
+
sqlalchemy.update(SagaExecutionModel)
|
|
380
|
+
.where(SagaExecutionModel.id == saga_id)
|
|
381
|
+
.values(
|
|
382
|
+
recovery_attempts=attempts,
|
|
383
|
+
version=SagaExecutionModel.version + 1,
|
|
384
|
+
),
|
|
385
|
+
)
|
|
386
|
+
if result.rowcount == 0: # type: ignore[attr-defined]
|
|
387
|
+
raise ValueError(f"Saga {saga_id} not found")
|
|
388
|
+
await session.commit()
|
|
389
|
+
except SQLAlchemyError:
|
|
390
|
+
await session.rollback()
|
|
391
|
+
raise
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-cqrs
|
|
3
|
-
Version: 4.7.
|
|
3
|
+
Version: 4.7.3
|
|
4
4
|
Summary: Python CQRS pattern implementation
|
|
5
5
|
Author-email: Vadim Kozyrevskiy <vadikko2@mail.ru>, Dmitry Kutlubaev <kutlubaev00@mail.ru>
|
|
6
6
|
Maintainer-email: Vadim Kozyrevskiy <vadikko2@mail.ru>
|
|
@@ -1,113 +0,0 @@
|
|
|
1
|
-
import abc
|
|
2
|
-
import typing
|
|
3
|
-
import uuid
|
|
4
|
-
|
|
5
|
-
from cqrs.saga.storage.enums import SagaStatus, SagaStepStatus
|
|
6
|
-
from cqrs.saga.storage.models import SagaLogEntry
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
class ISagaStorage(abc.ABC):
|
|
10
|
-
"""Interface for saga persistence storage."""
|
|
11
|
-
|
|
12
|
-
@abc.abstractmethod
|
|
13
|
-
async def create_saga(
|
|
14
|
-
self,
|
|
15
|
-
saga_id: uuid.UUID,
|
|
16
|
-
name: str,
|
|
17
|
-
context: dict[str, typing.Any],
|
|
18
|
-
) -> None:
|
|
19
|
-
"""Initialize a new saga in storage."""
|
|
20
|
-
|
|
21
|
-
@abc.abstractmethod
|
|
22
|
-
async def update_context(
|
|
23
|
-
self,
|
|
24
|
-
saga_id: uuid.UUID,
|
|
25
|
-
context: dict[str, typing.Any],
|
|
26
|
-
current_version: int | None = None,
|
|
27
|
-
) -> None:
|
|
28
|
-
"""Save saga context snapshot.
|
|
29
|
-
|
|
30
|
-
Args:
|
|
31
|
-
saga_id: The ID of the saga to update.
|
|
32
|
-
context: The new context data.
|
|
33
|
-
current_version: The expected current version of the saga execution.
|
|
34
|
-
If provided, optimistic locking will be used.
|
|
35
|
-
Raises:
|
|
36
|
-
SagaConcurrencyError: If optimistic locking fails.
|
|
37
|
-
"""
|
|
38
|
-
|
|
39
|
-
@abc.abstractmethod
|
|
40
|
-
async def update_status(
|
|
41
|
-
self,
|
|
42
|
-
saga_id: uuid.UUID,
|
|
43
|
-
status: SagaStatus,
|
|
44
|
-
) -> None:
|
|
45
|
-
"""Update saga global status."""
|
|
46
|
-
|
|
47
|
-
@abc.abstractmethod
|
|
48
|
-
async def log_step(
|
|
49
|
-
self,
|
|
50
|
-
saga_id: uuid.UUID,
|
|
51
|
-
step_name: str,
|
|
52
|
-
action: typing.Literal["act", "compensate"],
|
|
53
|
-
status: SagaStepStatus,
|
|
54
|
-
details: str | None = None,
|
|
55
|
-
) -> None:
|
|
56
|
-
"""Log a step transition."""
|
|
57
|
-
|
|
58
|
-
@abc.abstractmethod
|
|
59
|
-
async def load_saga_state(
|
|
60
|
-
self,
|
|
61
|
-
saga_id: uuid.UUID,
|
|
62
|
-
*,
|
|
63
|
-
read_for_update: bool = False,
|
|
64
|
-
) -> tuple[SagaStatus, dict[str, typing.Any], int]:
|
|
65
|
-
"""Load current saga status, context, and version."""
|
|
66
|
-
|
|
67
|
-
@abc.abstractmethod
|
|
68
|
-
async def get_step_history(
|
|
69
|
-
self,
|
|
70
|
-
saga_id: uuid.UUID,
|
|
71
|
-
) -> list[SagaLogEntry]:
|
|
72
|
-
"""Get step execution history."""
|
|
73
|
-
|
|
74
|
-
@abc.abstractmethod
|
|
75
|
-
async def get_sagas_for_recovery(
|
|
76
|
-
self,
|
|
77
|
-
limit: int,
|
|
78
|
-
max_recovery_attempts: int = 5,
|
|
79
|
-
stale_after_seconds: int | None = None,
|
|
80
|
-
) -> list[uuid.UUID]:
|
|
81
|
-
"""Return saga IDs that need recovery.
|
|
82
|
-
|
|
83
|
-
Args:
|
|
84
|
-
limit: Maximum number of saga IDs to return.
|
|
85
|
-
max_recovery_attempts: Only include sagas with recovery_attempts
|
|
86
|
-
strictly less than this value. Default 5.
|
|
87
|
-
stale_after_seconds: If set, only include sagas whose updated_at
|
|
88
|
-
is older than (now_utc - stale_after_seconds). Use this to
|
|
89
|
-
avoid picking sagas that are currently being executed (recently
|
|
90
|
-
updated). None means no staleness filter (backward compatible).
|
|
91
|
-
|
|
92
|
-
Returns:
|
|
93
|
-
List of saga IDs (RUNNING or COMPENSATING only; FAILED sagas are
|
|
94
|
-
not included), ordered by updated_at ascending, with
|
|
95
|
-
recovery_attempts < max_recovery_attempts, and optionally
|
|
96
|
-
updated_at older than the staleness threshold.
|
|
97
|
-
"""
|
|
98
|
-
|
|
99
|
-
@abc.abstractmethod
|
|
100
|
-
async def increment_recovery_attempts(
|
|
101
|
-
self,
|
|
102
|
-
saga_id: uuid.UUID,
|
|
103
|
-
new_status: SagaStatus | None = None,
|
|
104
|
-
) -> None:
|
|
105
|
-
"""Atomically increment recovery attempts after a failed recovery.
|
|
106
|
-
|
|
107
|
-
Updates recovery_attempts += 1, updated_at = now(), and optionally
|
|
108
|
-
status. Also increments version for optimistic locking.
|
|
109
|
-
|
|
110
|
-
Args:
|
|
111
|
-
saga_id: The saga to update.
|
|
112
|
-
new_status: If provided, set saga status to this value (e.g. FAILED).
|
|
113
|
-
"""
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|