eventsourcing 9.5.0b3__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.
- eventsourcing/__init__.py +0 -0
- eventsourcing/application.py +998 -0
- eventsourcing/cipher.py +107 -0
- eventsourcing/compressor.py +15 -0
- eventsourcing/cryptography.py +91 -0
- eventsourcing/dcb/__init__.py +0 -0
- eventsourcing/dcb/api.py +144 -0
- eventsourcing/dcb/application.py +159 -0
- eventsourcing/dcb/domain.py +369 -0
- eventsourcing/dcb/msgpack.py +38 -0
- eventsourcing/dcb/persistence.py +193 -0
- eventsourcing/dcb/popo.py +178 -0
- eventsourcing/dcb/postgres_tt.py +704 -0
- eventsourcing/dcb/tests.py +608 -0
- eventsourcing/dispatch.py +80 -0
- eventsourcing/domain.py +1964 -0
- eventsourcing/interface.py +164 -0
- eventsourcing/persistence.py +1429 -0
- eventsourcing/popo.py +267 -0
- eventsourcing/postgres.py +1441 -0
- eventsourcing/projection.py +502 -0
- eventsourcing/py.typed +0 -0
- eventsourcing/sqlite.py +816 -0
- eventsourcing/system.py +1203 -0
- eventsourcing/tests/__init__.py +3 -0
- eventsourcing/tests/application.py +483 -0
- eventsourcing/tests/domain.py +105 -0
- eventsourcing/tests/persistence.py +1744 -0
- eventsourcing/tests/postgres_utils.py +131 -0
- eventsourcing/utils.py +257 -0
- eventsourcing-9.5.0b3.dist-info/METADATA +253 -0
- eventsourcing-9.5.0b3.dist-info/RECORD +35 -0
- eventsourcing-9.5.0b3.dist-info/WHEEL +4 -0
- eventsourcing-9.5.0b3.dist-info/licenses/AUTHORS +10 -0
- eventsourcing-9.5.0b3.dist-info/licenses/LICENSE +29 -0
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, ABCMeta, abstractmethod
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import TYPE_CHECKING, Any, Generic, ParamSpec, cast
|
|
6
|
+
from uuid import uuid4
|
|
7
|
+
|
|
8
|
+
from typing_extensions import Self, TypeVar
|
|
9
|
+
|
|
10
|
+
from eventsourcing.domain import (
|
|
11
|
+
AbstractDecision,
|
|
12
|
+
CallableType,
|
|
13
|
+
ProgrammingError,
|
|
14
|
+
all_func_decorators,
|
|
15
|
+
decorated_func_callers,
|
|
16
|
+
filter_kwargs_for_method_params,
|
|
17
|
+
)
|
|
18
|
+
from eventsourcing.utils import construct_topic, get_topic, resolve_topic
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from collections.abc import Callable, Sequence
|
|
22
|
+
|
|
23
|
+
_enduring_object_init_classes: dict[type[Any], type[InitialDecision]] = {}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Decision(AbstractDecision):
|
|
27
|
+
def as_dict(self) -> dict[str, Any]:
|
|
28
|
+
return self.__dict__.copy()
|
|
29
|
+
|
|
30
|
+
def mutate(self, obj: TPerspective | None) -> TPerspective | None:
|
|
31
|
+
assert obj is not None
|
|
32
|
+
|
|
33
|
+
# Identify the function that was decorated.
|
|
34
|
+
try:
|
|
35
|
+
decorated_func = decorated_funcs[(type(obj), type(self))]
|
|
36
|
+
except KeyError:
|
|
37
|
+
pass
|
|
38
|
+
else:
|
|
39
|
+
# Select event attributes mentioned in function signature.
|
|
40
|
+
self_dict = self.as_dict()
|
|
41
|
+
kwargs = filter_kwargs_for_method_params(self_dict, decorated_func)
|
|
42
|
+
|
|
43
|
+
# Call the original method with event attribute values.
|
|
44
|
+
decorated_method = decorated_func.__get__(obj, type(obj))
|
|
45
|
+
try:
|
|
46
|
+
decorated_method(**kwargs)
|
|
47
|
+
except TypeError as e: # pragma: no cover
|
|
48
|
+
# TODO: Write a test that does this...
|
|
49
|
+
msg = (
|
|
50
|
+
f"Failed to apply {type(self).__qualname__} to "
|
|
51
|
+
f"{type(obj).__qualname__} with kwargs {kwargs}: {e}"
|
|
52
|
+
)
|
|
53
|
+
raise TypeError(msg) from e
|
|
54
|
+
|
|
55
|
+
self.apply(obj)
|
|
56
|
+
return obj
|
|
57
|
+
|
|
58
|
+
def apply(self, obj: Any) -> None:
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class InitialDecision(Decision):
|
|
63
|
+
originator_topic: str
|
|
64
|
+
|
|
65
|
+
def mutate(self, obj: TPerspective | None) -> TPerspective | None:
|
|
66
|
+
# Identify the function that was decorated.
|
|
67
|
+
if obj is not None:
|
|
68
|
+
try:
|
|
69
|
+
decorated_func = decorated_funcs[(type(obj), type(self))]
|
|
70
|
+
except KeyError: # pragma: no cover
|
|
71
|
+
pass
|
|
72
|
+
else:
|
|
73
|
+
# Select event attributes mentioned in function signature.
|
|
74
|
+
self_dict = self.as_dict()
|
|
75
|
+
kwargs = filter_kwargs_for_method_params(self_dict, decorated_func)
|
|
76
|
+
|
|
77
|
+
# Call the original method with event attribute values.
|
|
78
|
+
decorated_method = decorated_func.__get__(obj, type(obj))
|
|
79
|
+
try:
|
|
80
|
+
decorated_method(**kwargs)
|
|
81
|
+
except TypeError as e: # pragma: no cover
|
|
82
|
+
# TODO: Write a test that does this...
|
|
83
|
+
msg = (
|
|
84
|
+
f"Failed to apply {type(self).__qualname__} to "
|
|
85
|
+
f"{type(obj).__qualname__} with kwargs {kwargs}: {e}"
|
|
86
|
+
)
|
|
87
|
+
raise TypeError(msg) from e
|
|
88
|
+
return obj
|
|
89
|
+
|
|
90
|
+
kwargs = self.as_dict()
|
|
91
|
+
originator_type = resolve_topic(kwargs.pop("originator_topic"))
|
|
92
|
+
if issubclass(originator_type, EnduringObject):
|
|
93
|
+
enduring_object_id = kwargs.pop(self.id_attr_name(originator_type))
|
|
94
|
+
try:
|
|
95
|
+
enduring_object = type.__call__(originator_type, **kwargs)
|
|
96
|
+
except TypeError as e: # pragma: no cover
|
|
97
|
+
msg = (
|
|
98
|
+
f"{type(self).__qualname__} cannot __init__ "
|
|
99
|
+
f"{originator_type.__qualname__} "
|
|
100
|
+
f"with kwargs {kwargs}: {e}"
|
|
101
|
+
)
|
|
102
|
+
raise TypeError(msg) from e
|
|
103
|
+
enduring_object.id = enduring_object_id
|
|
104
|
+
enduring_object.__post_init__()
|
|
105
|
+
return enduring_object
|
|
106
|
+
msg = f"Originator type not subclass of EnduringObject: {originator_type}"
|
|
107
|
+
raise TypeError(msg)
|
|
108
|
+
|
|
109
|
+
@classmethod
|
|
110
|
+
def id_attr_name(cls, enduring_object_class: type[EnduringObject[Any, TID]]) -> TID:
|
|
111
|
+
return cast(TID, f"{enduring_object_class.__name__.lower()}_id")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
TDecision = TypeVar("TDecision", bound=Decision)
|
|
115
|
+
"""
|
|
116
|
+
A type variable representing any subclass of :class:`Decision`.
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class Tagged(Generic[TDecision]):
|
|
121
|
+
def __init__(self, tags: list[str], decision: TDecision) -> None:
|
|
122
|
+
self.tags = tags
|
|
123
|
+
self.decision = decision
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
T = TypeVar("T")
|
|
127
|
+
P = ParamSpec("P")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class MetaPerspective(ABCMeta):
|
|
131
|
+
pass
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class Perspective(ABC, Generic[TDecision], metaclass=MetaPerspective):
|
|
135
|
+
last_known_position: int | None
|
|
136
|
+
new_decisions: list[Tagged[TDecision]]
|
|
137
|
+
|
|
138
|
+
def __new__(cls, *_: Any, **__: Any) -> Self:
|
|
139
|
+
self = super().__new__(cls)
|
|
140
|
+
self.last_known_position = None
|
|
141
|
+
self.new_decisions = []
|
|
142
|
+
return self
|
|
143
|
+
|
|
144
|
+
@abstractmethod
|
|
145
|
+
def consistency_boundary(self) -> Selector | Sequence[Selector]:
|
|
146
|
+
raise NotImplementedError # pragma: no cover
|
|
147
|
+
|
|
148
|
+
def trigger_event(
|
|
149
|
+
self,
|
|
150
|
+
decision_cls: Callable[P, TDecision],
|
|
151
|
+
tags: Sequence[str] = (),
|
|
152
|
+
*args: P.args,
|
|
153
|
+
**kwargs: P.kwargs,
|
|
154
|
+
) -> None:
|
|
155
|
+
"""
|
|
156
|
+
Constructs new tagged decision and appends to list of uncommitted events.
|
|
157
|
+
"""
|
|
158
|
+
tagged = Tagged[TDecision](
|
|
159
|
+
tags=list(tags),
|
|
160
|
+
decision=decision_cls(*args, **kwargs),
|
|
161
|
+
)
|
|
162
|
+
tagged.decision.mutate(self)
|
|
163
|
+
self.new_decisions.append(tagged)
|
|
164
|
+
|
|
165
|
+
def collect_events(self) -> Sequence[Tagged[TDecision]]:
|
|
166
|
+
"""
|
|
167
|
+
Drains list of triggered events.
|
|
168
|
+
"""
|
|
169
|
+
collected, self.new_decisions = self.new_decisions, []
|
|
170
|
+
return collected
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
TPerspective = TypeVar("TPerspective", bound=Perspective[Any])
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
decorated_funcs: dict[tuple[MetaPerspective, type[Decision]], CallableType] = {}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class MetaSupportsEventDecorator(MetaPerspective):
|
|
180
|
+
def __init__(
|
|
181
|
+
cls, name: str, bases: tuple[type, ...], namespace: dict[str, Any]
|
|
182
|
+
) -> None:
|
|
183
|
+
super().__init__(name, bases, namespace)
|
|
184
|
+
|
|
185
|
+
topic_prefix = construct_topic(cls) + "."
|
|
186
|
+
|
|
187
|
+
cls.projected_types: list[type[Decision]] = []
|
|
188
|
+
|
|
189
|
+
# Find the event decorators on this class.
|
|
190
|
+
func_decorators = [
|
|
191
|
+
decorator
|
|
192
|
+
for decorator in all_func_decorators
|
|
193
|
+
if construct_topic(decorator.decorated_func).startswith(topic_prefix)
|
|
194
|
+
]
|
|
195
|
+
|
|
196
|
+
for decorator in func_decorators:
|
|
197
|
+
given = decorator.given_event_cls
|
|
198
|
+
|
|
199
|
+
# Keep things simple by only supporting given classes (not names).
|
|
200
|
+
assert given is not None, "Event class not given"
|
|
201
|
+
# TODO: Maybe support event name strings, maybe not....
|
|
202
|
+
|
|
203
|
+
# Make sure given event class is a Decision subclass.
|
|
204
|
+
assert issubclass(given, Decision)
|
|
205
|
+
|
|
206
|
+
if (
|
|
207
|
+
issubclass(given, InitialDecision)
|
|
208
|
+
and decorator.decorated_func.__name__ == "__init__"
|
|
209
|
+
):
|
|
210
|
+
_enduring_object_init_classes[cls] = given
|
|
211
|
+
# If command method, remember which event class to trigger.
|
|
212
|
+
elif not construct_topic(decorator.decorated_func).endswith("._"):
|
|
213
|
+
decorated_func_callers[decorator] = given
|
|
214
|
+
|
|
215
|
+
# Remember which decorated func to call.
|
|
216
|
+
decorated_funcs[(cls, given)] = decorator.decorated_func
|
|
217
|
+
|
|
218
|
+
cls.projected_types.append(given)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class MetaEnduringObject(MetaSupportsEventDecorator):
|
|
222
|
+
def __init__(
|
|
223
|
+
cls, name: str, bases: tuple[type, ...], namespace: dict[str, Any]
|
|
224
|
+
) -> None:
|
|
225
|
+
super().__init__(name, bases, namespace)
|
|
226
|
+
# Find and remember the "InitialDecision" class.
|
|
227
|
+
for item in cls.__dict__.values():
|
|
228
|
+
if isinstance(item, type) and issubclass(item, InitialDecision):
|
|
229
|
+
_enduring_object_init_classes[cls] = item
|
|
230
|
+
break
|
|
231
|
+
|
|
232
|
+
def __call__(cls: type[T], **kwargs: Any) -> T:
|
|
233
|
+
# TODO: For convenience, make this error out in the same way
|
|
234
|
+
# as it would if the arguments didn't match the __init__
|
|
235
|
+
# method and __init__was called directly, and verify the
|
|
236
|
+
# event's __init__ is valid when initialising the class,
|
|
237
|
+
# just like we do for event-sourced aggregates.
|
|
238
|
+
|
|
239
|
+
assert issubclass(cls, EnduringObject)
|
|
240
|
+
try:
|
|
241
|
+
init_enduring_object_class = _enduring_object_init_classes[cls]
|
|
242
|
+
except KeyError:
|
|
243
|
+
msg = (
|
|
244
|
+
f"Enduring object class {cls.__name__} has no "
|
|
245
|
+
f"InitialDecision class. Please define a subclass of "
|
|
246
|
+
f"InitialDecision as a nested class on {cls.__name__}."
|
|
247
|
+
)
|
|
248
|
+
raise ProgrammingError(msg) from None
|
|
249
|
+
|
|
250
|
+
return cls._create(
|
|
251
|
+
decision_cls=init_enduring_object_class,
|
|
252
|
+
**kwargs,
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
TID = TypeVar("TID", bound=str, default=str)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
class EnduringObject(
|
|
260
|
+
Perspective[TDecision], Generic[TDecision, TID], metaclass=MetaEnduringObject
|
|
261
|
+
):
|
|
262
|
+
id: TID
|
|
263
|
+
|
|
264
|
+
@classmethod
|
|
265
|
+
def _create(
|
|
266
|
+
cls: type[Self], decision_cls: type[InitialDecision], **kwargs: Any
|
|
267
|
+
) -> Self:
|
|
268
|
+
enduring_object_id = cls._create_id()
|
|
269
|
+
id_attr_name = decision_cls.id_attr_name(cls)
|
|
270
|
+
assert id_attr_name not in kwargs
|
|
271
|
+
assert "originator_topic" not in kwargs
|
|
272
|
+
assert "tags" not in kwargs
|
|
273
|
+
initial_kwargs: dict[str, Any] = {
|
|
274
|
+
id_attr_name: enduring_object_id,
|
|
275
|
+
"originator_topic": get_topic(cls),
|
|
276
|
+
}
|
|
277
|
+
initial_kwargs.update(kwargs)
|
|
278
|
+
try:
|
|
279
|
+
|
|
280
|
+
tagged = Tagged[TDecision](
|
|
281
|
+
tags=[enduring_object_id],
|
|
282
|
+
decision=cast(type[TDecision], decision_cls)(**initial_kwargs),
|
|
283
|
+
)
|
|
284
|
+
except TypeError as e:
|
|
285
|
+
msg = (
|
|
286
|
+
f"Unable to construct {decision_cls.__qualname__} event "
|
|
287
|
+
f"with kwargs {initial_kwargs}: {e}"
|
|
288
|
+
)
|
|
289
|
+
raise TypeError(msg) from e
|
|
290
|
+
self = cast(Self, tagged.decision.mutate(None))
|
|
291
|
+
assert self is not None
|
|
292
|
+
self.new_decisions.append(tagged)
|
|
293
|
+
return self
|
|
294
|
+
|
|
295
|
+
@classmethod
|
|
296
|
+
def _create_id(cls) -> TID:
|
|
297
|
+
return cast(TID, f"{cls.__name__.lower()}-{uuid4()}")
|
|
298
|
+
|
|
299
|
+
def __post_init__(self) -> None:
|
|
300
|
+
pass
|
|
301
|
+
|
|
302
|
+
def consistency_boundary(self) -> list[Selector]:
|
|
303
|
+
return [Selector(tags=[self.id])]
|
|
304
|
+
|
|
305
|
+
def trigger_event(
|
|
306
|
+
self,
|
|
307
|
+
decision_cls: Callable[P, TDecision],
|
|
308
|
+
tags: Sequence[str] = (),
|
|
309
|
+
*args: P.args,
|
|
310
|
+
**kwargs: P.kwargs,
|
|
311
|
+
) -> None:
|
|
312
|
+
tags = [self.id, *tags]
|
|
313
|
+
super().trigger_event(decision_cls, tags, *args, **kwargs)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
class Group(Perspective[TDecision]):
|
|
317
|
+
_enduring_objects: list[EnduringObject[TDecision]]
|
|
318
|
+
|
|
319
|
+
def __new__(cls, *args: Any, **kwargs: Any) -> Self:
|
|
320
|
+
self = super().__new__(cls, *args, **kwargs)
|
|
321
|
+
self._enduring_objects = [a for a in args if isinstance(a, EnduringObject)]
|
|
322
|
+
return self
|
|
323
|
+
|
|
324
|
+
def consistency_boundary(self) -> list[Selector]:
|
|
325
|
+
return [
|
|
326
|
+
Selector(tags=cb.tags)
|
|
327
|
+
for cbs in [o.consistency_boundary() for o in self._enduring_objects]
|
|
328
|
+
for cb in cbs
|
|
329
|
+
]
|
|
330
|
+
|
|
331
|
+
def trigger_event(
|
|
332
|
+
self,
|
|
333
|
+
decision_cls: Callable[P, TDecision],
|
|
334
|
+
tags: Sequence[str] = (),
|
|
335
|
+
*args: P.args,
|
|
336
|
+
**kwargs: P.kwargs,
|
|
337
|
+
) -> None:
|
|
338
|
+
objs = self._enduring_objects
|
|
339
|
+
tags = [o.id for o in objs] + list(tags)
|
|
340
|
+
tagged = Tagged[TDecision](
|
|
341
|
+
tags=tags,
|
|
342
|
+
decision=decision_cls(*args, **kwargs),
|
|
343
|
+
)
|
|
344
|
+
for o in objs:
|
|
345
|
+
tagged.decision.mutate(o)
|
|
346
|
+
self.new_decisions.append(tagged)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
@dataclass
|
|
350
|
+
class Selector:
|
|
351
|
+
types: Sequence[type[Decision]] = ()
|
|
352
|
+
tags: Sequence[str] = ()
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
class MetaSlice(MetaSupportsEventDecorator):
|
|
356
|
+
def __init__(
|
|
357
|
+
cls, name: str, bases: tuple[type, ...], namespace: dict[str, Any]
|
|
358
|
+
) -> None:
|
|
359
|
+
super().__init__(name, bases, namespace)
|
|
360
|
+
cls.do_projection = len(cls.projected_types) != 0
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
class Slice(Perspective[TDecision], metaclass=MetaSlice):
|
|
364
|
+
def execute(self) -> None:
|
|
365
|
+
pass
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
TSlice = TypeVar("TSlice", bound=Slice[Any])
|
|
369
|
+
TGroup = TypeVar("TGroup", bound=Group[Any])
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, TypeVar
|
|
4
|
+
|
|
5
|
+
import msgspec
|
|
6
|
+
|
|
7
|
+
from eventsourcing.dcb import api, domain, persistence
|
|
8
|
+
from eventsourcing.utils import get_topic, resolve_topic
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Decision(msgspec.Struct, domain.Decision):
|
|
12
|
+
def as_dict(self) -> dict[str, Any]:
|
|
13
|
+
return {key: getattr(self, key) for key in self.__struct_fields__}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
TDecision = TypeVar("TDecision", bound=Decision)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class MessagePackMapper(persistence.DCBMapper[Decision]):
|
|
20
|
+
def to_dcb_event(self, event: domain.Tagged[TDecision]) -> api.DCBEvent:
|
|
21
|
+
return api.DCBEvent(
|
|
22
|
+
type=get_topic(type(event.decision)),
|
|
23
|
+
data=msgspec.msgpack.encode(event.decision),
|
|
24
|
+
tags=event.tags,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
def to_domain_event(self, event: api.DCBEvent) -> domain.Tagged[Decision]:
|
|
28
|
+
return domain.Tagged(
|
|
29
|
+
tags=event.tags,
|
|
30
|
+
decision=msgspec.msgpack.decode(
|
|
31
|
+
event.data,
|
|
32
|
+
type=resolve_topic(event.type),
|
|
33
|
+
),
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class InitialDecision(Decision, domain.InitialDecision):
|
|
38
|
+
originator_topic: str
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import threading
|
|
4
|
+
from abc import ABC, abstractmethod
|
|
5
|
+
from collections.abc import Iterator
|
|
6
|
+
from queue import Queue
|
|
7
|
+
from typing import TYPE_CHECKING, Any, Generic
|
|
8
|
+
|
|
9
|
+
from eventsourcing.dcb.api import (
|
|
10
|
+
DCBAppendCondition,
|
|
11
|
+
DCBEvent,
|
|
12
|
+
DCBQuery,
|
|
13
|
+
DCBQueryItem,
|
|
14
|
+
DCBReadResponse,
|
|
15
|
+
DCBRecorder,
|
|
16
|
+
DCBSequencedEvent,
|
|
17
|
+
DCBSubscription,
|
|
18
|
+
TDCBRecorder_co,
|
|
19
|
+
)
|
|
20
|
+
from eventsourcing.dcb.domain import (
|
|
21
|
+
Selector,
|
|
22
|
+
Tagged,
|
|
23
|
+
TDecision,
|
|
24
|
+
)
|
|
25
|
+
from eventsourcing.persistence import BaseInfrastructureFactory, TTrackingRecorder
|
|
26
|
+
from eventsourcing.utils import get_topic
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from collections.abc import Sequence
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class DCBMapper(ABC, Generic[TDecision]):
|
|
33
|
+
@abstractmethod
|
|
34
|
+
def to_dcb_event(self, event: Tagged[TDecision]) -> DCBEvent:
|
|
35
|
+
raise NotImplementedError # pragma: no cover
|
|
36
|
+
|
|
37
|
+
@abstractmethod
|
|
38
|
+
def to_domain_event(self, event: DCBEvent) -> Tagged[TDecision]:
|
|
39
|
+
raise NotImplementedError # pragma: no cover
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class DCBEventStore(Generic[TDecision]):
|
|
43
|
+
def __init__(self, mapper: DCBMapper[TDecision], recorder: DCBRecorder):
|
|
44
|
+
self.mapper = mapper
|
|
45
|
+
self.recorder = recorder
|
|
46
|
+
|
|
47
|
+
def append(
|
|
48
|
+
self,
|
|
49
|
+
events: Sequence[Tagged[TDecision]],
|
|
50
|
+
cb: Selector | Sequence[Selector] | None = None,
|
|
51
|
+
after: int | None = None,
|
|
52
|
+
) -> int:
|
|
53
|
+
if len(events) == 0:
|
|
54
|
+
return 0
|
|
55
|
+
condition = (
|
|
56
|
+
None
|
|
57
|
+
if not cb and not after
|
|
58
|
+
else DCBAppendCondition(
|
|
59
|
+
fail_if_events_match=self._cb_to_dcb_query(cb),
|
|
60
|
+
after=after,
|
|
61
|
+
)
|
|
62
|
+
)
|
|
63
|
+
return self.recorder.append(
|
|
64
|
+
events=[self.mapper.to_dcb_event(e) for e in events],
|
|
65
|
+
condition=condition,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
def read(
|
|
69
|
+
self,
|
|
70
|
+
cb: Selector | Sequence[Selector] | None = None,
|
|
71
|
+
*,
|
|
72
|
+
after: int | None = None,
|
|
73
|
+
) -> DCBEventStoreReadResponse[TDecision]:
|
|
74
|
+
query = self._cb_to_dcb_query(cb)
|
|
75
|
+
read_response = self.recorder.read(
|
|
76
|
+
query=query,
|
|
77
|
+
after=after,
|
|
78
|
+
)
|
|
79
|
+
return DCBEventStoreReadResponse(read_response, self.mapper)
|
|
80
|
+
|
|
81
|
+
@staticmethod
|
|
82
|
+
def _cb_to_dcb_query(
|
|
83
|
+
cb: Selector | Sequence[Selector] | None = None,
|
|
84
|
+
) -> DCBQuery:
|
|
85
|
+
cb = [cb] if isinstance(cb, Selector) else cb or []
|
|
86
|
+
return DCBQuery(
|
|
87
|
+
items=[
|
|
88
|
+
DCBQueryItem(
|
|
89
|
+
types=[get_topic(t) for t in s.types],
|
|
90
|
+
tags=list(s.tags),
|
|
91
|
+
)
|
|
92
|
+
for s in cb
|
|
93
|
+
]
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class DCBEventStoreReadResponse(Iterator[Tagged[TDecision]]):
|
|
98
|
+
def __init__(
|
|
99
|
+
self, dcb_read_response: DCBReadResponse, mapper: DCBMapper[TDecision]
|
|
100
|
+
):
|
|
101
|
+
self._dcb_read_response = dcb_read_response
|
|
102
|
+
self._mapper = mapper
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def head(self) -> int | None:
|
|
106
|
+
return self._dcb_read_response.head
|
|
107
|
+
|
|
108
|
+
def __next__(self) -> Tagged[TDecision]:
|
|
109
|
+
dcb_sequenced_event = self._dcb_read_response.__next__()
|
|
110
|
+
return self._mapper.to_domain_event(dcb_sequenced_event.event)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class NotFoundError(Exception):
|
|
114
|
+
pass
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class DCBInfrastructureFactory(BaseInfrastructureFactory[TTrackingRecorder], ABC):
|
|
118
|
+
@abstractmethod
|
|
119
|
+
def dcb_recorder(self) -> DCBRecorder:
|
|
120
|
+
pass # pragma: no cover
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class DCBListenNotifySubscription(DCBSubscription[TDCBRecorder_co]):
|
|
124
|
+
def __init__(
|
|
125
|
+
self,
|
|
126
|
+
recorder: TDCBRecorder_co,
|
|
127
|
+
query: DCBQuery | None = None,
|
|
128
|
+
after: int | None = None,
|
|
129
|
+
) -> None:
|
|
130
|
+
super().__init__(recorder=recorder, query=query, after=after)
|
|
131
|
+
self.select_limit = 500
|
|
132
|
+
self._events: Sequence[DCBSequencedEvent] = []
|
|
133
|
+
self._events_index: int = 0
|
|
134
|
+
self._events_queue: Queue[Sequence[DCBSequencedEvent]] = Queue(maxsize=10)
|
|
135
|
+
self._has_been_notified = threading.Event()
|
|
136
|
+
self._thread_error: BaseException | None = None
|
|
137
|
+
self._pull_thread = threading.Thread(target=self._loop_on_pull)
|
|
138
|
+
self._pull_thread.start()
|
|
139
|
+
|
|
140
|
+
def __exit__(self, *args: object, **kwargs: Any) -> None:
|
|
141
|
+
super().__exit__(*args, **kwargs)
|
|
142
|
+
self._pull_thread.join()
|
|
143
|
+
|
|
144
|
+
def stop(self) -> None:
|
|
145
|
+
"""Stops the subscription."""
|
|
146
|
+
super().stop()
|
|
147
|
+
self._events_queue.put([])
|
|
148
|
+
self._has_been_notified.set()
|
|
149
|
+
|
|
150
|
+
def __next__(self) -> DCBSequencedEvent:
|
|
151
|
+
# If necessary, get a new list of events from the recorder.
|
|
152
|
+
if self._events_index == len(self._events) and not self._has_been_stopped:
|
|
153
|
+
self._events = self._events_queue.get()
|
|
154
|
+
self._events_index = 0
|
|
155
|
+
|
|
156
|
+
# Stop the iteration if necessary, maybe raise thread error.
|
|
157
|
+
if self._has_been_stopped or not self._events:
|
|
158
|
+
if self._thread_error is not None:
|
|
159
|
+
raise self._thread_error
|
|
160
|
+
raise StopIteration
|
|
161
|
+
|
|
162
|
+
# Return a notification from previously obtained list.
|
|
163
|
+
notification = self._events[self._events_index]
|
|
164
|
+
self._events_index += 1
|
|
165
|
+
return notification
|
|
166
|
+
|
|
167
|
+
def _loop_on_pull(self) -> None:
|
|
168
|
+
try:
|
|
169
|
+
self._pull() # Already recorded events.
|
|
170
|
+
while not self._has_been_stopped:
|
|
171
|
+
self._has_been_notified.wait()
|
|
172
|
+
self._pull() # Newly recorded events.
|
|
173
|
+
except BaseException as e: # pragma: no cover
|
|
174
|
+
if self._thread_error is None:
|
|
175
|
+
self._thread_error = e
|
|
176
|
+
self.stop()
|
|
177
|
+
|
|
178
|
+
def _pull(self) -> None:
|
|
179
|
+
while not self._has_been_stopped:
|
|
180
|
+
self._has_been_notified.clear()
|
|
181
|
+
events = list(
|
|
182
|
+
self._recorder.read(
|
|
183
|
+
query=self._query,
|
|
184
|
+
after=self._last_position,
|
|
185
|
+
limit=self.select_limit,
|
|
186
|
+
)
|
|
187
|
+
)
|
|
188
|
+
if len(events) > 0:
|
|
189
|
+
# print("Putting", len(events), "events into queue")
|
|
190
|
+
self._events_queue.put(events)
|
|
191
|
+
self._last_position = events[-1].position
|
|
192
|
+
if len(events) < self.select_limit:
|
|
193
|
+
break
|