python-cqrs 5.2.0__py3-none-any.whl → 5.3.0__py3-none-any.whl
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.
- cqrs/__init__.py +33 -0
- cqrs/inbox/__init__.py +21 -0
- cqrs/inbox/map.py +201 -0
- cqrs/inbox/memory.py +110 -0
- cqrs/inbox/processor.py +116 -0
- cqrs/inbox/repository.py +149 -0
- cqrs/inbox/sqlalchemy.py +322 -0
- cqrs/inbox/sqlmodel.py +164 -0
- {python_cqrs-5.2.0.dist-info → python_cqrs-5.3.0.dist-info}/METADATA +2 -1
- {python_cqrs-5.2.0.dist-info → python_cqrs-5.3.0.dist-info}/RECORD +13 -6
- {python_cqrs-5.2.0.dist-info → python_cqrs-5.3.0.dist-info}/WHEEL +0 -0
- {python_cqrs-5.2.0.dist-info → python_cqrs-5.3.0.dist-info}/licenses/LICENSE +0 -0
- {python_cqrs-5.2.0.dist-info → python_cqrs-5.3.0.dist-info}/top_level.txt +0 -0
cqrs/__init__.py
CHANGED
|
@@ -46,6 +46,14 @@ Outbox on ``cqrs``: ``OutboxedEventRepository``, ``OutboxedEventMap``,
|
|
|
46
46
|
the ``sqlmodel`` extra. Codecs on ``cqrs``: ``JsonEventSerializer``,
|
|
47
47
|
``ProtobufEventSerializer`` (extra ``protobuf``).
|
|
48
48
|
|
|
49
|
+
Inbox on ``cqrs``: ``InboxRepository``, ``InboxMessage``, ``InboxStatus``,
|
|
50
|
+
``InboxMessageMap``, ``InboxProcessor``. ``SqlAlchemyInboxRepository`` and
|
|
51
|
+
``rebind_inbox_model`` are lazy and need the ``sqlalchemy`` extra.
|
|
52
|
+
``SqlModelInboxRepository`` is lazy and needs the ``sqlmodel`` extra.
|
|
53
|
+
Presentation calls ``add`` with a batch. ``InboxProcessor`` reads rows and
|
|
54
|
+
calls the mediator that is already registered. FastAPI and FastStream are
|
|
55
|
+
not imported here.
|
|
56
|
+
|
|
49
57
|
Brokers are not on ``cqrs``. Kafka is
|
|
50
58
|
``cqrs.adapters.kafka.kafka_producer_factory`` plus
|
|
51
59
|
``cqrs.message_brokers.kafka.KafkaMessageBroker`` (extra ``kafka``). AMQP is
|
|
@@ -79,6 +87,11 @@ if typing.TYPE_CHECKING:
|
|
|
79
87
|
)
|
|
80
88
|
from cqrs.models.pydantic import PydanticRequest
|
|
81
89
|
from cqrs.models.pydantic import PydanticResponse
|
|
90
|
+
from cqrs.inbox.sqlalchemy import (
|
|
91
|
+
SqlAlchemyInboxRepository,
|
|
92
|
+
rebind_inbox_model,
|
|
93
|
+
)
|
|
94
|
+
from cqrs.inbox.sqlmodel import SqlModelInboxRepository
|
|
82
95
|
from cqrs.outbox.sqlalchemy import (
|
|
83
96
|
SqlAlchemyOutboxedEventRepository,
|
|
84
97
|
rebind_outbox_model,
|
|
@@ -126,6 +139,9 @@ from cqrs.mediators import (
|
|
|
126
139
|
SagaMediator,
|
|
127
140
|
StreamingRequestMediator,
|
|
128
141
|
)
|
|
142
|
+
from cqrs.inbox.map import InboxMessageMap
|
|
143
|
+
from cqrs.inbox.processor import InboxProcessor
|
|
144
|
+
from cqrs.inbox.repository import InboxMessage, InboxRepository, InboxStatus
|
|
129
145
|
from cqrs.mapping.outbox import OutboxedEventMap
|
|
130
146
|
from cqrs.outbox.repository import (
|
|
131
147
|
EventStatus,
|
|
@@ -202,6 +218,13 @@ __all__ = (
|
|
|
202
218
|
"OutboxedEventRepository",
|
|
203
219
|
"SqlAlchemyOutboxedEventRepository",
|
|
204
220
|
"SqlModelOutboxedEventRepository",
|
|
221
|
+
"InboxRepository",
|
|
222
|
+
"InboxMessage",
|
|
223
|
+
"InboxStatus",
|
|
224
|
+
"InboxMessageMap",
|
|
225
|
+
"InboxProcessor",
|
|
226
|
+
"SqlAlchemyInboxRepository",
|
|
227
|
+
"SqlModelInboxRepository",
|
|
205
228
|
"EventProducer",
|
|
206
229
|
"EventSerializer",
|
|
207
230
|
"EventCodec",
|
|
@@ -220,6 +243,7 @@ __all__ = (
|
|
|
220
243
|
"Compressor",
|
|
221
244
|
"ZlibCompressor",
|
|
222
245
|
"rebind_outbox_model",
|
|
246
|
+
"rebind_inbox_model",
|
|
223
247
|
"Saga",
|
|
224
248
|
"SagaStepHandler",
|
|
225
249
|
"SagaStepResult",
|
|
@@ -252,6 +276,11 @@ _LAZY_SQLALCHEMY = {
|
|
|
252
276
|
"SqlAlchemyOutboxedEventRepository",
|
|
253
277
|
),
|
|
254
278
|
"rebind_outbox_model": ("cqrs.outbox.sqlalchemy", "rebind_outbox_model"),
|
|
279
|
+
"SqlAlchemyInboxRepository": (
|
|
280
|
+
"cqrs.inbox.sqlalchemy",
|
|
281
|
+
"SqlAlchemyInboxRepository",
|
|
282
|
+
),
|
|
283
|
+
"rebind_inbox_model": ("cqrs.inbox.sqlalchemy", "rebind_inbox_model"),
|
|
255
284
|
"DialectAwareType": ("cqrs.sqlalchemy_types", "DialectAwareType"),
|
|
256
285
|
"DialectTypeHandler": ("cqrs.sqlalchemy_types", "DialectTypeHandler"),
|
|
257
286
|
"UUIDBinary": ("cqrs.sqlalchemy_types", "UUIDBinary"),
|
|
@@ -265,6 +294,10 @@ _LAZY_SQLMODEL = {
|
|
|
265
294
|
"cqrs.outbox.sqlmodel",
|
|
266
295
|
"SqlModelOutboxedEventRepository",
|
|
267
296
|
),
|
|
297
|
+
"SqlModelInboxRepository": (
|
|
298
|
+
"cqrs.inbox.sqlmodel",
|
|
299
|
+
"SqlModelInboxRepository",
|
|
300
|
+
),
|
|
268
301
|
}
|
|
269
302
|
|
|
270
303
|
|
cqrs/inbox/__init__.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Transactional inbox.
|
|
3
|
+
|
|
4
|
+
[AI Instruction]
|
|
5
|
+
Import ``InboxRepository``, ``InboxMessage``, ``InboxStatus``,
|
|
6
|
+
``InboxMessageMap``, and ``InboxProcessor`` from ``cqrs``. SQLAlchemy and
|
|
7
|
+
SQLModel repositories are lazy. Presentation calls ``add``. A worker calls
|
|
8
|
+
``InboxProcessor``.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from cqrs.inbox.map import InboxMessageMap
|
|
12
|
+
from cqrs.inbox.processor import InboxProcessor
|
|
13
|
+
from cqrs.inbox.repository import InboxMessage, InboxRepository, InboxStatus
|
|
14
|
+
|
|
15
|
+
__all__ = (
|
|
16
|
+
"InboxMessage",
|
|
17
|
+
"InboxMessageMap",
|
|
18
|
+
"InboxProcessor",
|
|
19
|
+
"InboxRepository",
|
|
20
|
+
"InboxStatus",
|
|
21
|
+
)
|
cqrs/inbox/map.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Name registry for inbox messages.
|
|
3
|
+
|
|
4
|
+
[AI Instruction]
|
|
5
|
+
Register each request or event name before ``InboxRepository.add``. JSON is
|
|
6
|
+
the default codec. A second ``register`` of the same name raises ``KeyError``.
|
|
7
|
+
Prefer an instance when tests must not share the process-global map.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import functools
|
|
11
|
+
import typing
|
|
12
|
+
|
|
13
|
+
import orjson
|
|
14
|
+
|
|
15
|
+
from cqrs.inbox.repository import InboxEntry
|
|
16
|
+
from cqrs.models.event import IEvent
|
|
17
|
+
from cqrs.models.request import IRequest
|
|
18
|
+
|
|
19
|
+
_KT = typing.TypeVar("_KT")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class _JsonMessageSerializer:
|
|
23
|
+
"""JSON codec. Wire format is ``orjson.dumps(message.to_dict())``."""
|
|
24
|
+
|
|
25
|
+
def serialize(self, message: InboxEntry) -> bytes:
|
|
26
|
+
return orjson.dumps(message.to_dict())
|
|
27
|
+
|
|
28
|
+
def deserialize(self, payload: bytes, message_type: typing.Type[_KT]) -> _KT:
|
|
29
|
+
data = orjson.loads(payload)
|
|
30
|
+
if not isinstance(data, dict):
|
|
31
|
+
raise TypeError("inbox payload must be a JSON object")
|
|
32
|
+
return message_type.from_dict(**data) # type: ignore[attr-defined]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class MessageCodec(typing.Protocol):
|
|
36
|
+
"""Serialize and deserialize one inbox message type."""
|
|
37
|
+
|
|
38
|
+
def serialize(self, message: InboxEntry) -> bytes: ...
|
|
39
|
+
|
|
40
|
+
def deserialize(self, payload: bytes, message_type: typing.Type[typing.Any]) -> typing.Any: ...
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class _bind_self_or_singleton:
|
|
44
|
+
"""Class access binds the process singleton; instance access binds that map."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, func: typing.Callable) -> None:
|
|
47
|
+
self.func = func
|
|
48
|
+
|
|
49
|
+
def __get__(
|
|
50
|
+
self,
|
|
51
|
+
obj: typing.Any,
|
|
52
|
+
objtype: type | None = None,
|
|
53
|
+
) -> typing.Callable:
|
|
54
|
+
target = obj if obj is not None else objtype._singleton() # type: ignore[union-attr]
|
|
55
|
+
return functools.partial(self.func, target)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def message_name(message: InboxEntry) -> str:
|
|
59
|
+
"""
|
|
60
|
+
Name stored with the row.
|
|
61
|
+
|
|
62
|
+
Notification events use ``event_name``, the same string the outbox stores.
|
|
63
|
+
Requests and domain events use the class name.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
event_name = getattr(message, "event_name", None)
|
|
67
|
+
if isinstance(event_name, str) and event_name:
|
|
68
|
+
return event_name
|
|
69
|
+
return type(message).__name__
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _type_matches(message_type: type, registered: typing.Any) -> bool:
|
|
73
|
+
registered_origin = typing.get_origin(registered) or registered
|
|
74
|
+
if message_type is registered:
|
|
75
|
+
return True
|
|
76
|
+
if typing.get_origin(registered) is not None and message_type is registered_origin:
|
|
77
|
+
return True
|
|
78
|
+
if isinstance(registered, type) and issubclass(message_type, registered):
|
|
79
|
+
return True
|
|
80
|
+
return False
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class InboxMessageMap:
|
|
84
|
+
"""
|
|
85
|
+
Registry of inbox message names to request or event types.
|
|
86
|
+
|
|
87
|
+
Class-level ``register`` / ``get`` use a process-global singleton.
|
|
88
|
+
``InboxMessageMap()`` creates an isolated registry.
|
|
89
|
+
|
|
90
|
+
[AI Instruction]
|
|
91
|
+
Tell the inbox which Python type belongs to a message name. Register
|
|
92
|
+
before the repository reads or writes that message. JSON is the default.
|
|
93
|
+
A second registration of the same name raises ``KeyError``. Requests and
|
|
94
|
+
domain events are registered under the class name. Notification events
|
|
95
|
+
are registered under ``event_name``.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
_instance: typing.ClassVar["InboxMessageMap | None"] = None
|
|
99
|
+
|
|
100
|
+
def __init__(self) -> None:
|
|
101
|
+
self._entries: typing.Dict[str, typing.Tuple[typing.Any, MessageCodec | None]] = {}
|
|
102
|
+
|
|
103
|
+
@classmethod
|
|
104
|
+
def _singleton(cls) -> "InboxMessageMap":
|
|
105
|
+
if cls._instance is None:
|
|
106
|
+
cls._instance = cls()
|
|
107
|
+
return cls._instance
|
|
108
|
+
|
|
109
|
+
@_bind_self_or_singleton
|
|
110
|
+
def register(
|
|
111
|
+
self,
|
|
112
|
+
message_name_: str,
|
|
113
|
+
message_type: typing.Type[InboxEntry],
|
|
114
|
+
serializer: MessageCodec | None = None,
|
|
115
|
+
) -> None:
|
|
116
|
+
"""
|
|
117
|
+
Bind ``message_name_`` to a request or event class.
|
|
118
|
+
|
|
119
|
+
[AI Instruction]
|
|
120
|
+
Call once at startup. A second registration of the same name raises
|
|
121
|
+
``KeyError``. Omit ``serializer`` for JSON.
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
if message_name_ in self._entries:
|
|
125
|
+
raise KeyError(f"Message {message_name_} already registered")
|
|
126
|
+
self._entries[message_name_] = (message_type, serializer)
|
|
127
|
+
|
|
128
|
+
@_bind_self_or_singleton
|
|
129
|
+
def get(self, message_name_: str) -> typing.Any | None:
|
|
130
|
+
"""Return the registered type, or ``None`` when the name is unknown."""
|
|
131
|
+
|
|
132
|
+
entry = self._entries.get(message_name_)
|
|
133
|
+
if entry is None:
|
|
134
|
+
return None
|
|
135
|
+
return entry[0]
|
|
136
|
+
|
|
137
|
+
@_bind_self_or_singleton
|
|
138
|
+
def get_serializer(self, message_name_: str) -> MessageCodec | None:
|
|
139
|
+
"""Return the codec registered for this name, or ``None`` for JSON."""
|
|
140
|
+
|
|
141
|
+
entry = self._entries.get(message_name_)
|
|
142
|
+
if entry is None:
|
|
143
|
+
return None
|
|
144
|
+
return entry[1]
|
|
145
|
+
|
|
146
|
+
@_bind_self_or_singleton
|
|
147
|
+
def deserialize(self, message_name_: str, payload: bytes) -> InboxEntry:
|
|
148
|
+
"""
|
|
149
|
+
Rebuild a request or event from stored bytes.
|
|
150
|
+
|
|
151
|
+
[AI Instruction]
|
|
152
|
+
``InboxProcessor`` calls this. An unknown name raises ``KeyError``
|
|
153
|
+
and does not return a message.
|
|
154
|
+
"""
|
|
155
|
+
|
|
156
|
+
entry = self._entries.get(message_name_)
|
|
157
|
+
if entry is None:
|
|
158
|
+
raise KeyError(f"Unknown message name {message_name_}")
|
|
159
|
+
message_type, serializer = entry
|
|
160
|
+
codec = serializer or _JsonMessageSerializer()
|
|
161
|
+
return codec.deserialize(payload, message_type)
|
|
162
|
+
|
|
163
|
+
@_bind_self_or_singleton
|
|
164
|
+
def payload_for(self, message: InboxEntry) -> typing.Tuple[str, bytes]:
|
|
165
|
+
"""
|
|
166
|
+
Return ``(message_name, payload)`` for a registered message.
|
|
167
|
+
|
|
168
|
+
Raises ``TypeError`` when the name is missing or the instance type
|
|
169
|
+
does not match the registration.
|
|
170
|
+
"""
|
|
171
|
+
|
|
172
|
+
if not isinstance(message, (IRequest, IEvent)):
|
|
173
|
+
raise TypeError(f"Unsupported inbox message type {type(message)}")
|
|
174
|
+
name = message_name(message)
|
|
175
|
+
registered = self.get(name)
|
|
176
|
+
if registered is None:
|
|
177
|
+
raise TypeError(f"Unknown message name for {name}")
|
|
178
|
+
if not _type_matches(type(message), registered):
|
|
179
|
+
raise TypeError(
|
|
180
|
+
f"Message type {type(message)} does not match registered type {registered}",
|
|
181
|
+
)
|
|
182
|
+
codec = self.get_serializer(name) or _JsonMessageSerializer()
|
|
183
|
+
return name, codec.serialize(message)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def resolve_message_map(
|
|
187
|
+
message_map: "InboxMessageMap | None",
|
|
188
|
+
) -> InboxMessageMap:
|
|
189
|
+
"""Use the given instance, or the process singleton when ``message_map`` is omitted."""
|
|
190
|
+
|
|
191
|
+
if message_map is None or message_map is InboxMessageMap:
|
|
192
|
+
return InboxMessageMap._singleton()
|
|
193
|
+
return message_map
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
__all__ = (
|
|
197
|
+
"InboxMessageMap",
|
|
198
|
+
"MessageCodec",
|
|
199
|
+
"message_name",
|
|
200
|
+
"resolve_message_map",
|
|
201
|
+
)
|
cqrs/inbox/memory.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""In-memory inbox repository for unit tests."""
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import dataclasses
|
|
5
|
+
import datetime
|
|
6
|
+
import typing
|
|
7
|
+
|
|
8
|
+
from cqrs.inbox.map import InboxMessageMap, resolve_message_map
|
|
9
|
+
from cqrs.inbox.repository import (
|
|
10
|
+
MAX_FLUSH_COUNTER_VALUE,
|
|
11
|
+
InboxEntry,
|
|
12
|
+
InboxMessage,
|
|
13
|
+
InboxRepository,
|
|
14
|
+
InboxStatus,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class MemoryInboxRepository(InboxRepository):
|
|
19
|
+
"""
|
|
20
|
+
Process-local inbox.
|
|
21
|
+
|
|
22
|
+
``add`` is visible to ``get_many`` on this instance before ``commit``,
|
|
23
|
+
matching a session that has flushed and not committed. ``rollback``
|
|
24
|
+
restores the last ``commit``. ``nested`` snapshots the rows and restores
|
|
25
|
+
them when the block raises.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self, message_map: InboxMessageMap | None = None) -> None:
|
|
29
|
+
self._message_map = resolve_message_map(message_map)
|
|
30
|
+
self._rows: typing.List[InboxMessage] = []
|
|
31
|
+
self._checkpoint: typing.List[InboxMessage] = []
|
|
32
|
+
self._next_id = 1
|
|
33
|
+
|
|
34
|
+
async def add(
|
|
35
|
+
self,
|
|
36
|
+
messages: typing.Sequence[typing.Tuple[str, InboxEntry]],
|
|
37
|
+
) -> typing.List[bool]:
|
|
38
|
+
if not messages:
|
|
39
|
+
return []
|
|
40
|
+
stored = {(row.message_id, row.message_name) for row in self._rows}
|
|
41
|
+
results: typing.List[bool] = []
|
|
42
|
+
for message_id, message in messages:
|
|
43
|
+
name, payload = self._message_map.payload_for(message)
|
|
44
|
+
key = (message_id, name)
|
|
45
|
+
if key in stored:
|
|
46
|
+
results.append(False)
|
|
47
|
+
continue
|
|
48
|
+
self._rows.append(
|
|
49
|
+
InboxMessage(
|
|
50
|
+
id=self._next_id,
|
|
51
|
+
message_id=message_id,
|
|
52
|
+
message_name=name,
|
|
53
|
+
status=InboxStatus.NEW,
|
|
54
|
+
flush_counter=0,
|
|
55
|
+
created_at=datetime.datetime.now(),
|
|
56
|
+
payload=payload,
|
|
57
|
+
),
|
|
58
|
+
)
|
|
59
|
+
self._next_id += 1
|
|
60
|
+
stored.add(key)
|
|
61
|
+
results.append(True)
|
|
62
|
+
return results
|
|
63
|
+
|
|
64
|
+
async def get_many(self, batch_size: int = 100) -> typing.List[InboxMessage]:
|
|
65
|
+
selectable = [
|
|
66
|
+
row
|
|
67
|
+
for row in self._rows
|
|
68
|
+
if row.status in (InboxStatus.NEW, InboxStatus.NOT_PROCESSED)
|
|
69
|
+
and row.flush_counter < MAX_FLUSH_COUNTER_VALUE
|
|
70
|
+
]
|
|
71
|
+
selectable.sort(key=lambda row: row.id)
|
|
72
|
+
return selectable[:batch_size]
|
|
73
|
+
|
|
74
|
+
async def update_status(self, row_id: int, new_status: InboxStatus) -> None:
|
|
75
|
+
for index, row in enumerate(self._rows):
|
|
76
|
+
if row.id != row_id:
|
|
77
|
+
continue
|
|
78
|
+
flush_counter = row.flush_counter
|
|
79
|
+
if new_status is InboxStatus.NOT_PROCESSED:
|
|
80
|
+
flush_counter += 1
|
|
81
|
+
self._rows[index] = dataclasses.replace(
|
|
82
|
+
row,
|
|
83
|
+
status=new_status,
|
|
84
|
+
flush_counter=flush_counter,
|
|
85
|
+
)
|
|
86
|
+
return
|
|
87
|
+
|
|
88
|
+
def nested(self) -> contextlib.AbstractAsyncContextManager[None]:
|
|
89
|
+
return self._nested()
|
|
90
|
+
|
|
91
|
+
@contextlib.asynccontextmanager
|
|
92
|
+
async def _nested(self) -> typing.AsyncIterator[None]:
|
|
93
|
+
snapshot = [dataclasses.replace(row) for row in self._rows]
|
|
94
|
+
next_id = self._next_id
|
|
95
|
+
try:
|
|
96
|
+
yield
|
|
97
|
+
except BaseException:
|
|
98
|
+
self._rows = snapshot
|
|
99
|
+
self._next_id = next_id
|
|
100
|
+
raise
|
|
101
|
+
|
|
102
|
+
async def commit(self) -> None:
|
|
103
|
+
self._checkpoint = [dataclasses.replace(row) for row in self._rows]
|
|
104
|
+
|
|
105
|
+
async def rollback(self) -> None:
|
|
106
|
+
self._rows = [dataclasses.replace(row) for row in self._checkpoint]
|
|
107
|
+
self._next_id = max((row.id for row in self._rows), default=0) + 1
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
__all__ = ("MemoryInboxRepository",)
|
cqrs/inbox/processor.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Read inbox rows and hand them to the mediator that is already registered.
|
|
3
|
+
|
|
4
|
+
[AI Instruction]
|
|
5
|
+
Run ``InboxProcessor`` in a worker process. It does not replace
|
|
6
|
+
``repository.add`` in a route or a subscriber. ``process`` does not commit.
|
|
7
|
+
The worker commits the business rows and the status together.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import functools
|
|
11
|
+
import logging
|
|
12
|
+
import typing
|
|
13
|
+
|
|
14
|
+
from cqrs.inbox.map import InboxMessageMap
|
|
15
|
+
from cqrs.inbox.repository import InboxMessage, InboxRepository, InboxStatus
|
|
16
|
+
from cqrs.models.event import IEvent
|
|
17
|
+
from cqrs.models.request import IRequest
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger("cqrs")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class InboxProcessor:
|
|
23
|
+
"""
|
|
24
|
+
Deserialize one inbox row and send it through an existing mediator.
|
|
25
|
+
|
|
26
|
+
A ``Request`` goes to ``RequestMediator.send``. An event goes to
|
|
27
|
+
``EventMediator.send``. Each row has its own savepoint: success stores
|
|
28
|
+
``processed`` inside that savepoint, and a failure stores ``not_processed``
|
|
29
|
+
on the outer transaction after the savepoint rolls back.
|
|
30
|
+
|
|
31
|
+
[AI Instruction]
|
|
32
|
+
Construct it with the repository for this batch's session, the same
|
|
33
|
+
``InboxMessageMap`` that repository uses, and the mediators bootstrap
|
|
34
|
+
already built. The caller owns the session and the commit. Do not add a
|
|
35
|
+
handler whose only job is to write the inbox row. The handler that
|
|
36
|
+
``send`` runs must not call ``session.commit``.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
repository: InboxRepository,
|
|
42
|
+
message_map: InboxMessageMap,
|
|
43
|
+
request_mediator: typing.Any | None = None,
|
|
44
|
+
event_mediator: typing.Any | None = None,
|
|
45
|
+
) -> None:
|
|
46
|
+
self.repository = repository
|
|
47
|
+
self._map = message_map
|
|
48
|
+
self._request_mediator = request_mediator
|
|
49
|
+
self._event_mediator = event_mediator
|
|
50
|
+
|
|
51
|
+
async def batch_generator(
|
|
52
|
+
self,
|
|
53
|
+
batch_size: int = 100,
|
|
54
|
+
) -> typing.AsyncIterator[typing.List[InboxMessage]]:
|
|
55
|
+
"""
|
|
56
|
+
Yield batches until the caller stops the loop.
|
|
57
|
+
|
|
58
|
+
[AI Instruction]
|
|
59
|
+
Poll forever. Each yield is one ``get_many`` result from the
|
|
60
|
+
repository passed to ``__init__``. Process every row, then let the
|
|
61
|
+
caller commit. This generator does not commit and does not end on
|
|
62
|
+
an empty batch.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
while True:
|
|
66
|
+
yield await self.repository.get_many(batch_size=batch_size)
|
|
67
|
+
|
|
68
|
+
async def process(self, row: InboxMessage) -> None:
|
|
69
|
+
"""
|
|
70
|
+
Run one row.
|
|
71
|
+
|
|
72
|
+
[AI Instruction]
|
|
73
|
+
Call this for each row from ``get_many``. The repository is the one
|
|
74
|
+
passed to ``__init__``. A handler exception rolls back that row's
|
|
75
|
+
business writes and leaves neighbors in the batch untouched.
|
|
76
|
+
``not_processed`` increments ``flush_counter``. The row stays
|
|
77
|
+
selectable until the counter reaches five.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
message = self._map.deserialize(row.message_name, row.payload)
|
|
82
|
+
async with self.repository.nested():
|
|
83
|
+
await self._dispatch(message)
|
|
84
|
+
await self.repository.update_status(row.id, InboxStatus.PROCESSED)
|
|
85
|
+
except Exception:
|
|
86
|
+
logger.exception(
|
|
87
|
+
"inbox row %s (%s) failed",
|
|
88
|
+
row.id,
|
|
89
|
+
row.message_name,
|
|
90
|
+
)
|
|
91
|
+
await self.repository.update_status(row.id, InboxStatus.NOT_PROCESSED)
|
|
92
|
+
|
|
93
|
+
@typing.overload
|
|
94
|
+
async def _dispatch(self, message: IRequest) -> None: ...
|
|
95
|
+
|
|
96
|
+
@typing.overload
|
|
97
|
+
async def _dispatch(self, message: IEvent) -> None: ...
|
|
98
|
+
|
|
99
|
+
@functools.singledispatchmethod
|
|
100
|
+
async def _dispatch(self, message: typing.Any) -> None:
|
|
101
|
+
raise TypeError(f"Unsupported inbox message type {type(message)}")
|
|
102
|
+
|
|
103
|
+
@_dispatch.register(IRequest) # pyright: ignore[reportFunctionMemberAccess]
|
|
104
|
+
async def _dispatch_request(self, message: IRequest) -> None:
|
|
105
|
+
if self._request_mediator is None:
|
|
106
|
+
raise RuntimeError("RequestMediator is not configured")
|
|
107
|
+
await self._request_mediator.send(message)
|
|
108
|
+
|
|
109
|
+
@_dispatch.register(IEvent) # pyright: ignore[reportFunctionMemberAccess]
|
|
110
|
+
async def _dispatch_event(self, message: IEvent) -> None:
|
|
111
|
+
if self._event_mediator is None:
|
|
112
|
+
raise RuntimeError("EventMediator is not configured")
|
|
113
|
+
await self._event_mediator.send(message)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
__all__ = ("InboxProcessor",)
|
cqrs/inbox/repository.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Transactional inbox repository protocol.
|
|
3
|
+
|
|
4
|
+
[AI Instruction]
|
|
5
|
+
Presentation calls ``add`` with a batch of ``(message_id, message)`` and
|
|
6
|
+
does not commit inside ``add``. A separate process uses ``get_many`` and
|
|
7
|
+
``InboxProcessor``. There is no handler and no middleware whose only job
|
|
8
|
+
is to insert the inbox row.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import abc
|
|
12
|
+
import contextlib
|
|
13
|
+
import dataclasses
|
|
14
|
+
import datetime
|
|
15
|
+
import enum
|
|
16
|
+
import sys
|
|
17
|
+
import typing
|
|
18
|
+
|
|
19
|
+
from cqrs.models.event import IEvent
|
|
20
|
+
from cqrs.models.request import IRequest
|
|
21
|
+
|
|
22
|
+
if sys.version_info >= (3, 11):
|
|
23
|
+
StrEnum = enum.StrEnum # novm
|
|
24
|
+
else:
|
|
25
|
+
|
|
26
|
+
class StrEnum(str, enum.Enum): # type: ignore[misc]
|
|
27
|
+
"""Compatible StrEnum for Python 3.10."""
|
|
28
|
+
|
|
29
|
+
def __str__(self) -> str:
|
|
30
|
+
return self.value
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class InboxStatus(StrEnum):
|
|
34
|
+
"""
|
|
35
|
+
Lifecycle of one inbox row.
|
|
36
|
+
|
|
37
|
+
Presentation inserts ``NEW``. ``InboxProcessor`` moves a row to
|
|
38
|
+
``PROCESSED`` when the mediator finishes and to ``NOT_PROCESSED`` when
|
|
39
|
+
that attempt raises. ``NOT_PROCESSED`` increments ``flush_counter``.
|
|
40
|
+
After five failures the row leaves ``get_many``.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
NEW = "new"
|
|
44
|
+
PROCESSED = "processed"
|
|
45
|
+
NOT_PROCESSED = "not_processed"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
MAX_FLUSH_COUNTER_VALUE = 5
|
|
49
|
+
|
|
50
|
+
InboxEntry = typing.Union[IRequest, IEvent]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclasses.dataclass(frozen=True)
|
|
54
|
+
class InboxMessage:
|
|
55
|
+
"""
|
|
56
|
+
One stored inbox row, returned by ``get_many``.
|
|
57
|
+
|
|
58
|
+
Application code does not construct it. ``payload`` is the bytes written
|
|
59
|
+
by ``add``. ``InboxProcessor`` deserializes them through ``InboxMessageMap``.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
id: int
|
|
63
|
+
message_id: str
|
|
64
|
+
message_name: str
|
|
65
|
+
status: InboxStatus
|
|
66
|
+
flush_counter: int
|
|
67
|
+
created_at: datetime.datetime
|
|
68
|
+
payload: bytes
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class InboxRepository(abc.ABC):
|
|
72
|
+
"""
|
|
73
|
+
[AI Instruction]
|
|
74
|
+
Store a command or an event before a worker runs it. ``add`` always takes
|
|
75
|
+
a batch and returns ``list[bool]`` of the same length. ``True`` means the
|
|
76
|
+
row was inserted. ``False`` means ``(message_id, message_name)`` is already
|
|
77
|
+
stored, including earlier in this batch. An empty batch returns ``[]``.
|
|
78
|
+
``add`` does not commit. The caller commits that session. ``get_many`` and
|
|
79
|
+
``InboxProcessor.process`` run later, on their own session.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
@abc.abstractmethod
|
|
83
|
+
async def add(
|
|
84
|
+
self,
|
|
85
|
+
messages: typing.Sequence[typing.Tuple[str, InboxEntry]],
|
|
86
|
+
) -> typing.List[bool]:
|
|
87
|
+
"""
|
|
88
|
+
Insert a batch. One message is a batch of one element.
|
|
89
|
+
|
|
90
|
+
[AI Instruction]
|
|
91
|
+
Pass ``[(message_id, request_or_event)]``. Register every message name
|
|
92
|
+
on the ``InboxMessageMap`` this repository uses before ``add``. A
|
|
93
|
+
duplicate key returns ``False`` and does not abort the outer
|
|
94
|
+
transaction. Do not commit here.
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
@abc.abstractmethod
|
|
98
|
+
async def get_many(self, batch_size: int = 100) -> typing.List[InboxMessage]:
|
|
99
|
+
"""
|
|
100
|
+
Return up to ``batch_size`` rows that still need work.
|
|
101
|
+
|
|
102
|
+
Rows have status ``new`` or ``not_processed`` and ``flush_counter``
|
|
103
|
+
below five, ordered by ``id`` ascending. SQLAlchemy and SQLModel lock
|
|
104
|
+
those rows with ``FOR UPDATE SKIP LOCKED``. SQLite does not.
|
|
105
|
+
|
|
106
|
+
[AI Instruction]
|
|
107
|
+
Call this from the worker, not from the HTTP route or the subscriber.
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
@abc.abstractmethod
|
|
111
|
+
async def update_status(self, row_id: int, new_status: InboxStatus) -> None:
|
|
112
|
+
"""
|
|
113
|
+
Set one row's status.
|
|
114
|
+
|
|
115
|
+
``NOT_PROCESSED`` increments ``flush_counter``. ``InboxProcessor``
|
|
116
|
+
calls this. Application handlers do not.
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
@abc.abstractmethod
|
|
120
|
+
def nested(self) -> contextlib.AbstractAsyncContextManager[None]:
|
|
121
|
+
"""
|
|
122
|
+
Open a savepoint.
|
|
123
|
+
|
|
124
|
+
[AI Instruction]
|
|
125
|
+
``InboxProcessor`` runs ``send`` and the ``processed`` status update
|
|
126
|
+
inside this block. An exception rolls back only that savepoint.
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
@abc.abstractmethod
|
|
130
|
+
async def commit(self) -> None:
|
|
131
|
+
"""
|
|
132
|
+
Commit the current session.
|
|
133
|
+
|
|
134
|
+
[AI Instruction]
|
|
135
|
+
The caller commits the session. ``add`` and ``process`` do not.
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
@abc.abstractmethod
|
|
139
|
+
async def rollback(self) -> None:
|
|
140
|
+
"""Drop the current session's uncommitted inbox and business rows."""
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
__all__ = (
|
|
144
|
+
"MAX_FLUSH_COUNTER_VALUE",
|
|
145
|
+
"InboxEntry",
|
|
146
|
+
"InboxMessage",
|
|
147
|
+
"InboxRepository",
|
|
148
|
+
"InboxStatus",
|
|
149
|
+
)
|
cqrs/inbox/sqlalchemy.py
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SQLAlchemy transactional inbox.
|
|
3
|
+
|
|
4
|
+
[AI Instruction]
|
|
5
|
+
Import ``SqlAlchemyInboxRepository`` and ``rebind_inbox_model`` from
|
|
6
|
+
``cqrs``. Both are lazy and need the ``sqlalchemy`` extra. ``add`` takes a
|
|
7
|
+
batch, uses one savepoint per element, and does not commit.
|
|
8
|
+
``get_many`` locks rows with ``FOR UPDATE SKIP LOCKED`` on PostgreSQL and
|
|
9
|
+
MySQL. SQLite has no skip lock; examples create the table with
|
|
10
|
+
``create_sqlite_inbox_table`` because ``Identity()`` is not an autoincrement
|
|
11
|
+
on SQLite.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import contextlib
|
|
15
|
+
import datetime
|
|
16
|
+
import typing
|
|
17
|
+
|
|
18
|
+
from cqrs.inbox.map import InboxMessageMap, resolve_message_map
|
|
19
|
+
from cqrs.inbox.repository import (
|
|
20
|
+
MAX_FLUSH_COUNTER_VALUE,
|
|
21
|
+
InboxEntry,
|
|
22
|
+
InboxMessage,
|
|
23
|
+
InboxRepository,
|
|
24
|
+
InboxStatus,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
import sqlalchemy
|
|
29
|
+
from sqlalchemy import func
|
|
30
|
+
from sqlalchemy.exc import IntegrityError
|
|
31
|
+
from sqlalchemy.orm import DeclarativeMeta, Mapped, mapped_column, registry
|
|
32
|
+
|
|
33
|
+
from cqrs import sqlalchemy_types
|
|
34
|
+
except ImportError:
|
|
35
|
+
raise ImportError(
|
|
36
|
+
"You are trying to use the SQLAlchemy inbox implementation, "
|
|
37
|
+
"but 'sqlalchemy' is not installed. "
|
|
38
|
+
"Please install it using: pip install python-cqrs[sqlalchemy]",
|
|
39
|
+
) from None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
Base = registry().generate_base()
|
|
43
|
+
|
|
44
|
+
DEFAULT_INBOX_TABLE_NAME = "inbox"
|
|
45
|
+
|
|
46
|
+
_SKIP_LOCKED_DIALECTS = frozenset({"postgresql", "mysql", "mariadb"})
|
|
47
|
+
|
|
48
|
+
SQLITE_INBOX_DDL = """
|
|
49
|
+
CREATE TABLE IF NOT EXISTS inbox (
|
|
50
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
51
|
+
message_id VARCHAR(255) NOT NULL,
|
|
52
|
+
message_name VARCHAR(255) NOT NULL,
|
|
53
|
+
status VARCHAR(32) NOT NULL,
|
|
54
|
+
flush_counter SMALLINT NOT NULL DEFAULT 0,
|
|
55
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
|
56
|
+
payload BLOB NOT NULL,
|
|
57
|
+
CONSTRAINT inbox_message_unique_index UNIQUE (message_id, message_name)
|
|
58
|
+
)
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def create_sqlite_inbox_table(sync_connection: typing.Any) -> None:
|
|
63
|
+
"""
|
|
64
|
+
Create the inbox table on SQLite.
|
|
65
|
+
|
|
66
|
+
``InboxModel.id`` uses ``Identity()``, which SQLite renders as
|
|
67
|
+
``BIGINT NOT NULL`` without autoincrement. Examples and SQLite tests call
|
|
68
|
+
this instead of ``Base.metadata.create_all``. MySQL and PostgreSQL use
|
|
69
|
+
``Base.metadata`` as-is.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
sync_connection.exec_driver_sql(SQLITE_INBOX_DDL)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class InboxModel(Base):
|
|
76
|
+
__tablename__ = DEFAULT_INBOX_TABLE_NAME
|
|
77
|
+
|
|
78
|
+
__table_args__ = (
|
|
79
|
+
sqlalchemy.UniqueConstraint(
|
|
80
|
+
"message_id",
|
|
81
|
+
"message_name",
|
|
82
|
+
name="inbox_message_unique_index",
|
|
83
|
+
),
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
id: Mapped[int] = mapped_column(
|
|
87
|
+
sqlalchemy.BigInteger,
|
|
88
|
+
sqlalchemy.Identity(),
|
|
89
|
+
primary_key=True,
|
|
90
|
+
nullable=False,
|
|
91
|
+
autoincrement=True,
|
|
92
|
+
comment="Identity",
|
|
93
|
+
)
|
|
94
|
+
message_id: Mapped[str] = mapped_column(
|
|
95
|
+
sqlalchemy.String(255),
|
|
96
|
+
nullable=False,
|
|
97
|
+
comment="Delivery idempotency id",
|
|
98
|
+
)
|
|
99
|
+
message_name: Mapped[str] = mapped_column(
|
|
100
|
+
sqlalchemy.String(255),
|
|
101
|
+
nullable=False,
|
|
102
|
+
comment="Registered message name",
|
|
103
|
+
)
|
|
104
|
+
status: Mapped[InboxStatus] = mapped_column(
|
|
105
|
+
sqlalchemy.Enum(InboxStatus),
|
|
106
|
+
nullable=False,
|
|
107
|
+
default=InboxStatus.NEW,
|
|
108
|
+
comment="Inbox processing status",
|
|
109
|
+
)
|
|
110
|
+
flush_counter: Mapped[int] = mapped_column(
|
|
111
|
+
sqlalchemy.SmallInteger,
|
|
112
|
+
nullable=False,
|
|
113
|
+
default=0,
|
|
114
|
+
comment="Failed processing attempts",
|
|
115
|
+
)
|
|
116
|
+
created_at: Mapped[datetime.datetime] = mapped_column(
|
|
117
|
+
sqlalchemy.DateTime,
|
|
118
|
+
nullable=False,
|
|
119
|
+
default=datetime.datetime.now,
|
|
120
|
+
server_default=func.now(),
|
|
121
|
+
comment="Row creation timestamp",
|
|
122
|
+
)
|
|
123
|
+
payload: Mapped[bytes] = mapped_column(
|
|
124
|
+
sqlalchemy_types.PayloadBinary,
|
|
125
|
+
nullable=False,
|
|
126
|
+
comment="Serialized request or event",
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
@classmethod
|
|
130
|
+
def get_batch_query(cls, size: int, *, lock: bool = False) -> sqlalchemy.Select:
|
|
131
|
+
query = (
|
|
132
|
+
sqlalchemy.select(cls)
|
|
133
|
+
.where(
|
|
134
|
+
sqlalchemy.and_(
|
|
135
|
+
cls.status.in_(
|
|
136
|
+
[
|
|
137
|
+
InboxStatus.NEW,
|
|
138
|
+
InboxStatus.NOT_PROCESSED,
|
|
139
|
+
],
|
|
140
|
+
),
|
|
141
|
+
cls.flush_counter < MAX_FLUSH_COUNTER_VALUE,
|
|
142
|
+
),
|
|
143
|
+
)
|
|
144
|
+
.order_by(cls.id.asc())
|
|
145
|
+
.limit(size)
|
|
146
|
+
.execution_options(populate_existing=True)
|
|
147
|
+
)
|
|
148
|
+
if lock:
|
|
149
|
+
query = query.with_for_update(skip_locked=True)
|
|
150
|
+
return query
|
|
151
|
+
|
|
152
|
+
@classmethod
|
|
153
|
+
def update_status_query(
|
|
154
|
+
cls,
|
|
155
|
+
row_id: int,
|
|
156
|
+
status: InboxStatus,
|
|
157
|
+
) -> sqlalchemy.Update:
|
|
158
|
+
values: typing.Dict[str, typing.Any] = {
|
|
159
|
+
"status": status,
|
|
160
|
+
"flush_counter": cls.flush_counter,
|
|
161
|
+
}
|
|
162
|
+
if status == InboxStatus.NOT_PROCESSED:
|
|
163
|
+
values["flush_counter"] += 1
|
|
164
|
+
return sqlalchemy.update(cls).where(cls.id == row_id).values(**values)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class SqlAlchemyInboxRepository(InboxRepository):
|
|
168
|
+
"""
|
|
169
|
+
Inbox on a SQLAlchemy ``AsyncSession``.
|
|
170
|
+
|
|
171
|
+
[AI Instruction]
|
|
172
|
+
Construct it with the presentation or worker session, not a session
|
|
173
|
+
factory. ``add`` does not commit. Install ``python-cqrs[sqlalchemy]``.
|
|
174
|
+
Register every message name on ``InboxMessageMap`` before the first
|
|
175
|
+
write, including JSON.
|
|
176
|
+
"""
|
|
177
|
+
|
|
178
|
+
def __init__(
|
|
179
|
+
self,
|
|
180
|
+
session: typing.Any,
|
|
181
|
+
*,
|
|
182
|
+
message_map: InboxMessageMap | None = None,
|
|
183
|
+
model: typing.Any = None,
|
|
184
|
+
) -> None:
|
|
185
|
+
self.session = session
|
|
186
|
+
self._message_map = resolve_message_map(message_map)
|
|
187
|
+
self._model = InboxModel if model is None else model
|
|
188
|
+
|
|
189
|
+
async def add(
|
|
190
|
+
self,
|
|
191
|
+
messages: typing.Sequence[typing.Tuple[str, InboxEntry]],
|
|
192
|
+
) -> typing.List[bool]:
|
|
193
|
+
if not messages:
|
|
194
|
+
return []
|
|
195
|
+
await self._prepare_savepoint()
|
|
196
|
+
results: typing.List[bool] = []
|
|
197
|
+
seen: typing.Set[typing.Tuple[str, str]] = set()
|
|
198
|
+
for message_id, message in messages:
|
|
199
|
+
name, payload = self._message_map.payload_for(message)
|
|
200
|
+
key = (message_id, name)
|
|
201
|
+
if key in seen:
|
|
202
|
+
results.append(False)
|
|
203
|
+
continue
|
|
204
|
+
try:
|
|
205
|
+
async with self.session.begin_nested():
|
|
206
|
+
self.session.add(
|
|
207
|
+
self._model(
|
|
208
|
+
message_id=message_id,
|
|
209
|
+
message_name=name,
|
|
210
|
+
payload=payload,
|
|
211
|
+
status=InboxStatus.NEW,
|
|
212
|
+
flush_counter=0,
|
|
213
|
+
created_at=datetime.datetime.now(),
|
|
214
|
+
),
|
|
215
|
+
)
|
|
216
|
+
await self.session.flush()
|
|
217
|
+
except IntegrityError:
|
|
218
|
+
results.append(False)
|
|
219
|
+
continue
|
|
220
|
+
seen.add(key)
|
|
221
|
+
results.append(True)
|
|
222
|
+
return results
|
|
223
|
+
|
|
224
|
+
async def _prepare_savepoint(self) -> None:
|
|
225
|
+
"""Keep a SQLite savepoint inside the caller's transaction.
|
|
226
|
+
|
|
227
|
+
sqlite3 commits when ``RELEASE`` runs on a savepoint that started the
|
|
228
|
+
transaction. Emit ``BEGIN`` first so ``add`` and ``nested`` can roll
|
|
229
|
+
back with the outer session. PostgreSQL and MySQL already do this.
|
|
230
|
+
"""
|
|
231
|
+
|
|
232
|
+
bind = self.session.get_bind()
|
|
233
|
+
if getattr(getattr(bind, "dialect", None), "name", "") != "sqlite":
|
|
234
|
+
return
|
|
235
|
+
connection = await self.session.connection()
|
|
236
|
+
raw = await connection.get_raw_connection()
|
|
237
|
+
driver = raw.driver_connection
|
|
238
|
+
sqlite_connection = getattr(driver, "_conn", driver)
|
|
239
|
+
if getattr(sqlite_connection, "in_transaction", None) is False:
|
|
240
|
+
await connection.exec_driver_sql("BEGIN")
|
|
241
|
+
|
|
242
|
+
def _skip_locked(self) -> bool:
|
|
243
|
+
bind = self.session.get_bind()
|
|
244
|
+
return bind.dialect.name in _SKIP_LOCKED_DIALECTS
|
|
245
|
+
|
|
246
|
+
def _to_message(self, row: typing.Any) -> InboxMessage:
|
|
247
|
+
status = row.status
|
|
248
|
+
if not isinstance(status, InboxStatus):
|
|
249
|
+
if isinstance(status, str) and status in InboxStatus.__members__:
|
|
250
|
+
status = InboxStatus[status]
|
|
251
|
+
else:
|
|
252
|
+
status = InboxStatus(status)
|
|
253
|
+
payload = row.payload
|
|
254
|
+
if not isinstance(payload, bytes):
|
|
255
|
+
payload = bytes(payload)
|
|
256
|
+
return InboxMessage(
|
|
257
|
+
id=row.id,
|
|
258
|
+
message_id=row.message_id,
|
|
259
|
+
message_name=row.message_name,
|
|
260
|
+
status=status,
|
|
261
|
+
flush_counter=row.flush_counter,
|
|
262
|
+
created_at=row.created_at,
|
|
263
|
+
payload=payload,
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
async def get_many(self, batch_size: int = 100) -> typing.List[InboxMessage]:
|
|
267
|
+
query = self._model.get_batch_query(batch_size, lock=self._skip_locked())
|
|
268
|
+
rows = (await self.session.execute(query)).scalars().all()
|
|
269
|
+
return [self._to_message(row) for row in rows]
|
|
270
|
+
|
|
271
|
+
async def update_status(self, row_id: int, new_status: InboxStatus) -> None:
|
|
272
|
+
await self.session.execute(
|
|
273
|
+
statement=self._model.update_status_query(row_id, new_status),
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
def nested(self) -> contextlib.AbstractAsyncContextManager[None]:
|
|
277
|
+
return self._nested()
|
|
278
|
+
|
|
279
|
+
@contextlib.asynccontextmanager
|
|
280
|
+
async def _nested(self) -> typing.AsyncIterator[None]:
|
|
281
|
+
await self._prepare_savepoint()
|
|
282
|
+
async with self.session.begin_nested():
|
|
283
|
+
yield
|
|
284
|
+
|
|
285
|
+
async def commit(self) -> None:
|
|
286
|
+
await self.session.commit()
|
|
287
|
+
|
|
288
|
+
async def rollback(self) -> None:
|
|
289
|
+
await self.session.rollback()
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def rebind_inbox_model(
|
|
293
|
+
model: typing.Any,
|
|
294
|
+
new_base: DeclarativeMeta,
|
|
295
|
+
table_name: typing.Text | None = None,
|
|
296
|
+
) -> None:
|
|
297
|
+
"""
|
|
298
|
+
Attach the inbox model to the application's declarative base.
|
|
299
|
+
|
|
300
|
+
[AI Instruction]
|
|
301
|
+
Call it once at startup, before ``create_all``, before migrations, and
|
|
302
|
+
before the first inbox query. Pass a table name when the default
|
|
303
|
+
``inbox`` collides with an existing table.
|
|
304
|
+
"""
|
|
305
|
+
|
|
306
|
+
model.__bases__ = (new_base,)
|
|
307
|
+
model.__table__.name = table_name or model.__table__.name
|
|
308
|
+
new_base.metadata._add_table(
|
|
309
|
+
model.__table__.name,
|
|
310
|
+
model.__table__.schema,
|
|
311
|
+
model.__table__,
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
__all__ = (
|
|
316
|
+
"DEFAULT_INBOX_TABLE_NAME",
|
|
317
|
+
"Base",
|
|
318
|
+
"InboxModel",
|
|
319
|
+
"SqlAlchemyInboxRepository",
|
|
320
|
+
"create_sqlite_inbox_table",
|
|
321
|
+
"rebind_inbox_model",
|
|
322
|
+
)
|
cqrs/inbox/sqlmodel.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SQLModel inbox: same table, queries, and repository behavior as SQLAlchemy.
|
|
3
|
+
|
|
4
|
+
[AI Instruction]
|
|
5
|
+
Import ``SqlModelInboxRepository`` from ``cqrs``. It is lazy and needs the
|
|
6
|
+
``sqlmodel`` extra. Pass a ``sqlmodel.ext.asyncio.session.AsyncSession``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import datetime
|
|
10
|
+
import typing
|
|
11
|
+
|
|
12
|
+
from cqrs.inbox.map import InboxMessageMap
|
|
13
|
+
from cqrs.inbox.repository import InboxStatus
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
from cqrs.inbox.sqlalchemy import (
|
|
17
|
+
DEFAULT_INBOX_TABLE_NAME,
|
|
18
|
+
InboxModel as SqlAlchemyInboxModel,
|
|
19
|
+
SqlAlchemyInboxRepository,
|
|
20
|
+
rebind_inbox_model,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
import sqlalchemy
|
|
24
|
+
from sqlalchemy import func
|
|
25
|
+
from sqlmodel import Field
|
|
26
|
+
from sqlmodel.ext.asyncio.session import AsyncSession
|
|
27
|
+
|
|
28
|
+
from cqrs import sqlalchemy_types
|
|
29
|
+
from cqrs.sqlmodel_base import CqrsSQLModel, SqlAlchemyExecuteSession
|
|
30
|
+
except ImportError:
|
|
31
|
+
raise ImportError(
|
|
32
|
+
"You are trying to use the SQLModel inbox implementation, "
|
|
33
|
+
"but 'sqlmodel' is not installed. "
|
|
34
|
+
"Please install it using: pip install python-cqrs[sqlmodel]",
|
|
35
|
+
) from None
|
|
36
|
+
|
|
37
|
+
Base = CqrsSQLModel
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class InboxModel(Base, table=True):
|
|
41
|
+
__tablename__ = DEFAULT_INBOX_TABLE_NAME # type: ignore[assignment]
|
|
42
|
+
|
|
43
|
+
__table_args__ = (
|
|
44
|
+
sqlalchemy.UniqueConstraint(
|
|
45
|
+
"message_id",
|
|
46
|
+
"message_name",
|
|
47
|
+
name="inbox_message_unique_index",
|
|
48
|
+
),
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
id: int | None = Field(
|
|
52
|
+
default=None,
|
|
53
|
+
sa_column=sqlalchemy.Column(
|
|
54
|
+
sqlalchemy.BigInteger,
|
|
55
|
+
sqlalchemy.Identity(),
|
|
56
|
+
primary_key=True,
|
|
57
|
+
nullable=False,
|
|
58
|
+
autoincrement=True,
|
|
59
|
+
comment="Identity",
|
|
60
|
+
),
|
|
61
|
+
)
|
|
62
|
+
message_id: str = Field(
|
|
63
|
+
sa_column=sqlalchemy.Column(
|
|
64
|
+
sqlalchemy.String(255),
|
|
65
|
+
nullable=False,
|
|
66
|
+
comment="Delivery idempotency id",
|
|
67
|
+
),
|
|
68
|
+
)
|
|
69
|
+
message_name: str = Field(
|
|
70
|
+
sa_column=sqlalchemy.Column(
|
|
71
|
+
sqlalchemy.String(255),
|
|
72
|
+
nullable=False,
|
|
73
|
+
comment="Registered message name",
|
|
74
|
+
),
|
|
75
|
+
)
|
|
76
|
+
status: InboxStatus = Field(
|
|
77
|
+
default=InboxStatus.NEW,
|
|
78
|
+
sa_column=sqlalchemy.Column(
|
|
79
|
+
sqlalchemy.Enum(InboxStatus),
|
|
80
|
+
nullable=False,
|
|
81
|
+
default=InboxStatus.NEW,
|
|
82
|
+
comment="Inbox processing status",
|
|
83
|
+
),
|
|
84
|
+
)
|
|
85
|
+
flush_counter: int = Field(
|
|
86
|
+
default=0,
|
|
87
|
+
sa_column=sqlalchemy.Column(
|
|
88
|
+
sqlalchemy.SmallInteger,
|
|
89
|
+
nullable=False,
|
|
90
|
+
default=0,
|
|
91
|
+
comment="Failed processing attempts",
|
|
92
|
+
),
|
|
93
|
+
)
|
|
94
|
+
created_at: datetime.datetime | None = Field(
|
|
95
|
+
default=None,
|
|
96
|
+
sa_column=sqlalchemy.Column(
|
|
97
|
+
sqlalchemy.DateTime,
|
|
98
|
+
nullable=False,
|
|
99
|
+
server_default=func.now(),
|
|
100
|
+
comment="Row creation timestamp",
|
|
101
|
+
),
|
|
102
|
+
)
|
|
103
|
+
payload: bytes = Field(
|
|
104
|
+
sa_column=sqlalchemy.Column(
|
|
105
|
+
sqlalchemy_types.PayloadBinary,
|
|
106
|
+
nullable=False,
|
|
107
|
+
comment="Serialized request or event",
|
|
108
|
+
),
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
@classmethod
|
|
112
|
+
def get_batch_query(
|
|
113
|
+
cls,
|
|
114
|
+
size: int,
|
|
115
|
+
*,
|
|
116
|
+
lock: bool = False,
|
|
117
|
+
) -> sqlalchemy.Select:
|
|
118
|
+
query = SqlAlchemyInboxModel.get_batch_query
|
|
119
|
+
return query.__func__(cls, size, lock=lock) # type: ignore[attr-defined]
|
|
120
|
+
|
|
121
|
+
@classmethod
|
|
122
|
+
def update_status_query(
|
|
123
|
+
cls,
|
|
124
|
+
row_id: int,
|
|
125
|
+
status: InboxStatus,
|
|
126
|
+
) -> sqlalchemy.Update:
|
|
127
|
+
query = SqlAlchemyInboxModel.update_status_query
|
|
128
|
+
return query.__func__(cls, row_id, status) # type: ignore[attr-defined]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class SqlModelInboxRepository(SqlAlchemyInboxRepository):
|
|
132
|
+
"""
|
|
133
|
+
Inbox on a SQLModel ``AsyncSession``.
|
|
134
|
+
|
|
135
|
+
Behavior matches ``SqlAlchemyInboxRepository``: one savepoint per batch
|
|
136
|
+
element, ``get_many`` with ``FOR UPDATE SKIP LOCKED`` on PostgreSQL and
|
|
137
|
+
MySQL, and no commit inside ``add``.
|
|
138
|
+
|
|
139
|
+
[AI Instruction]
|
|
140
|
+
Install ``python-cqrs[sqlmodel]``. Pass the SQLModel session the
|
|
141
|
+
presentation or the worker already opened.
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
def __init__(
|
|
145
|
+
self,
|
|
146
|
+
session: AsyncSession,
|
|
147
|
+
*,
|
|
148
|
+
message_map: InboxMessageMap | None = None,
|
|
149
|
+
) -> None:
|
|
150
|
+
super().__init__(
|
|
151
|
+
typing.cast(typing.Any, SqlAlchemyExecuteSession(session)),
|
|
152
|
+
message_map=message_map,
|
|
153
|
+
model=InboxModel,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
__all__ = (
|
|
158
|
+
"AsyncSession",
|
|
159
|
+
"Base",
|
|
160
|
+
"DEFAULT_INBOX_TABLE_NAME",
|
|
161
|
+
"InboxModel",
|
|
162
|
+
"SqlModelInboxRepository",
|
|
163
|
+
"rebind_inbox_model",
|
|
164
|
+
)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-cqrs
|
|
3
|
-
Version: 5.
|
|
3
|
+
Version: 5.3.0
|
|
4
4
|
Summary: Event-Driven Architecture Framework for Distributed Systems
|
|
5
5
|
Author-email: Vadim Kozyrevskiy <vadikko2@mail.ru>, Dmitry Kutlubaev <kutlubaev00@mail.ru>
|
|
6
6
|
Maintainer-email: Vadim Kozyrevskiy <vadikko2@mail.ru>
|
|
@@ -14,6 +14,7 @@ Classifier: Programming Language :: Python :: 3.10
|
|
|
14
14
|
Classifier: Programming Language :: Python :: 3.11
|
|
15
15
|
Classifier: Programming Language :: Python :: 3.12
|
|
16
16
|
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
17
18
|
Requires-Python: >=3.10
|
|
18
19
|
Description-Content-Type: text/markdown
|
|
19
20
|
License-File: LICENSE
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
cqrs/__init__.py,sha256=
|
|
1
|
+
cqrs/__init__.py,sha256=X5sbSFabkB9rStBlpYV1lKEkqypnjoObaiX6Is4dsAQ,11091
|
|
2
2
|
cqrs/_dataclass_utils.py,sha256=OqKF3Gifarcj7rjFWUe60BcBrpt0NePgiXbbiuigMdk,3555
|
|
3
3
|
cqrs/circuit_breaker.py,sha256=-6d01MaI-isZ4UDWKPQlXxTLe4ggr3N3ZLDOpoTQQ1c,2850
|
|
4
4
|
cqrs/generic_utils.py,sha256=UMFUHofmAtKnZSWqrOGSZFhG3K-8nZotO7WGIFyCPhg,4476
|
|
@@ -44,6 +44,13 @@ cqrs/handlers/cor.py,sha256=3nKX4m7gtoNzSe_AGrJ9_wF-fhwrGAQKabNAX8vW1yM,5012
|
|
|
44
44
|
cqrs/handlers/event.py,sha256=_6oR4FpxpCINqYjlUMsseRahkcDcwFJBwNO-CewU_E0,2564
|
|
45
45
|
cqrs/handlers/request.py,sha256=VuBzugiX2fVBU04Pl45xGpa7TrKdQJKLKmAj3qtZTSo,7431
|
|
46
46
|
cqrs/handlers/saga.py,sha256=3FW7zkoo31IOKfqbstAaKltpLOR6Y3cSvLog5bYyboQ,8775
|
|
47
|
+
cqrs/inbox/__init__.py,sha256=ncYMqAZRorhtFHEn-o30RHuKzMB3ErWjefUHUNyyZ38,570
|
|
48
|
+
cqrs/inbox/map.py,sha256=CZ8X7ExmhK1ip7nzXANVodzaSB7ufyaFYPqZdi7j-sI,6669
|
|
49
|
+
cqrs/inbox/memory.py,sha256=ypCGKLfb9Blmc2-lX5cjUsz02w4TYr4H3khKk9GR52E,3663
|
|
50
|
+
cqrs/inbox/processor.py,sha256=q4BrTLPh97DgHlaAC6SMmPIlgjmmVayzfgZFgsvM0UU,4282
|
|
51
|
+
cqrs/inbox/repository.py,sha256=aAPvzJlCQROmyEHsK94jOkDljTun-N_u9X7J8wtg15M,4355
|
|
52
|
+
cqrs/inbox/sqlalchemy.py,sha256=Gc76C1cpGxZTQ-ccJgBkmQXbIDTPqRoZor4lo0Gq6NI,10356
|
|
53
|
+
cqrs/inbox/sqlmodel.py,sha256=2kymL9l8lud13rh-Ibmke4kA_lHeSqg90EtMe0u5EyE,4530
|
|
47
54
|
cqrs/mapping/__init__.py,sha256=Kutj0hdjXCyO0L_gm4fMtE2jojOZeJRIidcqRsBqyKY,213
|
|
48
55
|
cqrs/mapping/events.py,sha256=W5ms16-t8dF-DufZRd79g-p5m7RYRSf9nG3z_8W5o08,3257
|
|
49
56
|
cqrs/mapping/outbox.py,sha256=x9ZNg5orp6mt_kqNO9XFTGZOWYBUeh7ff-D_T3I1hfc,5244
|
|
@@ -99,8 +106,8 @@ cqrs/serializers/default.py,sha256=USVd1dwtgMgMd2FlK8BHtc7B61myzWcosHR4VkYEKB4,2
|
|
|
99
106
|
cqrs/serializers/json.py,sha256=UxHQs_NdP5M44gH6ZZ2CkXtxoTTOdhfwG12N7gxP--0,884
|
|
100
107
|
cqrs/serializers/protobuf.py,sha256=T6TO6cQ3c6XHK6YGG9Rr198EHIVP9isawNa_VLV78gI,1966
|
|
101
108
|
cqrs/serializers/protocol.py,sha256=xYimT7tjzpDvY86aSncSZagqgf-xJiy2kmdjC1cxz_Q,1676
|
|
102
|
-
python_cqrs-5.
|
|
103
|
-
python_cqrs-5.
|
|
104
|
-
python_cqrs-5.
|
|
105
|
-
python_cqrs-5.
|
|
106
|
-
python_cqrs-5.
|
|
109
|
+
python_cqrs-5.3.0.dist-info/licenses/LICENSE,sha256=eS7m3vXf_ZJ_ZGceKhQUySTjk32aywfCHFFirO4t3qc,1072
|
|
110
|
+
python_cqrs-5.3.0.dist-info/METADATA,sha256=5othZeBCvSj22dJC3oxcXFqXmERFreCuYvMz2SgFZ7E,51161
|
|
111
|
+
python_cqrs-5.3.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
112
|
+
python_cqrs-5.3.0.dist-info/top_level.txt,sha256=ic74ni8Zxy9lirOAcyt4YEKU34e8IlC8RslIQmbWCq0,5
|
|
113
|
+
python_cqrs-5.3.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|