python-event-sourcery 0.4.0a11__tar.gz → 0.4.0a14__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_event_sourcery-0.4.0a11 → python_event_sourcery-0.4.0a14}/PKG-INFO +5 -3
- {python_event_sourcery-0.4.0a11 → python_event_sourcery-0.4.0a14}/event_sourcery/event_store/__init__.py +7 -4
- {python_event_sourcery-0.4.0a11 → python_event_sourcery-0.4.0a14}/event_sourcery/event_store/event/dto.py +21 -8
- {python_event_sourcery-0.4.0a11 → python_event_sourcery-0.4.0a14}/event_sourcery/event_store/event/serde.py +6 -2
- {python_event_sourcery-0.4.0a11 → python_event_sourcery-0.4.0a14}/event_sourcery/event_store/stream_id.py +1 -1
- python_event_sourcery-0.4.0a14/event_sourcery_django/__init__.py +80 -0
- python_event_sourcery-0.4.0a14/event_sourcery_django/dto.py +83 -0
- python_event_sourcery-0.4.0a14/event_sourcery_django/event_store.py +151 -0
- python_event_sourcery-0.4.0a14/event_sourcery_django/migrations/0001_initial.py +95 -0
- python_event_sourcery-0.4.0a14/event_sourcery_django/models.py +86 -0
- python_event_sourcery-0.4.0a14/event_sourcery_django/outbox.py +51 -0
- python_event_sourcery-0.4.0a14/event_sourcery_django/subscription.py +164 -0
- {python_event_sourcery-0.4.0a11 → python_event_sourcery-0.4.0a14}/event_sourcery_esdb/dto.py +11 -6
- {python_event_sourcery-0.4.0a11 → python_event_sourcery-0.4.0a14}/event_sourcery_esdb/event_store.py +8 -5
- python_event_sourcery-0.4.0a14/event_sourcery_esdb/py.typed +0 -0
- {python_event_sourcery-0.4.0a11 → python_event_sourcery-0.4.0a14}/event_sourcery_esdb/stream.py +1 -1
- {python_event_sourcery-0.4.0a11 → python_event_sourcery-0.4.0a14}/event_sourcery_sqlalchemy/models.py +2 -2
- {python_event_sourcery-0.4.0a11 → python_event_sourcery-0.4.0a14}/event_sourcery_sqlalchemy/outbox.py +7 -4
- python_event_sourcery-0.4.0a14/event_sourcery_sqlalchemy/py.typed +0 -0
- {python_event_sourcery-0.4.0a11 → python_event_sourcery-0.4.0a14}/pyproject.toml +12 -2
- {python_event_sourcery-0.4.0a11 → python_event_sourcery-0.4.0a14}/LICENSE +0 -0
- {python_event_sourcery-0.4.0a11 → python_event_sourcery-0.4.0a14}/event_sourcery/__init__.py +0 -0
- {python_event_sourcery-0.4.0a11 → python_event_sourcery-0.4.0a14}/event_sourcery/aggregate/__init__.py +0 -0
- {python_event_sourcery-0.4.0a11 → python_event_sourcery-0.4.0a14}/event_sourcery/aggregate/aggregate.py +0 -0
- {python_event_sourcery-0.4.0a11 → python_event_sourcery-0.4.0a14}/event_sourcery/aggregate/repository.py +0 -0
- {python_event_sourcery-0.4.0a11 → python_event_sourcery-0.4.0a14}/event_sourcery/event_store/dispatcher.py +0 -0
- {python_event_sourcery-0.4.0a11 → python_event_sourcery-0.4.0a14}/event_sourcery/event_store/event/__init__.py +1 -1
- {python_event_sourcery-0.4.0a11 → python_event_sourcery-0.4.0a14}/event_sourcery/event_store/event/registry.py +0 -0
- {python_event_sourcery-0.4.0a11 → python_event_sourcery-0.4.0a14}/event_sourcery/event_store/event_store.py +0 -0
- {python_event_sourcery-0.4.0a11 → python_event_sourcery-0.4.0a14}/event_sourcery/event_store/exceptions.py +0 -0
- {python_event_sourcery-0.4.0a11 → python_event_sourcery-0.4.0a14}/event_sourcery/event_store/factory.py +0 -0
- {python_event_sourcery-0.4.0a11 → python_event_sourcery-0.4.0a14}/event_sourcery/event_store/in_memory.py +0 -0
- {python_event_sourcery-0.4.0a11 → python_event_sourcery-0.4.0a14}/event_sourcery/event_store/interfaces.py +0 -0
- {python_event_sourcery-0.4.0a11 → python_event_sourcery-0.4.0a14}/event_sourcery/event_store/outbox.py +0 -0
- {python_event_sourcery-0.4.0a11 → python_event_sourcery-0.4.0a14}/event_sourcery/event_store/subscription.py +0 -0
- {python_event_sourcery-0.4.0a11 → python_event_sourcery-0.4.0a14}/event_sourcery/event_store/tenant_id.py +0 -0
- {python_event_sourcery-0.4.0a11 → python_event_sourcery-0.4.0a14}/event_sourcery/event_store/versioning.py +0 -0
- {python_event_sourcery-0.4.0a11 → python_event_sourcery-0.4.0a14}/event_sourcery/py.typed +0 -0
- {python_event_sourcery-0.4.0a11 → python_event_sourcery-0.4.0a14}/event_sourcery/read_model/__init__.py +0 -0
- {python_event_sourcery-0.4.0a11 → python_event_sourcery-0.4.0a14}/event_sourcery/read_model/cursors_dao.py +0 -0
- {python_event_sourcery-0.4.0a11 → python_event_sourcery-0.4.0a14}/event_sourcery/read_model/projector.py +0 -0
- python_event_sourcery-0.4.0a11/event_sourcery_esdb/py.typed → python_event_sourcery-0.4.0a14/event_sourcery_django/migrations/__init__.py +0 -0
- {python_event_sourcery-0.4.0a11/event_sourcery_sqlalchemy → python_event_sourcery-0.4.0a14/event_sourcery_django}/py.typed +0 -0
- {python_event_sourcery-0.4.0a11 → python_event_sourcery-0.4.0a14}/event_sourcery_esdb/__init__.py +0 -0
- {python_event_sourcery-0.4.0a11 → python_event_sourcery-0.4.0a14}/event_sourcery_esdb/outbox.py +0 -0
- {python_event_sourcery-0.4.0a11 → python_event_sourcery-0.4.0a14}/event_sourcery_esdb/subscription.py +0 -0
- {python_event_sourcery-0.4.0a11 → python_event_sourcery-0.4.0a14}/event_sourcery_sqlalchemy/__init__.py +2 -2
- {python_event_sourcery-0.4.0a11 → python_event_sourcery-0.4.0a14}/event_sourcery_sqlalchemy/cursors_dao.py +0 -0
- {python_event_sourcery-0.4.0a11 → python_event_sourcery-0.4.0a14}/event_sourcery_sqlalchemy/dto.py +0 -0
- {python_event_sourcery-0.4.0a11 → python_event_sourcery-0.4.0a14}/event_sourcery_sqlalchemy/event_store.py +0 -0
- {python_event_sourcery-0.4.0a11 → python_event_sourcery-0.4.0a14}/event_sourcery_sqlalchemy/guid.py +0 -0
- {python_event_sourcery-0.4.0a11 → python_event_sourcery-0.4.0a14}/event_sourcery_sqlalchemy/jsonb.py +0 -0
- {python_event_sourcery-0.4.0a11 → python_event_sourcery-0.4.0a14}/event_sourcery_sqlalchemy/subscription.py +0 -0
|
@@ -1,16 +1,17 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
2
|
Name: python-event-sourcery
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.0a14
|
|
4
4
|
Summary:
|
|
5
5
|
License: MIT
|
|
6
6
|
Author: Your Name
|
|
7
7
|
Author-email: you@example.com
|
|
8
|
-
Requires-Python: >=3.10
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
9
|
Classifier: License :: OSI Approved :: MIT License
|
|
10
10
|
Classifier: Programming Language :: Python :: 3
|
|
11
11
|
Classifier: Programming Language :: Python :: 3.10
|
|
12
12
|
Classifier: Programming Language :: Python :: 3.11
|
|
13
13
|
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
15
|
Provides-Extra: django
|
|
15
16
|
Provides-Extra: esdb
|
|
16
17
|
Provides-Extra: sqlalchemy
|
|
@@ -18,4 +19,5 @@ Requires-Dist: SQLAlchemy (>=2.0) ; extra == "sqlalchemy"
|
|
|
18
19
|
Requires-Dist: django (>=4,<6) ; extra == "django"
|
|
19
20
|
Requires-Dist: esdbclient (>=1.0b3) ; extra == "esdb"
|
|
20
21
|
Requires-Dist: more-itertools
|
|
22
|
+
Requires-Dist: psycopg2-binary (>2,<3)
|
|
21
23
|
Requires-Dist: pydantic (>=2.0,<3)
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
__all__ = [
|
|
2
|
+
"DEFAULT_TENANT",
|
|
3
|
+
"NO_VERSIONING",
|
|
2
4
|
"Backend",
|
|
3
5
|
"BackendFactory",
|
|
6
|
+
"Context",
|
|
4
7
|
"Dispatcher",
|
|
5
8
|
"Entry",
|
|
6
9
|
"Event",
|
|
@@ -9,26 +12,26 @@ __all__ = [
|
|
|
9
12
|
"ExplicitVersioning",
|
|
10
13
|
"InMemoryBackendFactory",
|
|
11
14
|
"Listener",
|
|
12
|
-
"WrappedEvent",
|
|
13
|
-
"NO_VERSIONING",
|
|
14
15
|
"Position",
|
|
15
16
|
"RawEvent",
|
|
16
17
|
"Recorded",
|
|
17
18
|
"RecordedRaw",
|
|
18
19
|
"StreamId",
|
|
19
20
|
"StreamUUID",
|
|
21
|
+
"TenantId",
|
|
20
22
|
"TransactionalBackend",
|
|
21
23
|
"Versioning",
|
|
24
|
+
"WrappedEvent",
|
|
22
25
|
"exceptions",
|
|
23
26
|
"factory",
|
|
24
27
|
"interfaces",
|
|
25
28
|
"subscription",
|
|
26
|
-
"TenantId",
|
|
27
29
|
]
|
|
28
30
|
|
|
29
31
|
from event_sourcery.event_store import exceptions, factory, interfaces, subscription
|
|
30
32
|
from event_sourcery.event_store.dispatcher import Dispatcher, Listener
|
|
31
33
|
from event_sourcery.event_store.event import (
|
|
34
|
+
Context,
|
|
32
35
|
Entry,
|
|
33
36
|
Event,
|
|
34
37
|
EventRegistry,
|
|
@@ -46,7 +49,7 @@ from event_sourcery.event_store.factory import (
|
|
|
46
49
|
)
|
|
47
50
|
from event_sourcery.event_store.in_memory import InMemoryBackendFactory
|
|
48
51
|
from event_sourcery.event_store.stream_id import StreamId, StreamUUID
|
|
49
|
-
from event_sourcery.event_store.tenant_id import TenantId
|
|
52
|
+
from event_sourcery.event_store.tenant_id import DEFAULT_TENANT, TenantId
|
|
50
53
|
from event_sourcery.event_store.versioning import (
|
|
51
54
|
NO_VERSIONING,
|
|
52
55
|
ExplicitVersioning,
|
|
@@ -1,27 +1,30 @@
|
|
|
1
|
-
|
|
1
|
+
import dataclasses
|
|
2
|
+
from datetime import datetime, timezone
|
|
2
3
|
from typing import Any, ClassVar, Generic, TypeAlias, TypeVar
|
|
3
4
|
from uuid import UUID, uuid4
|
|
4
5
|
|
|
5
|
-
from pydantic import BaseModel
|
|
6
|
+
from pydantic import BaseModel
|
|
6
7
|
|
|
7
8
|
from event_sourcery.event_store.event.registry import EventRegistry
|
|
8
9
|
from event_sourcery.event_store.stream_id import StreamId
|
|
9
10
|
from event_sourcery.event_store.tenant_id import DEFAULT_TENANT, TenantId
|
|
10
11
|
|
|
11
12
|
|
|
13
|
+
@dataclasses.dataclass(frozen=True)
|
|
12
14
|
class RawEvent(BaseModel):
|
|
13
15
|
uuid: UUID
|
|
14
16
|
stream_id: StreamId
|
|
15
17
|
created_at: datetime
|
|
16
|
-
version: int | None = None
|
|
17
18
|
name: str
|
|
18
19
|
data: dict
|
|
19
20
|
context: dict
|
|
21
|
+
version: int | None = None
|
|
20
22
|
|
|
21
23
|
|
|
22
24
|
Position: TypeAlias = int
|
|
23
25
|
|
|
24
26
|
|
|
27
|
+
@dataclasses.dataclass(frozen=True)
|
|
25
28
|
class RecordedRaw(BaseModel):
|
|
26
29
|
entry: RawEvent
|
|
27
30
|
position: Position
|
|
@@ -52,7 +55,8 @@ class Context(BaseModel, extra="allow"):
|
|
|
52
55
|
causation_id: UUID | None = None
|
|
53
56
|
|
|
54
57
|
|
|
55
|
-
|
|
58
|
+
@dataclasses.dataclass()
|
|
59
|
+
class WrappedEvent(Generic[TEvent]):
|
|
56
60
|
"""Wrapper for events with all relevant metadata.
|
|
57
61
|
|
|
58
62
|
Returned from EventStore when loading events from a stream.
|
|
@@ -69,20 +73,29 @@ class WrappedEvent(BaseModel, Generic[TEvent], extra="forbid"):
|
|
|
69
73
|
|
|
70
74
|
event: TEvent
|
|
71
75
|
version: int | None
|
|
72
|
-
uuid: UUID =
|
|
73
|
-
created_at: datetime =
|
|
74
|
-
|
|
76
|
+
uuid: UUID = dataclasses.field(default_factory=uuid4)
|
|
77
|
+
created_at: datetime = dataclasses.field(
|
|
78
|
+
default_factory=lambda: datetime.now(timezone.utc).replace(tzinfo=None)
|
|
79
|
+
)
|
|
80
|
+
context: Context = dataclasses.field(default_factory=Context)
|
|
75
81
|
|
|
76
82
|
@classmethod
|
|
77
83
|
def wrap(cls, event: TEvent, version: int | None) -> "WrappedEvent[TEvent]":
|
|
78
84
|
return WrappedEvent[TEvent](event=event, version=version)
|
|
79
85
|
|
|
80
86
|
|
|
81
|
-
|
|
87
|
+
@dataclasses.dataclass(frozen=True)
|
|
88
|
+
class Entry:
|
|
82
89
|
wrapped_event: WrappedEvent
|
|
83
90
|
stream_id: StreamId
|
|
84
91
|
|
|
85
92
|
|
|
93
|
+
@dataclasses.dataclass(frozen=True)
|
|
86
94
|
class Recorded(Entry):
|
|
95
|
+
"""DTO containing events saved in the event store.
|
|
96
|
+
|
|
97
|
+
It contains position of the event in the event store and tenant id.
|
|
98
|
+
"""
|
|
99
|
+
|
|
87
100
|
position: Position
|
|
88
101
|
tenant_id: TenantId = DEFAULT_TENANT
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import dataclasses
|
|
1
2
|
from collections.abc import Mapping, Sequence
|
|
2
3
|
from dataclasses import dataclass
|
|
3
4
|
from typing import cast
|
|
4
5
|
|
|
5
6
|
from event_sourcery.event_store.event.dto import (
|
|
7
|
+
Context,
|
|
6
8
|
Event,
|
|
7
9
|
RawEvent,
|
|
8
10
|
Recorded,
|
|
@@ -18,12 +20,14 @@ class Serde:
|
|
|
18
20
|
registry: EventRegistry
|
|
19
21
|
|
|
20
22
|
def deserialize(self, event: RawEvent) -> WrappedEvent:
|
|
21
|
-
event_as_dict =
|
|
23
|
+
event_as_dict = dataclasses.asdict(event)
|
|
22
24
|
del event_as_dict["stream_id"]
|
|
23
25
|
del event_as_dict["name"]
|
|
24
26
|
data = cast(Mapping, event_as_dict.pop("data"))
|
|
27
|
+
context = event_as_dict.pop("context", {})
|
|
28
|
+
event_as_dict["context"] = Context(**context)
|
|
25
29
|
event_type = self.registry.type_for_name(event.name)
|
|
26
|
-
return WrappedEvent[event_type]( # type: ignore
|
|
30
|
+
return WrappedEvent[event_type]( # type: ignore[valid-type]
|
|
27
31
|
**event_as_dict,
|
|
28
32
|
event=event_type(**data),
|
|
29
33
|
)
|
|
@@ -32,7 +32,7 @@ class StreamUUID(UUID):
|
|
|
32
32
|
return uuid5(self.NAMESPACE, name)
|
|
33
33
|
|
|
34
34
|
def __repr__(self) -> str:
|
|
35
|
-
return f"{type(self).__name__}
|
|
35
|
+
return f"{type(self).__name__}(hex={self!s}, name={self.name})"
|
|
36
36
|
|
|
37
37
|
|
|
38
38
|
@dataclass(frozen=True, repr=False, eq=False)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
__all__ = [
|
|
2
|
+
"Config",
|
|
3
|
+
"DjangoBackendFactory",
|
|
4
|
+
]
|
|
5
|
+
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from datetime import timedelta
|
|
8
|
+
from typing import cast
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, ConfigDict, PositiveInt
|
|
11
|
+
from typing_extensions import Self
|
|
12
|
+
|
|
13
|
+
from event_sourcery import event_store as es
|
|
14
|
+
from event_sourcery.event_store import (
|
|
15
|
+
BackendFactory,
|
|
16
|
+
Dispatcher,
|
|
17
|
+
Event,
|
|
18
|
+
EventRegistry,
|
|
19
|
+
EventStore,
|
|
20
|
+
)
|
|
21
|
+
from event_sourcery.event_store.event import Serde
|
|
22
|
+
from event_sourcery.event_store.factory import (
|
|
23
|
+
NoOutboxStorageStrategy,
|
|
24
|
+
TransactionalBackend,
|
|
25
|
+
no_filter,
|
|
26
|
+
)
|
|
27
|
+
from event_sourcery.event_store.interfaces import (
|
|
28
|
+
OutboxFiltererStrategy,
|
|
29
|
+
OutboxStorageStrategy,
|
|
30
|
+
)
|
|
31
|
+
from event_sourcery.event_store.outbox import Outbox
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Config(BaseModel):
|
|
35
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
36
|
+
|
|
37
|
+
outbox_attempts: PositiveInt = 3
|
|
38
|
+
gap_retry_interval: timedelta = timedelta(seconds=0.5)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(repr=False)
|
|
42
|
+
class DjangoBackendFactory(BackendFactory):
|
|
43
|
+
_config: Config = field(default_factory=Config)
|
|
44
|
+
_serde: Serde = field(default_factory=lambda: Serde(Event.__registry__))
|
|
45
|
+
_outbox_strategy: OutboxStorageStrategy | None = None
|
|
46
|
+
|
|
47
|
+
def build(self) -> TransactionalBackend:
|
|
48
|
+
from event_sourcery_django.event_store import DjangoStorageStrategy
|
|
49
|
+
from event_sourcery_django.outbox import DjangoOutboxStorageStrategy
|
|
50
|
+
from event_sourcery_django.subscription import DjangoSubscriptionStrategy
|
|
51
|
+
|
|
52
|
+
outbox = cast(DjangoOutboxStorageStrategy | None, self._outbox_strategy)
|
|
53
|
+
backend = TransactionalBackend()
|
|
54
|
+
backend.serde = self._serde
|
|
55
|
+
backend.in_transaction = Dispatcher(backend.serde)
|
|
56
|
+
storage_strategy = DjangoStorageStrategy(backend.in_transaction, outbox)
|
|
57
|
+
backend.event_store = EventStore(storage_strategy, backend.serde)
|
|
58
|
+
backend.outbox = Outbox(outbox or NoOutboxStorageStrategy(), backend.serde)
|
|
59
|
+
backend.subscriber = es.subscription.SubscriptionBuilder(
|
|
60
|
+
_serde=backend.serde,
|
|
61
|
+
_strategy=DjangoSubscriptionStrategy(self._config.gap_retry_interval),
|
|
62
|
+
)
|
|
63
|
+
return backend
|
|
64
|
+
|
|
65
|
+
def with_event_registry(self, event_registry: EventRegistry) -> Self:
|
|
66
|
+
self._serde = Serde(event_registry)
|
|
67
|
+
return self
|
|
68
|
+
|
|
69
|
+
def with_outbox(self, filterer: OutboxFiltererStrategy = no_filter) -> Self:
|
|
70
|
+
from event_sourcery_django.outbox import DjangoOutboxStorageStrategy
|
|
71
|
+
|
|
72
|
+
self._outbox_strategy = DjangoOutboxStorageStrategy(
|
|
73
|
+
filterer,
|
|
74
|
+
self._config.outbox_attempts,
|
|
75
|
+
)
|
|
76
|
+
return self
|
|
77
|
+
|
|
78
|
+
def without_outbox(self, filterer: OutboxFiltererStrategy = no_filter) -> Self:
|
|
79
|
+
self._outbox_strategy = None
|
|
80
|
+
return self
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
from datetime import datetime, timezone
|
|
2
|
+
from uuid import UUID
|
|
3
|
+
|
|
4
|
+
from event_sourcery.event_store import RawEvent, RecordedRaw, StreamId
|
|
5
|
+
from event_sourcery_django.models import Event, OutboxEntry, Snapshot, Stream
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def raw_event(from_entry: Event, in_stream: Stream) -> RawEvent:
|
|
9
|
+
return RawEvent(
|
|
10
|
+
uuid=from_entry.uuid,
|
|
11
|
+
stream_id=StreamId(
|
|
12
|
+
uuid=in_stream.uuid,
|
|
13
|
+
name=in_stream.name,
|
|
14
|
+
category=None if in_stream.category == "" else in_stream.category,
|
|
15
|
+
),
|
|
16
|
+
created_at=from_entry.created_at,
|
|
17
|
+
version=from_entry.version,
|
|
18
|
+
name=from_entry.name,
|
|
19
|
+
data=from_entry.data,
|
|
20
|
+
context=from_entry.event_context,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def entry(from_raw: RawEvent, to_stream: Stream) -> Event:
|
|
25
|
+
return Event(
|
|
26
|
+
uuid=from_raw.uuid,
|
|
27
|
+
created_at=from_raw.created_at,
|
|
28
|
+
name=from_raw.name,
|
|
29
|
+
data=from_raw.data,
|
|
30
|
+
event_context=from_raw.context,
|
|
31
|
+
version=from_raw.version,
|
|
32
|
+
stream=to_stream,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def snapshot(from_raw: RawEvent, to_stream: Stream) -> Snapshot:
|
|
37
|
+
return Snapshot(
|
|
38
|
+
uuid=from_raw.uuid,
|
|
39
|
+
created_at=from_raw.created_at,
|
|
40
|
+
name=from_raw.name,
|
|
41
|
+
data=from_raw.data,
|
|
42
|
+
event_context=from_raw.context,
|
|
43
|
+
version=from_raw.version,
|
|
44
|
+
stream=to_stream,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def outbox_entry(from_raw: RecordedRaw, max_attempts: int) -> OutboxEntry:
|
|
49
|
+
return OutboxEntry(
|
|
50
|
+
created_at=datetime.now(timezone.utc).replace(tzinfo=None),
|
|
51
|
+
data={
|
|
52
|
+
"created_at": from_raw.entry.created_at.isoformat(),
|
|
53
|
+
"uuid": str(from_raw.entry.uuid),
|
|
54
|
+
"stream_id": str(from_raw.entry.stream_id),
|
|
55
|
+
"version": from_raw.entry.version,
|
|
56
|
+
"name": from_raw.entry.name,
|
|
57
|
+
"data": from_raw.entry.data,
|
|
58
|
+
"context": from_raw.entry.context,
|
|
59
|
+
"tenant_id": from_raw.tenant_id,
|
|
60
|
+
},
|
|
61
|
+
stream_name=from_raw.entry.stream_id.name,
|
|
62
|
+
position=from_raw.position,
|
|
63
|
+
tries_left=max_attempts,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def raw_outbox(from_entry: OutboxEntry) -> RecordedRaw:
|
|
68
|
+
return RecordedRaw(
|
|
69
|
+
entry=RawEvent(
|
|
70
|
+
uuid=UUID(from_entry.data["uuid"]),
|
|
71
|
+
stream_id=StreamId(
|
|
72
|
+
from_hex=from_entry.data["stream_id"],
|
|
73
|
+
name=from_entry.stream_name,
|
|
74
|
+
),
|
|
75
|
+
created_at=datetime.fromisoformat(from_entry.data["created_at"]),
|
|
76
|
+
version=from_entry.data["version"],
|
|
77
|
+
name=from_entry.data["name"],
|
|
78
|
+
data=from_entry.data["data"],
|
|
79
|
+
context=from_entry.data["context"],
|
|
80
|
+
),
|
|
81
|
+
position=from_entry.position,
|
|
82
|
+
tenant_id=from_entry.data["tenant_id"],
|
|
83
|
+
)
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
from collections.abc import Sequence
|
|
2
|
+
from dataclasses import dataclass, replace
|
|
3
|
+
from typing import cast
|
|
4
|
+
|
|
5
|
+
from more_itertools import first, first_true
|
|
6
|
+
from typing_extensions import Self
|
|
7
|
+
|
|
8
|
+
from event_sourcery.event_store import (
|
|
9
|
+
NO_VERSIONING,
|
|
10
|
+
Dispatcher,
|
|
11
|
+
Position,
|
|
12
|
+
RawEvent,
|
|
13
|
+
RecordedRaw,
|
|
14
|
+
StreamId,
|
|
15
|
+
TenantId,
|
|
16
|
+
Versioning,
|
|
17
|
+
)
|
|
18
|
+
from event_sourcery.event_store.exceptions import (
|
|
19
|
+
AnotherStreamWithThisNameButOtherIdExists,
|
|
20
|
+
ConcurrentStreamWriteError,
|
|
21
|
+
)
|
|
22
|
+
from event_sourcery.event_store.interfaces import StorageStrategy
|
|
23
|
+
from event_sourcery.event_store.tenant_id import DEFAULT_TENANT
|
|
24
|
+
from event_sourcery_django import dto, models
|
|
25
|
+
from event_sourcery_django.outbox import DjangoOutboxStorageStrategy
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(repr=True)
|
|
29
|
+
class DjangoStorageStrategy(StorageStrategy):
|
|
30
|
+
_dispatcher: Dispatcher
|
|
31
|
+
_outbox: DjangoOutboxStorageStrategy | None = None
|
|
32
|
+
_tenant_id: TenantId = DEFAULT_TENANT
|
|
33
|
+
|
|
34
|
+
def fetch_events(
|
|
35
|
+
self,
|
|
36
|
+
stream_id: StreamId,
|
|
37
|
+
start: int | None = None,
|
|
38
|
+
stop: int | None = None,
|
|
39
|
+
) -> list[RawEvent]:
|
|
40
|
+
try:
|
|
41
|
+
stream = models.Stream.objects.by_stream_id(
|
|
42
|
+
stream_id=stream_id,
|
|
43
|
+
tenant_id=self._tenant_id,
|
|
44
|
+
).get()
|
|
45
|
+
except models.Stream.DoesNotExist:
|
|
46
|
+
return []
|
|
47
|
+
|
|
48
|
+
events_query = models.Event.objects.filter(stream=stream).order_by("version")
|
|
49
|
+
|
|
50
|
+
if start is not None:
|
|
51
|
+
events_query = events_query.filter(version__gte=start)
|
|
52
|
+
|
|
53
|
+
if stop is not None:
|
|
54
|
+
events_query = events_query.filter(version__lt=stop)
|
|
55
|
+
|
|
56
|
+
events: Sequence[models.Event | models.Snapshot]
|
|
57
|
+
|
|
58
|
+
snapshot_query = models.Snapshot.objects.filter(stream=stream).order_by(
|
|
59
|
+
"-created_at"
|
|
60
|
+
)
|
|
61
|
+
if start is not None:
|
|
62
|
+
snapshot_query = snapshot_query.filter(version__gte=start)
|
|
63
|
+
if stop is not None:
|
|
64
|
+
snapshot_query = snapshot_query.filter(version__lt=stop)
|
|
65
|
+
latest_snapshot = snapshot_query.first()
|
|
66
|
+
if latest_snapshot is None:
|
|
67
|
+
events = events_query.all()
|
|
68
|
+
else:
|
|
69
|
+
newer_events = events_query.filter(
|
|
70
|
+
version__gt=latest_snapshot.version
|
|
71
|
+
).all()
|
|
72
|
+
events = [latest_snapshot, *list(newer_events)]
|
|
73
|
+
|
|
74
|
+
return [dto.raw_event(event, stream) for event in events]
|
|
75
|
+
|
|
76
|
+
def insert_events(
|
|
77
|
+
self,
|
|
78
|
+
stream_id: StreamId,
|
|
79
|
+
versioning: Versioning,
|
|
80
|
+
events: list[RawEvent],
|
|
81
|
+
) -> None:
|
|
82
|
+
self._ensure_stream(stream_id=stream_id, versioning=versioning)
|
|
83
|
+
event = cast(RawEvent, first(events))
|
|
84
|
+
stream = models.Stream.objects.by_stream_id(
|
|
85
|
+
stream_id=event.stream_id,
|
|
86
|
+
tenant_id=self._tenant_id,
|
|
87
|
+
).get()
|
|
88
|
+
entries = [dto.entry(event, stream) for event in events]
|
|
89
|
+
models.Event.objects.bulk_create(entries)
|
|
90
|
+
records = [
|
|
91
|
+
RecordedRaw(entry=raw, position=db.id, tenant_id=self._tenant_id)
|
|
92
|
+
for raw, db in zip(events, entries, strict=False)
|
|
93
|
+
]
|
|
94
|
+
if self._outbox:
|
|
95
|
+
self._outbox.put_into_outbox(records)
|
|
96
|
+
self._dispatcher.dispatch(*records)
|
|
97
|
+
|
|
98
|
+
def _ensure_stream(self, stream_id: StreamId, versioning: Versioning) -> None:
|
|
99
|
+
initial_version = versioning.initial_version
|
|
100
|
+
|
|
101
|
+
matching_streams = models.Stream.objects.by_stream_id(
|
|
102
|
+
stream_id=stream_id,
|
|
103
|
+
tenant_id=self._tenant_id,
|
|
104
|
+
).all()
|
|
105
|
+
if stream_id.name and matching_streams:
|
|
106
|
+
stream_with_same_name = first_true(
|
|
107
|
+
matching_streams, pred=lambda stream: stream.name == stream_id.name
|
|
108
|
+
)
|
|
109
|
+
if (
|
|
110
|
+
stream_with_same_name is not None
|
|
111
|
+
and stream_with_same_name.uuid != stream_id
|
|
112
|
+
):
|
|
113
|
+
raise AnotherStreamWithThisNameButOtherIdExists()
|
|
114
|
+
|
|
115
|
+
model, created = models.Stream.objects.get_or_create(
|
|
116
|
+
uuid=stream_id,
|
|
117
|
+
name=stream_id.name,
|
|
118
|
+
category=stream_id.category or "",
|
|
119
|
+
tenant_id=self._tenant_id,
|
|
120
|
+
defaults={"version": initial_version},
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
versioning.validate_if_compatible(model.version)
|
|
124
|
+
|
|
125
|
+
if versioning.expected_version and versioning is not NO_VERSIONING:
|
|
126
|
+
result = models.Stream.objects.filter(
|
|
127
|
+
id=model.id, version=versioning.expected_version
|
|
128
|
+
).update(version=versioning.initial_version)
|
|
129
|
+
if result != 1:
|
|
130
|
+
raise ConcurrentStreamWriteError
|
|
131
|
+
|
|
132
|
+
def save_snapshot(self, snapshot: RawEvent) -> None:
|
|
133
|
+
stream = models.Stream.objects.by_stream_id(
|
|
134
|
+
stream_id=snapshot.stream_id,
|
|
135
|
+
tenant_id=self._tenant_id,
|
|
136
|
+
).get()
|
|
137
|
+
entry = dto.snapshot(from_raw=snapshot, to_stream=stream)
|
|
138
|
+
entry.save()
|
|
139
|
+
|
|
140
|
+
def delete_stream(self, stream_id: StreamId) -> None:
|
|
141
|
+
models.Stream.objects.by_stream_id(
|
|
142
|
+
stream_id=stream_id, tenant_id=self._tenant_id
|
|
143
|
+
).delete()
|
|
144
|
+
|
|
145
|
+
@property
|
|
146
|
+
def current_position(self) -> Position | None:
|
|
147
|
+
last_event = models.Event.objects.last()
|
|
148
|
+
return last_event.id if last_event else Position(0)
|
|
149
|
+
|
|
150
|
+
def scoped_for_tenant(self, tenant_id: TenantId) -> Self:
|
|
151
|
+
return replace(self, _tenant_id=tenant_id)
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# Generated by Django 5.0.4 on 2024-04-17 19:26
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
|
|
5
|
+
import django.db.models.deletion
|
|
6
|
+
from django.db import migrations, models
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Migration(migrations.Migration):
|
|
10
|
+
initial = True
|
|
11
|
+
|
|
12
|
+
dependencies: list = []
|
|
13
|
+
|
|
14
|
+
operations = [
|
|
15
|
+
migrations.CreateModel(
|
|
16
|
+
name="OutboxEntry",
|
|
17
|
+
fields=[
|
|
18
|
+
("id", models.BigAutoField(primary_key=True, serialize=False)),
|
|
19
|
+
("created_at", models.DateTimeField()),
|
|
20
|
+
("data", models.JSONField()),
|
|
21
|
+
(
|
|
22
|
+
"stream_name",
|
|
23
|
+
models.CharField(blank=True, max_length=255, null=True),
|
|
24
|
+
),
|
|
25
|
+
("position", models.IntegerField()),
|
|
26
|
+
("tries_left", models.IntegerField(default=3)),
|
|
27
|
+
],
|
|
28
|
+
),
|
|
29
|
+
migrations.CreateModel(
|
|
30
|
+
name="Stream",
|
|
31
|
+
fields=[
|
|
32
|
+
("id", models.BigAutoField(primary_key=True, serialize=False)),
|
|
33
|
+
("uuid", models.UUIDField(default=uuid.uuid4, editable=False)),
|
|
34
|
+
("name", models.CharField(blank=True, max_length=255, null=True)),
|
|
35
|
+
("category", models.CharField(default="", max_length=255)),
|
|
36
|
+
("tenant_id", models.CharField(max_length=255)),
|
|
37
|
+
("version", models.BigIntegerField(blank=True, null=True)),
|
|
38
|
+
],
|
|
39
|
+
options={
|
|
40
|
+
"unique_together": {
|
|
41
|
+
("name", "category", "tenant_id"),
|
|
42
|
+
("uuid", "category", "tenant_id"),
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
),
|
|
46
|
+
migrations.CreateModel(
|
|
47
|
+
name="Snapshot",
|
|
48
|
+
fields=[
|
|
49
|
+
("uuid", models.UUIDField(primary_key=True, serialize=False)),
|
|
50
|
+
("version", models.IntegerField()),
|
|
51
|
+
("name", models.CharField(max_length=50)),
|
|
52
|
+
("data", models.JSONField()),
|
|
53
|
+
("event_context", models.JSONField()),
|
|
54
|
+
("created_at", models.DateTimeField()),
|
|
55
|
+
(
|
|
56
|
+
"stream",
|
|
57
|
+
models.ForeignKey(
|
|
58
|
+
on_delete=django.db.models.deletion.CASCADE,
|
|
59
|
+
related_name="snapshots",
|
|
60
|
+
to="event_sourcery_django.stream",
|
|
61
|
+
),
|
|
62
|
+
),
|
|
63
|
+
],
|
|
64
|
+
),
|
|
65
|
+
migrations.CreateModel(
|
|
66
|
+
name="Event",
|
|
67
|
+
fields=[
|
|
68
|
+
("id", models.BigAutoField(primary_key=True, serialize=False)),
|
|
69
|
+
("version", models.IntegerField(blank=True, null=True)),
|
|
70
|
+
(
|
|
71
|
+
"uuid",
|
|
72
|
+
models.UUIDField(default=uuid.uuid4, editable=False, unique=True),
|
|
73
|
+
),
|
|
74
|
+
("name", models.CharField(max_length=200)),
|
|
75
|
+
("data", models.JSONField()),
|
|
76
|
+
("event_context", models.JSONField()),
|
|
77
|
+
("created_at", models.DateTimeField()),
|
|
78
|
+
(
|
|
79
|
+
"stream",
|
|
80
|
+
models.ForeignKey(
|
|
81
|
+
on_delete=django.db.models.deletion.CASCADE,
|
|
82
|
+
related_name="events",
|
|
83
|
+
to="event_sourcery_django.stream",
|
|
84
|
+
),
|
|
85
|
+
),
|
|
86
|
+
],
|
|
87
|
+
options={
|
|
88
|
+
"indexes": [
|
|
89
|
+
models.Index(
|
|
90
|
+
fields=["stream", "version"], name="ix_events_stream_id_version"
|
|
91
|
+
)
|
|
92
|
+
],
|
|
93
|
+
},
|
|
94
|
+
),
|
|
95
|
+
]
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
from typing import ClassVar
|
|
2
|
+
from uuid import uuid4
|
|
3
|
+
|
|
4
|
+
from django.core.exceptions import ObjectDoesNotExist
|
|
5
|
+
from django.db import models
|
|
6
|
+
|
|
7
|
+
from event_sourcery.event_store import StreamId, TenantId
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class StreamManager(models.Manager):
|
|
11
|
+
def by_stream_id(self, stream_id: StreamId, tenant_id: TenantId) -> models.QuerySet:
|
|
12
|
+
category = stream_id.category or ""
|
|
13
|
+
condition = models.Q(uuid=stream_id, category=category, tenant_id=tenant_id)
|
|
14
|
+
if stream_id.name:
|
|
15
|
+
condition = condition | models.Q(
|
|
16
|
+
name=stream_id.name,
|
|
17
|
+
category=category,
|
|
18
|
+
tenant_id=tenant_id,
|
|
19
|
+
)
|
|
20
|
+
return self.filter(condition)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Stream(models.Model):
|
|
24
|
+
DoesNotExist: ClassVar[ObjectDoesNotExist]
|
|
25
|
+
|
|
26
|
+
id = models.BigAutoField(primary_key=True)
|
|
27
|
+
uuid = models.UUIDField(default=uuid4, editable=False)
|
|
28
|
+
name = models.CharField(max_length=255, null=True, blank=True)
|
|
29
|
+
category = models.CharField(max_length=255, default="")
|
|
30
|
+
tenant_id = models.CharField(max_length=255)
|
|
31
|
+
version = models.BigIntegerField(null=True, blank=True)
|
|
32
|
+
|
|
33
|
+
objects = StreamManager()
|
|
34
|
+
|
|
35
|
+
class Meta:
|
|
36
|
+
unique_together = (
|
|
37
|
+
("uuid", "category", "tenant_id"),
|
|
38
|
+
("name", "category", "tenant_id"),
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class Event(models.Model):
|
|
43
|
+
objects: models.Manager
|
|
44
|
+
|
|
45
|
+
id = models.BigAutoField(primary_key=True)
|
|
46
|
+
version = models.IntegerField(null=True, blank=True)
|
|
47
|
+
uuid = models.UUIDField(default=uuid4, editable=False, unique=True)
|
|
48
|
+
name = models.CharField(max_length=200)
|
|
49
|
+
data = models.JSONField()
|
|
50
|
+
event_context = models.JSONField()
|
|
51
|
+
created_at = models.DateTimeField()
|
|
52
|
+
stream = models.ForeignKey(Stream, related_name="events", on_delete=models.CASCADE)
|
|
53
|
+
|
|
54
|
+
class Meta:
|
|
55
|
+
indexes = [
|
|
56
|
+
models.Index(
|
|
57
|
+
fields=["stream", "version"], name="ix_events_stream_id_version"
|
|
58
|
+
),
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class Snapshot(models.Model):
|
|
63
|
+
objects: models.Manager
|
|
64
|
+
|
|
65
|
+
uuid = models.UUIDField(primary_key=True)
|
|
66
|
+
version = models.IntegerField()
|
|
67
|
+
stream = models.ForeignKey(
|
|
68
|
+
Stream,
|
|
69
|
+
related_name="snapshots",
|
|
70
|
+
on_delete=models.CASCADE,
|
|
71
|
+
)
|
|
72
|
+
name = models.CharField(max_length=50)
|
|
73
|
+
data = models.JSONField()
|
|
74
|
+
event_context = models.JSONField()
|
|
75
|
+
created_at = models.DateTimeField()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class OutboxEntry(models.Model):
|
|
79
|
+
objects: models.Manager
|
|
80
|
+
|
|
81
|
+
id = models.BigAutoField(primary_key=True)
|
|
82
|
+
created_at = models.DateTimeField()
|
|
83
|
+
data = models.JSONField()
|
|
84
|
+
stream_name = models.CharField(max_length=255, null=True, blank=True)
|
|
85
|
+
position = models.BigIntegerField()
|
|
86
|
+
tries_left = models.IntegerField()
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from collections.abc import Iterator
|
|
3
|
+
from contextlib import AbstractContextManager, contextmanager
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
from event_sourcery.event_store import RecordedRaw
|
|
7
|
+
from event_sourcery.event_store.interfaces import (
|
|
8
|
+
OutboxFiltererStrategy,
|
|
9
|
+
OutboxStorageStrategy,
|
|
10
|
+
)
|
|
11
|
+
from event_sourcery_django import dto
|
|
12
|
+
from event_sourcery_django.models import OutboxEntry
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(repr=False)
|
|
18
|
+
class DjangoOutboxStorageStrategy(OutboxStorageStrategy):
|
|
19
|
+
_filterer: OutboxFiltererStrategy
|
|
20
|
+
_max_publish_attempts: int
|
|
21
|
+
|
|
22
|
+
def put_into_outbox(self, records: list[RecordedRaw]) -> None:
|
|
23
|
+
OutboxEntry.objects.bulk_create(
|
|
24
|
+
dto.outbox_entry(record, self._max_publish_attempts)
|
|
25
|
+
for record in records
|
|
26
|
+
if self._filterer(record.entry)
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
def outbox_entries(
|
|
30
|
+
self, limit: int
|
|
31
|
+
) -> Iterator[AbstractContextManager[RecordedRaw]]:
|
|
32
|
+
entries = (
|
|
33
|
+
OutboxEntry.objects.select_for_update(skip_locked=True)
|
|
34
|
+
.filter(tries_left__gt=0)
|
|
35
|
+
.order_by("id")[:limit]
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
for entry in entries:
|
|
39
|
+
yield self._publish_context(entry)
|
|
40
|
+
|
|
41
|
+
@contextmanager
|
|
42
|
+
def _publish_context(self, entry: OutboxEntry) -> Iterator[RecordedRaw]:
|
|
43
|
+
raw = dto.raw_outbox(entry)
|
|
44
|
+
try:
|
|
45
|
+
yield raw
|
|
46
|
+
except Exception:
|
|
47
|
+
logger.exception("Failed to publish message #%d", entry.id)
|
|
48
|
+
entry.tries_left -= 1
|
|
49
|
+
entry.save()
|
|
50
|
+
else:
|
|
51
|
+
entry.delete()
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import time
|
|
2
|
+
from collections.abc import Iterator
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from datetime import timedelta
|
|
5
|
+
from typing import Protocol, cast
|
|
6
|
+
|
|
7
|
+
from event_sourcery.event_store import Position, RecordedRaw
|
|
8
|
+
from event_sourcery.event_store.interfaces import SubscriptionStrategy
|
|
9
|
+
from event_sourcery_django import dto, models
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DjangoSubscriptionStrategy(SubscriptionStrategy):
|
|
13
|
+
def __init__(self, gap_retry_interval: timedelta) -> None:
|
|
14
|
+
self._gap_retry_interval = gap_retry_interval
|
|
15
|
+
|
|
16
|
+
def subscribe_to_all(
|
|
17
|
+
self,
|
|
18
|
+
start_from: Position,
|
|
19
|
+
batch_size: int,
|
|
20
|
+
timelimit: timedelta,
|
|
21
|
+
) -> Iterator[list[RecordedRaw]]:
|
|
22
|
+
return GapDetectingIterator(
|
|
23
|
+
get_batch=GetBatchToAll(batch_size),
|
|
24
|
+
gap_retry_interval=self._gap_retry_interval,
|
|
25
|
+
start_from=start_from,
|
|
26
|
+
batch_size=batch_size,
|
|
27
|
+
timelimit=timelimit,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
def subscribe_to_category(
|
|
31
|
+
self,
|
|
32
|
+
start_from: Position,
|
|
33
|
+
batch_size: int,
|
|
34
|
+
timelimit: timedelta,
|
|
35
|
+
category: str,
|
|
36
|
+
) -> Iterator[list[RecordedRaw]]:
|
|
37
|
+
return GapDetectingIterator(
|
|
38
|
+
get_batch=GetBatchToCategory(batch_size, category),
|
|
39
|
+
gap_retry_interval=self._gap_retry_interval,
|
|
40
|
+
start_from=start_from,
|
|
41
|
+
batch_size=batch_size,
|
|
42
|
+
timelimit=timelimit,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
def subscribe_to_events(
|
|
46
|
+
self,
|
|
47
|
+
start_from: Position,
|
|
48
|
+
batch_size: int,
|
|
49
|
+
timelimit: timedelta,
|
|
50
|
+
events: list[str],
|
|
51
|
+
) -> Iterator[list[RecordedRaw]]:
|
|
52
|
+
return GapDetectingIterator(
|
|
53
|
+
get_batch=GetBatchToEvents(batch_size, events),
|
|
54
|
+
gap_retry_interval=self._gap_retry_interval,
|
|
55
|
+
start_from=start_from,
|
|
56
|
+
batch_size=batch_size,
|
|
57
|
+
timelimit=timelimit,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class GetBatch(Protocol):
|
|
62
|
+
def __call__(self, position: Position) -> list[models.Event]: ...
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class GetBatchToAll(GetBatch):
|
|
66
|
+
def __init__(self, batch_size: int) -> None:
|
|
67
|
+
self._batch_size = batch_size
|
|
68
|
+
|
|
69
|
+
def __call__(self, position: Position) -> list[models.Event]:
|
|
70
|
+
query = (
|
|
71
|
+
models.Event.objects.filter(id__gt=position)
|
|
72
|
+
.select_related("stream")
|
|
73
|
+
.order_by("id")
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
return list(query[: self._batch_size])
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class GetBatchToCategory(GetBatch):
|
|
80
|
+
def __init__(self, batch_size: int, category: str) -> None:
|
|
81
|
+
self._batch_size = batch_size
|
|
82
|
+
self._category = category
|
|
83
|
+
|
|
84
|
+
def __call__(self, position: Position) -> list[models.Event]:
|
|
85
|
+
query = (
|
|
86
|
+
models.Event.objects.filter(
|
|
87
|
+
id__gt=position, stream__category=self._category
|
|
88
|
+
)
|
|
89
|
+
.select_related("stream")
|
|
90
|
+
.order_by("id")
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
return list(query[: self._batch_size])
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class GetBatchToEvents(GetBatch):
|
|
97
|
+
def __init__(self, batch_size: int, events: list[str]) -> None:
|
|
98
|
+
self._batch_size = batch_size
|
|
99
|
+
self._events = events
|
|
100
|
+
|
|
101
|
+
def __call__(self, position: Position) -> list[models.Event]:
|
|
102
|
+
query = (
|
|
103
|
+
models.Event.objects.filter(id__gt=position, name__in=self._events)
|
|
104
|
+
.select_related("stream")
|
|
105
|
+
.order_by("id")
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
return list(query[: self._batch_size])
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass
|
|
112
|
+
class Cursor:
|
|
113
|
+
position: Position
|
|
114
|
+
|
|
115
|
+
def advance(self, batch: list[models.Event]) -> None:
|
|
116
|
+
if len(batch) > 0:
|
|
117
|
+
self.position = batch[-1].id
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class GapDetectingIterator(Iterator[list[RecordedRaw]]):
|
|
121
|
+
def __init__(
|
|
122
|
+
self,
|
|
123
|
+
get_batch: GetBatch,
|
|
124
|
+
gap_retry_interval: timedelta,
|
|
125
|
+
start_from: Position,
|
|
126
|
+
batch_size: int,
|
|
127
|
+
timelimit: timedelta,
|
|
128
|
+
) -> None:
|
|
129
|
+
self._get_batch = get_batch
|
|
130
|
+
self._gap_retry_interval = gap_retry_interval
|
|
131
|
+
self._cursor = Cursor(position=start_from)
|
|
132
|
+
self._batch_size = batch_size
|
|
133
|
+
self._timelimit = timelimit
|
|
134
|
+
|
|
135
|
+
def __next__(self) -> list[RecordedRaw]:
|
|
136
|
+
start = time.monotonic()
|
|
137
|
+
while True:
|
|
138
|
+
batch = self._get_batch(self._cursor.position)
|
|
139
|
+
if self._is_continuous(batch) and len(batch) == self._batch_size:
|
|
140
|
+
self._cursor.advance(batch)
|
|
141
|
+
return self._batch_to_recorded_raw(batch)
|
|
142
|
+
elif time.monotonic() - start > self._timelimit.total_seconds():
|
|
143
|
+
self._cursor.advance(batch)
|
|
144
|
+
return self._batch_to_recorded_raw(batch)
|
|
145
|
+
else:
|
|
146
|
+
time.sleep(self._gap_retry_interval.total_seconds())
|
|
147
|
+
|
|
148
|
+
@staticmethod
|
|
149
|
+
def _is_continuous(batch: list[models.Event]) -> bool:
|
|
150
|
+
if len(batch) < 2:
|
|
151
|
+
return False
|
|
152
|
+
|
|
153
|
+
return cast(bool, batch[-1].id - batch[0].id + 1 == len(batch))
|
|
154
|
+
|
|
155
|
+
@staticmethod
|
|
156
|
+
def _batch_to_recorded_raw(batch: list[models.Event]) -> list[RecordedRaw]:
|
|
157
|
+
return [
|
|
158
|
+
RecordedRaw(
|
|
159
|
+
entry=dto.raw_event(event, event.stream),
|
|
160
|
+
position=event.id,
|
|
161
|
+
tenant_id=event.stream.tenant_id,
|
|
162
|
+
)
|
|
163
|
+
for event in batch
|
|
164
|
+
]
|
{python_event_sourcery-0.4.0a11 → python_event_sourcery-0.4.0a14}/event_sourcery_esdb/dto.py
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import json
|
|
2
2
|
from datetime import datetime
|
|
3
|
-
from typing import Any
|
|
3
|
+
from typing import Any, Literal, cast
|
|
4
4
|
|
|
5
5
|
from esdbclient import NewEvent, RecordedEvent
|
|
6
6
|
|
|
@@ -10,15 +10,22 @@ from event_sourcery_esdb import stream
|
|
|
10
10
|
ES_PREFIX = "$es-"
|
|
11
11
|
|
|
12
12
|
|
|
13
|
-
def raw_event(
|
|
13
|
+
def raw_event(
|
|
14
|
+
from_entry: RecordedEvent, version: int | Literal["undefined"] = "undefined"
|
|
15
|
+
) -> RawEvent:
|
|
14
16
|
metadata = json.loads(from_entry.metadata.decode("utf-8"))
|
|
15
17
|
created_at = datetime.fromisoformat(metadata.pop("created_at"))
|
|
16
18
|
position = stream.Position(from_entry.stream_position)
|
|
19
|
+
if version == "undefined":
|
|
20
|
+
version = position.as_version()
|
|
21
|
+
|
|
22
|
+
version = cast(int, version)
|
|
23
|
+
|
|
17
24
|
return RawEvent(
|
|
18
25
|
uuid=from_entry.id,
|
|
19
26
|
stream_id=stream.Name.from_stream_name(from_entry.stream_name).uuid,
|
|
20
27
|
created_at=created_at,
|
|
21
|
-
version=
|
|
28
|
+
version=version,
|
|
22
29
|
name=from_entry.type,
|
|
23
30
|
data=json.loads(from_entry.data.decode("utf-8")),
|
|
24
31
|
context={k: v for k, v in metadata.items() if not k.startswith(ES_PREFIX)},
|
|
@@ -28,9 +35,7 @@ def raw_event(from_entry: RecordedEvent) -> RawEvent:
|
|
|
28
35
|
def snapshot(from_entry: RecordedEvent) -> RawEvent:
|
|
29
36
|
metadata = json.loads(from_entry.metadata.decode("utf-8"))
|
|
30
37
|
position = metadata[f"{ES_PREFIX}stream_position"]
|
|
31
|
-
|
|
32
|
-
event.version = stream.Position(position).as_version()
|
|
33
|
-
return event
|
|
38
|
+
return raw_event(from_entry, version=stream.Position(position).as_version())
|
|
34
39
|
|
|
35
40
|
|
|
36
41
|
def new_entry(from_raw: RawEvent, **metadata: Any) -> NewEvent:
|
{python_event_sourcery-0.4.0a11 → python_event_sourcery-0.4.0a14}/event_sourcery_esdb/event_store.py
RENAMED
|
@@ -74,11 +74,14 @@ class ESDBStorageStrategy(StorageStrategy):
|
|
|
74
74
|
self._append_events(stream_name, events=stream_events)
|
|
75
75
|
|
|
76
76
|
def _append_events(self, name: stream.Name, events: list[RawEvent]) -> int:
|
|
77
|
-
return
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
77
|
+
return cast(
|
|
78
|
+
int,
|
|
79
|
+
self._client.append_events(
|
|
80
|
+
str(name),
|
|
81
|
+
current_version=StreamState.ANY,
|
|
82
|
+
events=(dto.new_entry(e) for e in events),
|
|
83
|
+
timeout=self._timeout,
|
|
84
|
+
),
|
|
82
85
|
)
|
|
83
86
|
|
|
84
87
|
def save_snapshot(self, snapshot: RawEvent) -> None:
|
|
File without changes
|
{python_event_sourcery-0.4.0a11 → python_event_sourcery-0.4.0a14}/event_sourcery_esdb/stream.py
RENAMED
|
@@ -56,5 +56,5 @@ def scope(
|
|
|
56
56
|
start = cast(
|
|
57
57
|
Position | None, start_version and Position.from_version(start_version)
|
|
58
58
|
)
|
|
59
|
-
stop = stop_version and Position.from_version(stop_version) or MAX_POSITION
|
|
59
|
+
stop = (stop_version and Position.from_version(stop_version)) or MAX_POSITION
|
|
60
60
|
return start, stop - (start or 0)
|
|
@@ -45,14 +45,14 @@ class StreamIdComparator(Comparator[StreamId]):
|
|
|
45
45
|
) -> None:
|
|
46
46
|
super().__init__(Composite(uuid, name, category))
|
|
47
47
|
|
|
48
|
-
def __eq__(self, other: Any) ->
|
|
48
|
+
def __eq__(self, other: Any) -> bool: # type: ignore
|
|
49
49
|
uuid, name, category = cast(Composite, self.__clause_element__()).attrs
|
|
50
50
|
same_uuid = cast(ColumnElement[bool], uuid == other)
|
|
51
51
|
same_name = cast(
|
|
52
52
|
ColumnElement[bool], name == other.name if other.name else true()
|
|
53
53
|
)
|
|
54
54
|
same_category = cast(ColumnElement[bool], category == (other.category or ""))
|
|
55
|
-
return and_(same_uuid, same_name, same_category)
|
|
55
|
+
return and_(same_uuid, same_name, same_category) # type: ignore
|
|
56
56
|
|
|
57
57
|
|
|
58
58
|
class Stream:
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
+
import dataclasses
|
|
1
2
|
import logging
|
|
2
3
|
from collections.abc import Generator, Iterator
|
|
3
4
|
from contextlib import AbstractContextManager, contextmanager
|
|
4
5
|
from dataclasses import dataclass
|
|
5
|
-
from datetime import datetime
|
|
6
|
+
from datetime import datetime, timezone
|
|
6
7
|
from typing import cast
|
|
7
8
|
from uuid import UUID
|
|
8
9
|
|
|
@@ -31,15 +32,17 @@ class SqlAlchemyOutboxStorageStrategy(OutboxStorageStrategy):
|
|
|
31
32
|
if not self._filterer(record.entry):
|
|
32
33
|
continue
|
|
33
34
|
|
|
34
|
-
|
|
35
|
+
stream_id = record.entry.stream_id
|
|
36
|
+
as_dict = dataclasses.asdict(record.entry)
|
|
37
|
+
as_dict.pop("stream_id")
|
|
35
38
|
created_at = cast(datetime, as_dict["created_at"])
|
|
36
39
|
as_dict["created_at"] = created_at.isoformat()
|
|
37
40
|
as_dict["uuid"] = str(as_dict["uuid"])
|
|
38
|
-
as_dict["stream_id"] = str(
|
|
41
|
+
as_dict["stream_id"] = str(stream_id)
|
|
39
42
|
as_dict["tenant_id"] = str(record.tenant_id)
|
|
40
43
|
rows.append(
|
|
41
44
|
{
|
|
42
|
-
"created_at": datetime.
|
|
45
|
+
"created_at": datetime.now(timezone.utc).replace(tzinfo=None),
|
|
43
46
|
"data": as_dict,
|
|
44
47
|
"stream_name": record.entry.stream_id.name,
|
|
45
48
|
"position": record.position,
|
|
File without changes
|
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
[project]
|
|
2
|
+
name = "python-event-sourcery"
|
|
2
3
|
requires-python = ">=3.10"
|
|
3
4
|
|
|
4
5
|
[tool.poetry]
|
|
5
|
-
|
|
6
|
-
version = "0.4.0a11"
|
|
6
|
+
version = "0.4.0a14"
|
|
7
7
|
description = ""
|
|
8
8
|
authors = ["Your Name <you@example.com>"]
|
|
9
9
|
license = "MIT"
|
|
10
10
|
packages = [
|
|
11
11
|
{ include = "event_sourcery" },
|
|
12
|
+
{ include = "event_sourcery_django" },
|
|
12
13
|
{ include = "event_sourcery_esdb" },
|
|
13
14
|
{ include = "event_sourcery_sqlalchemy" },
|
|
14
15
|
]
|
|
@@ -20,6 +21,7 @@ more-itertools = "*"
|
|
|
20
21
|
SQLAlchemy = {version = ">=2.0", optional = true}
|
|
21
22
|
esdbclient = {version = ">=1.0b3", optional = true}
|
|
22
23
|
django = {version = ">=4,<6", optional = true}
|
|
24
|
+
psycopg2-binary = ">2,<3"
|
|
23
25
|
|
|
24
26
|
[tool.poetry.group.dev.dependencies]
|
|
25
27
|
ruff = "*"
|
|
@@ -30,6 +32,9 @@ pytest-django = {version = "*"}
|
|
|
30
32
|
mkdocs-material = "^8.3.9"
|
|
31
33
|
psycopg2-binary = "^2.9.3"
|
|
32
34
|
mkdocstrings = {version = "*", extras = ["python"]}
|
|
35
|
+
pymdown-extensions = "*"
|
|
36
|
+
time-machine = "*"
|
|
37
|
+
pika = "*"
|
|
33
38
|
|
|
34
39
|
[tool.poetry.extras]
|
|
35
40
|
sqlalchemy = ["SQLAlchemy"]
|
|
@@ -116,6 +121,11 @@ ignore = [
|
|
|
116
121
|
"event_sourcery_django/models.py" = [
|
|
117
122
|
"RUF012", # Mutable class attributes should be annotated with typing.ClassVar
|
|
118
123
|
]
|
|
124
|
+
"docs/documentation/code/*" = [
|
|
125
|
+
"S101", # Use of assert detected
|
|
126
|
+
"N806", # Variables in function should be lowercase
|
|
127
|
+
"B018", # Useless expressions
|
|
128
|
+
]
|
|
119
129
|
|
|
120
130
|
[tool.ruff.lint.pylint]
|
|
121
131
|
max-args = 10
|
|
File without changes
|
{python_event_sourcery-0.4.0a11 → python_event_sourcery-0.4.0a14}/event_sourcery/__init__.py
RENAMED
|
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
|
{python_event_sourcery-0.4.0a11 → python_event_sourcery-0.4.0a14}/event_sourcery_esdb/__init__.py
RENAMED
|
File without changes
|
{python_event_sourcery-0.4.0a11 → python_event_sourcery-0.4.0a14}/event_sourcery_esdb/outbox.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_event_sourcery-0.4.0a11 → python_event_sourcery-0.4.0a14}/event_sourcery_sqlalchemy/dto.py
RENAMED
|
File without changes
|
|
File without changes
|
{python_event_sourcery-0.4.0a11 → python_event_sourcery-0.4.0a14}/event_sourcery_sqlalchemy/guid.py
RENAMED
|
File without changes
|
{python_event_sourcery-0.4.0a11 → python_event_sourcery-0.4.0a14}/event_sourcery_sqlalchemy/jsonb.py
RENAMED
|
File without changes
|
|
File without changes
|