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.
- {python_cqrs-4.7.0/src/python_cqrs.egg-info → python_cqrs-4.7.2}/PKG-INFO +1 -1
- {python_cqrs-4.7.0 → python_cqrs-4.7.2}/pyproject.toml +1 -1
- {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/saga/compensation.py +8 -0
- {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/saga/saga.py +8 -0
- {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/saga/storage/memory.py +15 -1
- python_cqrs-4.7.2/src/cqrs/saga/storage/protocol.py +216 -0
- {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/saga/storage/sqlalchemy.py +25 -1
- {python_cqrs-4.7.0 → python_cqrs-4.7.2/src/python_cqrs.egg-info}/PKG-INFO +1 -1
- python_cqrs-4.7.0/src/cqrs/saga/storage/protocol.py +0 -112
- {python_cqrs-4.7.0 → python_cqrs-4.7.2}/LICENSE +0 -0
- {python_cqrs-4.7.0 → python_cqrs-4.7.2}/README.md +0 -0
- {python_cqrs-4.7.0 → python_cqrs-4.7.2}/setup.cfg +0 -0
- {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/__init__.py +0 -0
- {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/adapters/__init__.py +0 -0
- {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/adapters/amqp.py +0 -0
- {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/adapters/circuit_breaker.py +0 -0
- {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/adapters/kafka.py +0 -0
- {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/adapters/protocol.py +0 -0
- {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/compressors/__init__.py +0 -0
- {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/compressors/protocol.py +0 -0
- {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/compressors/zlib.py +0 -0
- {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/container/__init__.py +0 -0
- {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/container/dependency_injector.py +0 -0
- {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/container/di.py +0 -0
- {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/container/protocol.py +0 -0
- {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/deserializers/__init__.py +0 -0
- {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/deserializers/exceptions.py +0 -0
- {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/deserializers/json.py +0 -0
- {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/dispatcher/__init__.py +0 -0
- {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/dispatcher/event.py +0 -0
- {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/dispatcher/exceptions.py +0 -0
- {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/dispatcher/models.py +0 -0
- {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/dispatcher/request.py +0 -0
- {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/dispatcher/saga.py +0 -0
- {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/dispatcher/streaming.py +0 -0
- {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/events/__init__.py +0 -0
- {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/events/bootstrap.py +0 -0
- {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/events/event.py +0 -0
- {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/events/event_emitter.py +0 -0
- {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/events/event_handler.py +0 -0
- {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/events/event_processor.py +0 -0
- {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/events/map.py +0 -0
- {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/mediator.py +0 -0
- {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/message_brokers/__init__.py +0 -0
- {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/message_brokers/amqp.py +0 -0
- {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/message_brokers/devnull.py +0 -0
- {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/message_brokers/kafka.py +0 -0
- {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/message_brokers/protocol.py +0 -0
- {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/middlewares/__init__.py +0 -0
- {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/middlewares/base.py +0 -0
- {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/middlewares/logging.py +0 -0
- {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/outbox/__init__.py +0 -0
- {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/outbox/map.py +0 -0
- {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/outbox/mock.py +0 -0
- {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/outbox/repository.py +0 -0
- {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/outbox/sqlalchemy.py +0 -0
- {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/producer.py +0 -0
- {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/requests/__init__.py +0 -0
- {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/requests/bootstrap.py +0 -0
- {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/requests/cor_request_handler.py +0 -0
- {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/requests/map.py +0 -0
- {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/requests/mermaid.py +0 -0
- {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/requests/request.py +0 -0
- {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/requests/request_handler.py +0 -0
- {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/response.py +0 -0
- {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/saga/__init__.py +0 -0
- {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/saga/bootstrap.py +0 -0
- {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/saga/circuit_breaker.py +0 -0
- {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/saga/execution.py +0 -0
- {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/saga/fallback.py +0 -0
- {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/saga/mermaid.py +0 -0
- {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/saga/models.py +0 -0
- {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/saga/recovery.py +0 -0
- {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/saga/step.py +0 -0
- {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/saga/storage/__init__.py +0 -0
- {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/saga/storage/enums.py +0 -0
- {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/saga/storage/models.py +0 -0
- {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/saga/validation.py +0 -0
- {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/serializers/__init__.py +0 -0
- {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/serializers/default.py +0 -0
- {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/cqrs/types.py +0 -0
- {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/python_cqrs.egg-info/SOURCES.txt +0 -0
- {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/python_cqrs.egg-info/dependency_links.txt +0 -0
- {python_cqrs-4.7.0 → python_cqrs-4.7.2}/src/python_cqrs.egg-info/requires.txt +0 -0
- {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.
|
|
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.
|
|
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
|
|
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.
|
|
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
|
|
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
|