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.
Files changed (85) hide show
  1. {python_cqrs-4.7.1/src/python_cqrs.egg-info → python_cqrs-4.7.3}/PKG-INFO +1 -1
  2. {python_cqrs-4.7.1 → python_cqrs-4.7.3}/pyproject.toml +1 -4
  3. {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/dispatcher/saga.py +9 -1
  4. {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/dispatcher/streaming.py +9 -4
  5. {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/mediator.py +18 -5
  6. {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/requests/request_handler.py +7 -1
  7. {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/saga/bootstrap.py +1 -1
  8. {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/saga/storage/memory.py +14 -0
  9. python_cqrs-4.7.3/src/cqrs/saga/storage/protocol.py +216 -0
  10. {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/saga/storage/sqlalchemy.py +25 -0
  11. {python_cqrs-4.7.1 → python_cqrs-4.7.3/src/python_cqrs.egg-info}/PKG-INFO +1 -1
  12. python_cqrs-4.7.1/src/cqrs/saga/storage/protocol.py +0 -113
  13. {python_cqrs-4.7.1 → python_cqrs-4.7.3}/LICENSE +0 -0
  14. {python_cqrs-4.7.1 → python_cqrs-4.7.3}/README.md +0 -0
  15. {python_cqrs-4.7.1 → python_cqrs-4.7.3}/setup.cfg +0 -0
  16. {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/__init__.py +0 -0
  17. {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/adapters/__init__.py +0 -0
  18. {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/adapters/amqp.py +0 -0
  19. {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/adapters/circuit_breaker.py +0 -0
  20. {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/adapters/kafka.py +0 -0
  21. {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/adapters/protocol.py +0 -0
  22. {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/compressors/__init__.py +0 -0
  23. {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/compressors/protocol.py +0 -0
  24. {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/compressors/zlib.py +0 -0
  25. {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/container/__init__.py +0 -0
  26. {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/container/dependency_injector.py +0 -0
  27. {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/container/di.py +0 -0
  28. {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/container/protocol.py +0 -0
  29. {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/deserializers/__init__.py +0 -0
  30. {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/deserializers/exceptions.py +0 -0
  31. {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/deserializers/json.py +0 -0
  32. {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/dispatcher/__init__.py +0 -0
  33. {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/dispatcher/event.py +0 -0
  34. {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/dispatcher/exceptions.py +0 -0
  35. {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/dispatcher/models.py +0 -0
  36. {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/dispatcher/request.py +0 -0
  37. {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/events/__init__.py +0 -0
  38. {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/events/bootstrap.py +0 -0
  39. {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/events/event.py +0 -0
  40. {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/events/event_emitter.py +0 -0
  41. {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/events/event_handler.py +0 -0
  42. {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/events/event_processor.py +0 -0
  43. {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/events/map.py +0 -0
  44. {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/message_brokers/__init__.py +0 -0
  45. {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/message_brokers/amqp.py +0 -0
  46. {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/message_brokers/devnull.py +0 -0
  47. {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/message_brokers/kafka.py +0 -0
  48. {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/message_brokers/protocol.py +0 -0
  49. {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/middlewares/__init__.py +0 -0
  50. {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/middlewares/base.py +0 -0
  51. {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/middlewares/logging.py +0 -0
  52. {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/outbox/__init__.py +0 -0
  53. {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/outbox/map.py +0 -0
  54. {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/outbox/mock.py +0 -0
  55. {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/outbox/repository.py +0 -0
  56. {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/outbox/sqlalchemy.py +0 -0
  57. {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/producer.py +0 -0
  58. {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/requests/__init__.py +0 -0
  59. {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/requests/bootstrap.py +0 -0
  60. {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/requests/cor_request_handler.py +0 -0
  61. {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/requests/map.py +0 -0
  62. {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/requests/mermaid.py +0 -0
  63. {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/requests/request.py +0 -0
  64. {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/response.py +0 -0
  65. {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/saga/__init__.py +0 -0
  66. {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/saga/circuit_breaker.py +0 -0
  67. {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/saga/compensation.py +0 -0
  68. {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/saga/execution.py +0 -0
  69. {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/saga/fallback.py +0 -0
  70. {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/saga/mermaid.py +0 -0
  71. {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/saga/models.py +0 -0
  72. {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/saga/recovery.py +0 -0
  73. {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/saga/saga.py +0 -0
  74. {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/saga/step.py +0 -0
  75. {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/saga/storage/__init__.py +0 -0
  76. {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/saga/storage/enums.py +0 -0
  77. {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/saga/storage/models.py +0 -0
  78. {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/saga/validation.py +0 -0
  79. {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/serializers/__init__.py +0 -0
  80. {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/serializers/default.py +0 -0
  81. {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/cqrs/types.py +0 -0
  82. {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/python_cqrs.egg-info/SOURCES.txt +0 -0
  83. {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/python_cqrs.egg-info/dependency_links.txt +0 -0
  84. {python_cqrs-4.7.1 → python_cqrs-4.7.3}/src/python_cqrs.egg-info/requires.txt +0 -0
  85. {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.1
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.1"
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
- async def dispatch(
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
- async def dispatch(
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
- async def stream(
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
- async def stream(
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
- async def handle(self, request: ReqT) -> typing.AsyncIterator[ResT]:
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.1
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