python-cqrs 4.7.0__tar.gz → 4.7.2__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.0/src/python_cqrs.egg-info → python_cqrs-4.7.2}/PKG-INFO +1 -1
  2. {python_cqrs-4.7.0 → python_cqrs-4.7.2}/pyproject.toml +1 -1
  3. {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/saga/compensation.py +8 -0
  4. {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/saga/saga.py +8 -0
  5. {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/saga/storage/memory.py +15 -1
  6. python_cqrs-4.7.2/src/cqrs/saga/storage/protocol.py +216 -0
  7. {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/saga/storage/sqlalchemy.py +25 -1
  8. {python_cqrs-4.7.0 → python_cqrs-4.7.2/src/python_cqrs.egg-info}/PKG-INFO +1 -1
  9. python_cqrs-4.7.0/src/cqrs/saga/storage/protocol.py +0 -112
  10. {python_cqrs-4.7.0 → python_cqrs-4.7.2}/LICENSE +0 -0
  11. {python_cqrs-4.7.0 → python_cqrs-4.7.2}/README.md +0 -0
  12. {python_cqrs-4.7.0 → python_cqrs-4.7.2}/setup.cfg +0 -0
  13. {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/__init__.py +0 -0
  14. {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/adapters/__init__.py +0 -0
  15. {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/adapters/amqp.py +0 -0
  16. {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/adapters/circuit_breaker.py +0 -0
  17. {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/adapters/kafka.py +0 -0
  18. {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/adapters/protocol.py +0 -0
  19. {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/compressors/__init__.py +0 -0
  20. {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/compressors/protocol.py +0 -0
  21. {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/compressors/zlib.py +0 -0
  22. {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/container/__init__.py +0 -0
  23. {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/container/dependency_injector.py +0 -0
  24. {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/container/di.py +0 -0
  25. {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/container/protocol.py +0 -0
  26. {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/deserializers/__init__.py +0 -0
  27. {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/deserializers/exceptions.py +0 -0
  28. {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/deserializers/json.py +0 -0
  29. {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/dispatcher/__init__.py +0 -0
  30. {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/dispatcher/event.py +0 -0
  31. {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/dispatcher/exceptions.py +0 -0
  32. {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/dispatcher/models.py +0 -0
  33. {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/dispatcher/request.py +0 -0
  34. {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/dispatcher/saga.py +0 -0
  35. {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/dispatcher/streaming.py +0 -0
  36. {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/events/__init__.py +0 -0
  37. {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/events/bootstrap.py +0 -0
  38. {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/events/event.py +0 -0
  39. {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/events/event_emitter.py +0 -0
  40. {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/events/event_handler.py +0 -0
  41. {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/events/event_processor.py +0 -0
  42. {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/events/map.py +0 -0
  43. {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/mediator.py +0 -0
  44. {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/message_brokers/__init__.py +0 -0
  45. {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/message_brokers/amqp.py +0 -0
  46. {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/message_brokers/devnull.py +0 -0
  47. {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/message_brokers/kafka.py +0 -0
  48. {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/message_brokers/protocol.py +0 -0
  49. {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/middlewares/__init__.py +0 -0
  50. {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/middlewares/base.py +0 -0
  51. {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/middlewares/logging.py +0 -0
  52. {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/outbox/__init__.py +0 -0
  53. {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/outbox/map.py +0 -0
  54. {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/outbox/mock.py +0 -0
  55. {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/outbox/repository.py +0 -0
  56. {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/outbox/sqlalchemy.py +0 -0
  57. {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/producer.py +0 -0
  58. {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/requests/__init__.py +0 -0
  59. {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/requests/bootstrap.py +0 -0
  60. {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/requests/cor_request_handler.py +0 -0
  61. {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/requests/map.py +0 -0
  62. {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/requests/mermaid.py +0 -0
  63. {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/requests/request.py +0 -0
  64. {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/requests/request_handler.py +0 -0
  65. {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/response.py +0 -0
  66. {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/saga/__init__.py +0 -0
  67. {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/saga/bootstrap.py +0 -0
  68. {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/saga/circuit_breaker.py +0 -0
  69. {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/saga/execution.py +0 -0
  70. {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/saga/fallback.py +0 -0
  71. {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/saga/mermaid.py +0 -0
  72. {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/saga/models.py +0 -0
  73. {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/saga/recovery.py +0 -0
  74. {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/saga/step.py +0 -0
  75. {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/saga/storage/__init__.py +0 -0
  76. {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/saga/storage/enums.py +0 -0
  77. {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/saga/storage/models.py +0 -0
  78. {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/saga/validation.py +0 -0
  79. {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/serializers/__init__.py +0 -0
  80. {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/serializers/default.py +0 -0
  81. {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/types.py +0 -0
  82. {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/python_cqrs.egg-info/SOURCES.txt +0 -0
  83. {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/python_cqrs.egg-info/dependency_links.txt +0 -0
  84. {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/python_cqrs.egg-info/requires.txt +0 -0
  85. {python_cqrs-4.7.0 → python_cqrs-4.7.2}/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.0
3
+ Version: 4.7.2
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.0"
34
+ version = "4.7.2"
35
35
 
36
36
  [project.optional-dependencies]
37
37
  aiobreaker = ["aiobreaker>=0.3.0"]
@@ -54,6 +54,14 @@ class SagaCompensator(typing.Generic[ContextT]):
54
54
  """
55
55
  await self._storage.update_status(self._saga_id, SagaStatus.COMPENSATING)
56
56
 
57
+ if not completed_steps:
58
+ logger.info(
59
+ f"Saga {self._saga_id}: completed_steps is empty, "
60
+ "skipping compensation (no step.compensate() will be called).",
61
+ )
62
+ await self._storage.update_status(self._saga_id, SagaStatus.FAILED)
63
+ return
64
+
57
65
  # Load history to skip already compensated steps
58
66
  history = await self._storage.get_step_history(self._saga_id)
59
67
  compensated_steps = {
@@ -234,6 +234,14 @@ class SagaTransaction(typing.Generic[ContextT]):
234
234
  for step in reconstructed_steps
235
235
  ]
236
236
 
237
+ if not self._completed_steps:
238
+ logger.warning(
239
+ f"Saga {self._saga_id}: no completed steps to compensate "
240
+ "(saga failed before any step finished 'act', or step names in "
241
+ "storage do not match saga step class names). "
242
+ "Marking as FAILED without calling compensate().",
243
+ )
244
+
237
245
  # Immediately proceed to compensation - no forward execution
238
246
  await self._compensate()
239
247
 
@@ -122,8 +122,9 @@ 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
- recoverable = (SagaStatus.RUNNING, SagaStatus.COMPENSATING, SagaStatus.FAILED)
127
+ recoverable = (SagaStatus.RUNNING, SagaStatus.COMPENSATING)
127
128
  now = datetime.datetime.now(datetime.timezone.utc)
128
129
  threshold = (
129
130
  (now - datetime.timedelta(seconds=stale_after_seconds))
@@ -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,11 +317,11 @@ 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,
323
324
  SagaStatus.COMPENSATING,
324
- SagaStatus.FAILED,
325
325
  )
326
326
  async with self.session_factory() as session:
327
327
  stmt = (
@@ -329,6 +329,8 @@ class SqlAlchemySagaStorage(ISagaStorage):
329
329
  .where(SagaExecutionModel.status.in_(recoverable))
330
330
  .where(SagaExecutionModel.recovery_attempts < max_recovery_attempts)
331
331
  )
332
+ if saga_name is not None:
333
+ stmt = stmt.where(SagaExecutionModel.name == saga_name)
332
334
  if stale_after_seconds is not None:
333
335
  threshold = datetime.datetime.now(
334
336
  datetime.timezone.utc,
@@ -365,3 +367,25 @@ class SqlAlchemySagaStorage(ISagaStorage):
365
367
  except SQLAlchemyError:
366
368
  await session.rollback()
367
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.0
3
+ Version: 4.7.2
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,112 +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, COMPENSATING, or FAILED), ordered by
94
- updated_at ascending, with recovery_attempts < max_recovery_attempts,
95
- and optionally updated_at older than the staleness threshold.
96
- """
97
-
98
- @abc.abstractmethod
99
- async def increment_recovery_attempts(
100
- self,
101
- saga_id: uuid.UUID,
102
- new_status: SagaStatus | None = None,
103
- ) -> None:
104
- """Atomically increment recovery attempts after a failed recovery.
105
-
106
- Updates recovery_attempts += 1, updated_at = now(), and optionally
107
- status. Also increments version for optimistic locking.
108
-
109
- Args:
110
- saga_id: The saga to update.
111
- new_status: If provided, set saga status to this value (e.g. FAILED).
112
- """
File without changes
File without changes
File without changes