python-cqrs 4.6.4__tar.gz → 4.7.0__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.6.4/src/python_cqrs.egg-info → python_cqrs-4.7.0}/PKG-INFO +5 -2
- {python_cqrs-4.6.4 → python_cqrs-4.7.0}/README.md +4 -1
- {python_cqrs-4.6.4 → python_cqrs-4.7.0}/pyproject.toml +1 -1
- {python_cqrs-4.6.4 → python_cqrs-4.7.0}/src/cqrs/saga/models.py +7 -6
- {python_cqrs-4.6.4 → python_cqrs-4.7.0}/src/cqrs/saga/recovery.py +9 -3
- {python_cqrs-4.6.4 → python_cqrs-4.7.0}/src/cqrs/saga/saga.py +10 -3
- {python_cqrs-4.6.4 → python_cqrs-4.7.0}/src/cqrs/saga/storage/memory.py +38 -0
- {python_cqrs-4.6.4 → python_cqrs-4.7.0}/src/cqrs/saga/storage/protocol.py +40 -0
- {python_cqrs-4.6.4 → python_cqrs-4.7.0}/src/cqrs/saga/storage/sqlalchemy.py +62 -0
- {python_cqrs-4.6.4 → python_cqrs-4.7.0/src/python_cqrs.egg-info}/PKG-INFO +5 -2
- {python_cqrs-4.6.4 → python_cqrs-4.7.0}/LICENSE +0 -0
- {python_cqrs-4.6.4 → python_cqrs-4.7.0}/setup.cfg +0 -0
- {python_cqrs-4.6.4 → python_cqrs-4.7.0}/src/cqrs/__init__.py +0 -0
- {python_cqrs-4.6.4 → python_cqrs-4.7.0}/src/cqrs/adapters/__init__.py +0 -0
- {python_cqrs-4.6.4 → python_cqrs-4.7.0}/src/cqrs/adapters/amqp.py +0 -0
- {python_cqrs-4.6.4 → python_cqrs-4.7.0}/src/cqrs/adapters/circuit_breaker.py +0 -0
- {python_cqrs-4.6.4 → python_cqrs-4.7.0}/src/cqrs/adapters/kafka.py +0 -0
- {python_cqrs-4.6.4 → python_cqrs-4.7.0}/src/cqrs/adapters/protocol.py +0 -0
- {python_cqrs-4.6.4 → python_cqrs-4.7.0}/src/cqrs/compressors/__init__.py +0 -0
- {python_cqrs-4.6.4 → python_cqrs-4.7.0}/src/cqrs/compressors/protocol.py +0 -0
- {python_cqrs-4.6.4 → python_cqrs-4.7.0}/src/cqrs/compressors/zlib.py +0 -0
- {python_cqrs-4.6.4 → python_cqrs-4.7.0}/src/cqrs/container/__init__.py +0 -0
- {python_cqrs-4.6.4 → python_cqrs-4.7.0}/src/cqrs/container/dependency_injector.py +0 -0
- {python_cqrs-4.6.4 → python_cqrs-4.7.0}/src/cqrs/container/di.py +0 -0
- {python_cqrs-4.6.4 → python_cqrs-4.7.0}/src/cqrs/container/protocol.py +0 -0
- {python_cqrs-4.6.4 → python_cqrs-4.7.0}/src/cqrs/deserializers/__init__.py +0 -0
- {python_cqrs-4.6.4 → python_cqrs-4.7.0}/src/cqrs/deserializers/exceptions.py +0 -0
- {python_cqrs-4.6.4 → python_cqrs-4.7.0}/src/cqrs/deserializers/json.py +0 -0
- {python_cqrs-4.6.4 → python_cqrs-4.7.0}/src/cqrs/dispatcher/__init__.py +0 -0
- {python_cqrs-4.6.4 → python_cqrs-4.7.0}/src/cqrs/dispatcher/event.py +0 -0
- {python_cqrs-4.6.4 → python_cqrs-4.7.0}/src/cqrs/dispatcher/exceptions.py +0 -0
- {python_cqrs-4.6.4 → python_cqrs-4.7.0}/src/cqrs/dispatcher/models.py +0 -0
- {python_cqrs-4.6.4 → python_cqrs-4.7.0}/src/cqrs/dispatcher/request.py +0 -0
- {python_cqrs-4.6.4 → python_cqrs-4.7.0}/src/cqrs/dispatcher/saga.py +0 -0
- {python_cqrs-4.6.4 → python_cqrs-4.7.0}/src/cqrs/dispatcher/streaming.py +0 -0
- {python_cqrs-4.6.4 → python_cqrs-4.7.0}/src/cqrs/events/__init__.py +0 -0
- {python_cqrs-4.6.4 → python_cqrs-4.7.0}/src/cqrs/events/bootstrap.py +0 -0
- {python_cqrs-4.6.4 → python_cqrs-4.7.0}/src/cqrs/events/event.py +0 -0
- {python_cqrs-4.6.4 → python_cqrs-4.7.0}/src/cqrs/events/event_emitter.py +0 -0
- {python_cqrs-4.6.4 → python_cqrs-4.7.0}/src/cqrs/events/event_handler.py +0 -0
- {python_cqrs-4.6.4 → python_cqrs-4.7.0}/src/cqrs/events/event_processor.py +0 -0
- {python_cqrs-4.6.4 → python_cqrs-4.7.0}/src/cqrs/events/map.py +0 -0
- {python_cqrs-4.6.4 → python_cqrs-4.7.0}/src/cqrs/mediator.py +0 -0
- {python_cqrs-4.6.4 → python_cqrs-4.7.0}/src/cqrs/message_brokers/__init__.py +0 -0
- {python_cqrs-4.6.4 → python_cqrs-4.7.0}/src/cqrs/message_brokers/amqp.py +0 -0
- {python_cqrs-4.6.4 → python_cqrs-4.7.0}/src/cqrs/message_brokers/devnull.py +0 -0
- {python_cqrs-4.6.4 → python_cqrs-4.7.0}/src/cqrs/message_brokers/kafka.py +0 -0
- {python_cqrs-4.6.4 → python_cqrs-4.7.0}/src/cqrs/message_brokers/protocol.py +0 -0
- {python_cqrs-4.6.4 → python_cqrs-4.7.0}/src/cqrs/middlewares/__init__.py +0 -0
- {python_cqrs-4.6.4 → python_cqrs-4.7.0}/src/cqrs/middlewares/base.py +0 -0
- {python_cqrs-4.6.4 → python_cqrs-4.7.0}/src/cqrs/middlewares/logging.py +0 -0
- {python_cqrs-4.6.4 → python_cqrs-4.7.0}/src/cqrs/outbox/__init__.py +0 -0
- {python_cqrs-4.6.4 → python_cqrs-4.7.0}/src/cqrs/outbox/map.py +0 -0
- {python_cqrs-4.6.4 → python_cqrs-4.7.0}/src/cqrs/outbox/mock.py +0 -0
- {python_cqrs-4.6.4 → python_cqrs-4.7.0}/src/cqrs/outbox/repository.py +0 -0
- {python_cqrs-4.6.4 → python_cqrs-4.7.0}/src/cqrs/outbox/sqlalchemy.py +0 -0
- {python_cqrs-4.6.4 → python_cqrs-4.7.0}/src/cqrs/producer.py +0 -0
- {python_cqrs-4.6.4 → python_cqrs-4.7.0}/src/cqrs/requests/__init__.py +0 -0
- {python_cqrs-4.6.4 → python_cqrs-4.7.0}/src/cqrs/requests/bootstrap.py +0 -0
- {python_cqrs-4.6.4 → python_cqrs-4.7.0}/src/cqrs/requests/cor_request_handler.py +0 -0
- {python_cqrs-4.6.4 → python_cqrs-4.7.0}/src/cqrs/requests/map.py +0 -0
- {python_cqrs-4.6.4 → python_cqrs-4.7.0}/src/cqrs/requests/mermaid.py +0 -0
- {python_cqrs-4.6.4 → python_cqrs-4.7.0}/src/cqrs/requests/request.py +0 -0
- {python_cqrs-4.6.4 → python_cqrs-4.7.0}/src/cqrs/requests/request_handler.py +0 -0
- {python_cqrs-4.6.4 → python_cqrs-4.7.0}/src/cqrs/response.py +0 -0
- {python_cqrs-4.6.4 → python_cqrs-4.7.0}/src/cqrs/saga/__init__.py +0 -0
- {python_cqrs-4.6.4 → python_cqrs-4.7.0}/src/cqrs/saga/bootstrap.py +0 -0
- {python_cqrs-4.6.4 → python_cqrs-4.7.0}/src/cqrs/saga/circuit_breaker.py +0 -0
- {python_cqrs-4.6.4 → python_cqrs-4.7.0}/src/cqrs/saga/compensation.py +0 -0
- {python_cqrs-4.6.4 → python_cqrs-4.7.0}/src/cqrs/saga/execution.py +0 -0
- {python_cqrs-4.6.4 → python_cqrs-4.7.0}/src/cqrs/saga/fallback.py +0 -0
- {python_cqrs-4.6.4 → python_cqrs-4.7.0}/src/cqrs/saga/mermaid.py +0 -0
- {python_cqrs-4.6.4 → python_cqrs-4.7.0}/src/cqrs/saga/step.py +0 -0
- {python_cqrs-4.6.4 → python_cqrs-4.7.0}/src/cqrs/saga/storage/__init__.py +0 -0
- {python_cqrs-4.6.4 → python_cqrs-4.7.0}/src/cqrs/saga/storage/enums.py +0 -0
- {python_cqrs-4.6.4 → python_cqrs-4.7.0}/src/cqrs/saga/storage/models.py +0 -0
- {python_cqrs-4.6.4 → python_cqrs-4.7.0}/src/cqrs/saga/validation.py +0 -0
- {python_cqrs-4.6.4 → python_cqrs-4.7.0}/src/cqrs/serializers/__init__.py +0 -0
- {python_cqrs-4.6.4 → python_cqrs-4.7.0}/src/cqrs/serializers/default.py +0 -0
- {python_cqrs-4.6.4 → python_cqrs-4.7.0}/src/cqrs/types.py +0 -0
- {python_cqrs-4.6.4 → python_cqrs-4.7.0}/src/python_cqrs.egg-info/SOURCES.txt +0 -0
- {python_cqrs-4.6.4 → python_cqrs-4.7.0}/src/python_cqrs.egg-info/dependency_links.txt +0 -0
- {python_cqrs-4.6.4 → python_cqrs-4.7.0}/src/python_cqrs.egg-info/requires.txt +0 -0
- {python_cqrs-4.6.4 → python_cqrs-4.7.0}/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.
|
|
3
|
+
Version: 4.7.0
|
|
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>
|
|
@@ -97,7 +97,10 @@ Dynamic: license-file
|
|
|
97
97
|
</p>
|
|
98
98
|
</div>
|
|
99
99
|
|
|
100
|
-
|
|
100
|
+
> [!WARNING]
|
|
101
|
+
> **Breaking Changes in v5.0.0**
|
|
102
|
+
>
|
|
103
|
+
> Starting with version 5.0.0, Pydantic support will become optional. The default implementations of `Request`, `Response`, `DomainEvent`, and `NotificationEvent` will be migrated to dataclasses-based implementations.
|
|
101
104
|
|
|
102
105
|
## Overview
|
|
103
106
|
|
|
@@ -36,7 +36,10 @@
|
|
|
36
36
|
</p>
|
|
37
37
|
</div>
|
|
38
38
|
|
|
39
|
-
|
|
39
|
+
> [!WARNING]
|
|
40
|
+
> **Breaking Changes in v5.0.0**
|
|
41
|
+
>
|
|
42
|
+
> Starting with version 5.0.0, Pydantic support will become optional. The default implementations of `Request`, `Response`, `DomainEvent`, and `NotificationEvent` will be migrated to dataclasses-based implementations.
|
|
40
43
|
|
|
41
44
|
## Overview
|
|
42
45
|
|
|
@@ -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.
|
|
34
|
+
version = "4.7.0"
|
|
35
35
|
|
|
36
36
|
[project.optional-dependencies]
|
|
37
37
|
aiobreaker = ["aiobreaker>=0.3.0"]
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import dataclasses
|
|
2
2
|
import typing
|
|
3
|
+
from dataclass_wizard import asdict, fromdict
|
|
3
4
|
|
|
4
5
|
# Type variable for from_dict classmethod return type
|
|
5
6
|
_T = typing.TypeVar("_T", bound="SagaContext")
|
|
@@ -29,7 +30,7 @@ class SagaContext:
|
|
|
29
30
|
Returns:
|
|
30
31
|
Dictionary representation of the context.
|
|
31
32
|
"""
|
|
32
|
-
return
|
|
33
|
+
return asdict(self)
|
|
33
34
|
|
|
34
35
|
@classmethod
|
|
35
36
|
def from_dict(cls: type[_T], data: dict[str, typing.Any]) -> _T:
|
|
@@ -42,11 +43,11 @@ class SagaContext:
|
|
|
42
43
|
Returns:
|
|
43
44
|
Instance of the context class.
|
|
44
45
|
"""
|
|
45
|
-
# Get field names from dataclass
|
|
46
|
-
field_names = {f.name for f in dataclasses.fields(cls)}
|
|
47
|
-
# Filter data to only include known fields
|
|
48
|
-
filtered_data = {k: v for k, v in data.items() if k in field_names}
|
|
49
|
-
return cls
|
|
46
|
+
# # Get field names from dataclass
|
|
47
|
+
# field_names = {f.name for f in dataclasses.fields(cls)}
|
|
48
|
+
# # Filter data to only include known fields
|
|
49
|
+
# filtered_data = {k: v for k, v in data.items() if k in field_names}
|
|
50
|
+
return fromdict(cls, data)
|
|
50
51
|
|
|
51
52
|
def model_dump(self) -> dict[str, typing.Any]:
|
|
52
53
|
"""
|
|
@@ -29,6 +29,11 @@ async def recover_saga(
|
|
|
29
29
|
Already completed steps will be skipped.
|
|
30
30
|
If the saga was in a compensating state, compensation will resume.
|
|
31
31
|
|
|
32
|
+
On recovery failure (exception during resume), the storage's
|
|
33
|
+
increment_recovery_attempts is called automatically so the saga can be
|
|
34
|
+
retried or excluded by get_sagas_for_recovery(max_recovery_attempts=...).
|
|
35
|
+
Callers do not need to call increment_recovery_attempts themselves.
|
|
36
|
+
|
|
32
37
|
Args:
|
|
33
38
|
saga: The saga orchestrator instance.
|
|
34
39
|
saga_id: The ID of the saga to recover.
|
|
@@ -109,11 +114,12 @@ async def recover_saga(
|
|
|
109
114
|
)
|
|
110
115
|
# Re-raise to allow callers to handle this case
|
|
111
116
|
raise
|
|
112
|
-
# For other RuntimeErrors,
|
|
117
|
+
# For other RuntimeErrors, recovery failed: increment attempts and re-raise
|
|
113
118
|
logger.error(f"Saga {saga_id} recovery ended with error: {e}")
|
|
119
|
+
await storage.increment_recovery_attempts(saga_id, new_status=SagaStatus.FAILED)
|
|
114
120
|
raise
|
|
115
121
|
except Exception as e:
|
|
116
122
|
logger.error(f"Saga {saga_id} recovery ended with error: {e}")
|
|
117
|
-
#
|
|
118
|
-
|
|
123
|
+
# Recovery failed: increment attempts so saga can be retried or excluded later
|
|
124
|
+
await storage.increment_recovery_attempts(saga_id, new_status=SagaStatus.FAILED)
|
|
119
125
|
raise
|
|
@@ -142,9 +142,14 @@ class SagaTransaction(typing.Generic[ContextT]):
|
|
|
142
142
|
exc_val: BaseException | None,
|
|
143
143
|
exc_tb: types.TracebackType | None,
|
|
144
144
|
) -> bool:
|
|
145
|
-
# If an exception occurred, compensate all completed steps
|
|
146
|
-
#
|
|
147
|
-
|
|
145
|
+
# If an exception occurred, compensate all completed steps.
|
|
146
|
+
# Do not compensate on GeneratorExit: consumer stopped iteration intentionally
|
|
147
|
+
# (e.g. to resume later), which is not a failure.
|
|
148
|
+
if (
|
|
149
|
+
exc_val is not None
|
|
150
|
+
and exc_type is not GeneratorExit
|
|
151
|
+
and not self._compensated
|
|
152
|
+
):
|
|
148
153
|
self._error = exc_val
|
|
149
154
|
await self._compensate()
|
|
150
155
|
return False # Don't suppress the exception
|
|
@@ -302,6 +307,8 @@ class SagaTransaction(typing.Generic[ContextT]):
|
|
|
302
307
|
|
|
303
308
|
yield step_result
|
|
304
309
|
|
|
310
|
+
# Update context one final time before marking as completed
|
|
311
|
+
await self._state_manager.update_context(self._context)
|
|
305
312
|
await self._state_manager.update_status(SagaStatus.COMPLETED)
|
|
306
313
|
|
|
307
314
|
except Exception as e:
|
|
@@ -37,6 +37,7 @@ class MemorySagaStorage(ISagaStorage):
|
|
|
37
37
|
"created_at": now,
|
|
38
38
|
"updated_at": now,
|
|
39
39
|
"version": 1,
|
|
40
|
+
"recovery_attempts": 0,
|
|
40
41
|
}
|
|
41
42
|
self._logs[saga_id] = []
|
|
42
43
|
|
|
@@ -115,3 +116,40 @@ class MemorySagaStorage(ISagaStorage):
|
|
|
115
116
|
return []
|
|
116
117
|
# Sort by timestamp
|
|
117
118
|
return sorted(self._logs[saga_id], key=lambda x: x.timestamp)
|
|
119
|
+
|
|
120
|
+
async def get_sagas_for_recovery(
|
|
121
|
+
self,
|
|
122
|
+
limit: int,
|
|
123
|
+
max_recovery_attempts: int = 5,
|
|
124
|
+
stale_after_seconds: int | None = None,
|
|
125
|
+
) -> list[uuid.UUID]:
|
|
126
|
+
recoverable = (SagaStatus.RUNNING, SagaStatus.COMPENSATING, SagaStatus.FAILED)
|
|
127
|
+
now = datetime.datetime.now(datetime.timezone.utc)
|
|
128
|
+
threshold = (
|
|
129
|
+
(now - datetime.timedelta(seconds=stale_after_seconds))
|
|
130
|
+
if stale_after_seconds is not None
|
|
131
|
+
else None
|
|
132
|
+
)
|
|
133
|
+
candidates = [
|
|
134
|
+
sid
|
|
135
|
+
for sid, data in self._sagas.items()
|
|
136
|
+
if data["status"] in recoverable
|
|
137
|
+
and data.get("recovery_attempts", 0) < max_recovery_attempts
|
|
138
|
+
and (threshold is None or data["updated_at"] < threshold)
|
|
139
|
+
]
|
|
140
|
+
candidates.sort(key=lambda sid: self._sagas[sid]["updated_at"])
|
|
141
|
+
return candidates[:limit]
|
|
142
|
+
|
|
143
|
+
async def increment_recovery_attempts(
|
|
144
|
+
self,
|
|
145
|
+
saga_id: uuid.UUID,
|
|
146
|
+
new_status: SagaStatus | None = None,
|
|
147
|
+
) -> None:
|
|
148
|
+
if saga_id not in self._sagas:
|
|
149
|
+
raise ValueError(f"Saga {saga_id} not found")
|
|
150
|
+
data = self._sagas[saga_id]
|
|
151
|
+
data["recovery_attempts"] = data.get("recovery_attempts", 0) + 1
|
|
152
|
+
data["updated_at"] = datetime.datetime.now(datetime.timezone.utc)
|
|
153
|
+
data["version"] += 1
|
|
154
|
+
if new_status is not None:
|
|
155
|
+
data["status"] = new_status
|
|
@@ -70,3 +70,43 @@ class ISagaStorage(abc.ABC):
|
|
|
70
70
|
saga_id: uuid.UUID,
|
|
71
71
|
) -> list[SagaLogEntry]:
|
|
72
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
|
+
"""
|
|
@@ -78,6 +78,13 @@ class SagaExecutionModel(Base):
|
|
|
78
78
|
onupdate=func.now(),
|
|
79
79
|
comment="Last update timestamp",
|
|
80
80
|
)
|
|
81
|
+
recovery_attempts = sqlalchemy.Column(
|
|
82
|
+
sqlalchemy.Integer,
|
|
83
|
+
nullable=False,
|
|
84
|
+
default=0,
|
|
85
|
+
server_default=sqlalchemy.text("0"),
|
|
86
|
+
comment="Number of recovery attempts",
|
|
87
|
+
)
|
|
81
88
|
|
|
82
89
|
|
|
83
90
|
class SagaLogModel(Base):
|
|
@@ -143,6 +150,7 @@ class SqlAlchemySagaStorage(ISagaStorage):
|
|
|
143
150
|
status=SagaStatus.PENDING,
|
|
144
151
|
context=context,
|
|
145
152
|
version=1,
|
|
153
|
+
recovery_attempts=0,
|
|
146
154
|
)
|
|
147
155
|
session.add(execution)
|
|
148
156
|
await session.commit()
|
|
@@ -303,3 +311,57 @@ class SqlAlchemySagaStorage(ISagaStorage):
|
|
|
303
311
|
)
|
|
304
312
|
for row in rows
|
|
305
313
|
]
|
|
314
|
+
|
|
315
|
+
async def get_sagas_for_recovery(
|
|
316
|
+
self,
|
|
317
|
+
limit: int,
|
|
318
|
+
max_recovery_attempts: int = 5,
|
|
319
|
+
stale_after_seconds: int | None = None,
|
|
320
|
+
) -> list[uuid.UUID]:
|
|
321
|
+
recoverable = (
|
|
322
|
+
SagaStatus.RUNNING,
|
|
323
|
+
SagaStatus.COMPENSATING,
|
|
324
|
+
SagaStatus.FAILED,
|
|
325
|
+
)
|
|
326
|
+
async with self.session_factory() as session:
|
|
327
|
+
stmt = (
|
|
328
|
+
sqlalchemy.select(SagaExecutionModel.id)
|
|
329
|
+
.where(SagaExecutionModel.status.in_(recoverable))
|
|
330
|
+
.where(SagaExecutionModel.recovery_attempts < max_recovery_attempts)
|
|
331
|
+
)
|
|
332
|
+
if stale_after_seconds is not None:
|
|
333
|
+
threshold = datetime.datetime.now(
|
|
334
|
+
datetime.timezone.utc,
|
|
335
|
+
) - datetime.timedelta(
|
|
336
|
+
seconds=stale_after_seconds,
|
|
337
|
+
)
|
|
338
|
+
stmt = stmt.where(SagaExecutionModel.updated_at < threshold)
|
|
339
|
+
stmt = stmt.order_by(SagaExecutionModel.updated_at.asc()).limit(limit)
|
|
340
|
+
result = await session.execute(stmt)
|
|
341
|
+
rows = result.scalars().all()
|
|
342
|
+
return [typing.cast(uuid.UUID, row) for row in rows]
|
|
343
|
+
|
|
344
|
+
async def increment_recovery_attempts(
|
|
345
|
+
self,
|
|
346
|
+
saga_id: uuid.UUID,
|
|
347
|
+
new_status: SagaStatus | None = None,
|
|
348
|
+
) -> None:
|
|
349
|
+
async with self.session_factory() as session:
|
|
350
|
+
try:
|
|
351
|
+
values: dict[str, typing.Any] = {
|
|
352
|
+
"recovery_attempts": SagaExecutionModel.recovery_attempts + 1,
|
|
353
|
+
"version": SagaExecutionModel.version + 1,
|
|
354
|
+
}
|
|
355
|
+
if new_status is not None:
|
|
356
|
+
values["status"] = new_status
|
|
357
|
+
result = await session.execute(
|
|
358
|
+
sqlalchemy.update(SagaExecutionModel)
|
|
359
|
+
.where(SagaExecutionModel.id == saga_id)
|
|
360
|
+
.values(**values),
|
|
361
|
+
)
|
|
362
|
+
if result.rowcount == 0: # type: ignore[attr-defined]
|
|
363
|
+
raise ValueError(f"Saga {saga_id} not found")
|
|
364
|
+
await session.commit()
|
|
365
|
+
except SQLAlchemyError:
|
|
366
|
+
await session.rollback()
|
|
367
|
+
raise
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-cqrs
|
|
3
|
-
Version: 4.
|
|
3
|
+
Version: 4.7.0
|
|
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>
|
|
@@ -97,7 +97,10 @@ Dynamic: license-file
|
|
|
97
97
|
</p>
|
|
98
98
|
</div>
|
|
99
99
|
|
|
100
|
-
|
|
100
|
+
> [!WARNING]
|
|
101
|
+
> **Breaking Changes in v5.0.0**
|
|
102
|
+
>
|
|
103
|
+
> Starting with version 5.0.0, Pydantic support will become optional. The default implementations of `Request`, `Response`, `DomainEvent`, and `NotificationEvent` will be migrated to dataclasses-based implementations.
|
|
101
104
|
|
|
102
105
|
## Overview
|
|
103
106
|
|
|
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
|