neuroglia-python 0.7.4__tar.gz → 0.7.6__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.
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/PKG-INFO +1 -1
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/pyproject.toml +1 -1
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/__init__.py +1 -1
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/data/infrastructure/event_sourcing/__init__.py +6 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/data/infrastructure/event_sourcing/abstractions.py +15 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/data/infrastructure/event_sourcing/event_sourcing_repository.py +13 -3
- neuroglia_python-0.7.6/src/neuroglia/data/infrastructure/event_sourcing/read_model_reconciliator.py +328 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/data/infrastructure/mongo/mongo_repository.py +3 -3
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/data/infrastructure/mongo/motor_query.py +5 -4
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/data/infrastructure/mongo/motor_repository.py +98 -9
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/data/queryable.py +6 -1
- neuroglia_python-0.7.4/src/neuroglia/data/infrastructure/event_sourcing/read_model_reconciliator.py +0 -89
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/LICENSE +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/README.md +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/application/__init__.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/application/background_scheduler.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/coordination/__init__.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/coordination/abstractions.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/coordination/leader_election.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/coordination/redis_backend.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/core/__init__.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/core/module_loader.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/core/operation_result.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/core/problem_details.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/core/type_extensions.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/core/type_finder.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/core/type_registry.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/data/__init__.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/data/abstractions.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/data/exceptions.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/data/infrastructure/__init__.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/data/infrastructure/abstractions.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/data/infrastructure/event_sourcing/event_store/__init__.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/data/infrastructure/event_sourcing/event_store/event_store.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/data/infrastructure/filesystem/__init__.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/data/infrastructure/filesystem/filesystem_repository.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/data/infrastructure/memory/__init__.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/data/infrastructure/memory/memory_repository.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/data/infrastructure/mongo/__init__.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/data/infrastructure/mongo/enhanced_mongo_repository.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/data/infrastructure/mongo/serialization_helper.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/data/infrastructure/mongo/typed_mongo_query.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/data/infrastructure/resources/__init__.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/data/infrastructure/resources/in_memory_storage_backend.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/data/infrastructure/resources/postgres_resource_store.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/data/infrastructure/resources/redis_resource_store.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/data/infrastructure/resources/resource_repository.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/data/infrastructure/tracing_mixin.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/data/queries/generic/__init__.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/data/queries/generic/get_by_id_query.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/data/queries/generic/list_query.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/data/resources/__init__.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/data/resources/abstractions.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/data/resources/controller.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/data/resources/serializers/__init__.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/data/resources/serializers/xml_serializer.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/data/resources/serializers/yaml_serializer.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/data/resources/state_machine.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/data/resources/watcher.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/data/unit_of_work.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/dependency_injection/__init__.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/dependency_injection/service_provider.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/dependency_injection/service_provider_backup.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/eventing/__init__.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/eventing/cloud_events/__init__.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/eventing/cloud_events/cloud_event.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/eventing/cloud_events/decorators.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/eventing/cloud_events/infrastructure/__init__.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/eventing/cloud_events/infrastructure/cloud_event_bus.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/eventing/cloud_events/infrastructure/cloud_event_ingestor.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/eventing/cloud_events/infrastructure/cloud_event_middleware.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/eventing/cloud_events/infrastructure/cloud_event_publisher.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/eventing/tracing.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/expressions/__init__.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/expressions/javascript_expression_translator.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/extensions/cqrs_metrics_extensions.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/extensions/mediator_extensions.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/extensions/state_persistence_extensions.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/hosting/__init__.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/hosting/abstractions.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/hosting/configuration/data_access_layer.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/hosting/web.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/integration/__init__.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/integration/cache_repository.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/integration/http_service_client.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/integration/models.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/logging/__init__.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/logging/logger.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/mapping/__init__.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/mapping/mapper.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/mediation/__init__.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/mediation/behaviors/domain_event_cloudevent_behavior.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/mediation/behaviors/domain_event_dispatching_middleware.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/mediation/mediator.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/mediation/metrics_middleware.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/mediation/pipeline_behavior.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/mediation/simple.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/mediation/tracing_middleware.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/mvc/__init__.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/mvc/controller_base.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/observability/__init__.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/observability/framework.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/observability/logging.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/observability/metrics.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/observability/otel_sdk.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/observability/settings.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/observability/tracing.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/py.typed +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/reactive/__init__.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/reactive/rx_async.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/serialization/__init__.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/serialization/abstractions.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/serialization/aggregate_serializer.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/serialization/json.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/utils/__init__.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/utils/camel_model.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/utils/case_conversion.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/validation/__init__.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/validation/business_rules.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/validation/exceptions.py +0 -0
- {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/validation/validators.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[tool.poetry]
|
|
2
2
|
name = "neuroglia-python"
|
|
3
|
-
version = "0.7.
|
|
3
|
+
version = "0.7.6"
|
|
4
4
|
description = "Port from .NET to Python of the Neuroglia Framework"
|
|
5
5
|
authors = ["Charles d'Avernas <charles.davernas@neuroglia.io>", "Bruno van de Werve <bvandewe@gmail.com>"]
|
|
6
6
|
license = "Apache"
|
|
@@ -14,6 +14,10 @@ from .abstractions import (
|
|
|
14
14
|
StreamReadDirection,
|
|
15
15
|
)
|
|
16
16
|
from .event_sourcing_repository import EventSourcingRepository
|
|
17
|
+
from .read_model_reconciliator import (
|
|
18
|
+
ReadModelConciliationOptions,
|
|
19
|
+
ReadModelReconciliator,
|
|
20
|
+
)
|
|
17
21
|
|
|
18
22
|
__all__ = [
|
|
19
23
|
"EventStore",
|
|
@@ -24,4 +28,6 @@ __all__ = [
|
|
|
24
28
|
"StreamReadDirection",
|
|
25
29
|
"EventStoreOptions",
|
|
26
30
|
"Aggregator",
|
|
31
|
+
"ReadModelConciliationOptions",
|
|
32
|
+
"ReadModelReconciliator",
|
|
27
33
|
]
|
|
@@ -202,8 +202,23 @@ class EventStore(ABC):
|
|
|
202
202
|
|
|
203
203
|
class Aggregator:
|
|
204
204
|
def aggregate(self, events: list, aggregate_type: type):
|
|
205
|
+
"""
|
|
206
|
+
Reconstitutes an aggregate from a list of domain events.
|
|
207
|
+
|
|
208
|
+
Creates a new aggregate instance using __new__() to bypass __init__,
|
|
209
|
+
then manually initializes the state and _pending_events list before
|
|
210
|
+
replaying events to rebuild the aggregate's state.
|
|
211
|
+
|
|
212
|
+
Args:
|
|
213
|
+
events: List of event records to replay
|
|
214
|
+
aggregate_type: The aggregate root class to instantiate
|
|
215
|
+
|
|
216
|
+
Returns:
|
|
217
|
+
Reconstituted aggregate with replayed state
|
|
218
|
+
"""
|
|
205
219
|
aggregate: AggregateRoot = object.__new__(aggregate_type)
|
|
206
220
|
aggregate.state = aggregate.__orig_bases__[0].__args__[0]()
|
|
221
|
+
aggregate._pending_events = list() # Initialize _pending_events to prevent AttributeError
|
|
207
222
|
for e in events:
|
|
208
223
|
aggregate.state.on(e.data)
|
|
209
224
|
aggregate.state.state_version = e.data.aggregate_version
|
|
@@ -113,10 +113,20 @@ class EventSourcingRepository(Generic[TAggregate, TKey], Repository[TAggregate,
|
|
|
113
113
|
return self._eventstore.contains_stream(self._build_stream_id_for(id))
|
|
114
114
|
|
|
115
115
|
async def get_async(self, id: TKey) -> Optional[TAggregate]:
|
|
116
|
-
"""
|
|
116
|
+
"""
|
|
117
|
+
Gets the aggregate with the specified id, if any.
|
|
118
|
+
|
|
119
|
+
Returns None if the stream does not exist.
|
|
120
|
+
"""
|
|
117
121
|
stream_id = self._build_stream_id_for(id)
|
|
118
|
-
|
|
119
|
-
|
|
122
|
+
try:
|
|
123
|
+
events = await self._eventstore.read_async(stream_id, StreamReadDirection.FORWARDS, 0)
|
|
124
|
+
if not events:
|
|
125
|
+
return None
|
|
126
|
+
return self._aggregator.aggregate(events, self.__orig_class__.__args__[0])
|
|
127
|
+
except Exception:
|
|
128
|
+
# If stream doesn't exist or any other error occurs, return None
|
|
129
|
+
return None
|
|
120
130
|
|
|
121
131
|
async def _do_add_async(self, aggregate: TAggregate) -> TAggregate:
|
|
122
132
|
"""Adds and persists the specified aggregate"""
|
neuroglia_python-0.7.6/src/neuroglia/data/infrastructure/event_sourcing/read_model_reconciliator.py
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
from collections import defaultdict
|
|
4
|
+
from collections.abc import Callable, Coroutine
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any, Optional
|
|
7
|
+
|
|
8
|
+
from rx.core.typing import Disposable
|
|
9
|
+
|
|
10
|
+
from neuroglia.data.infrastructure.event_sourcing.abstractions import (
|
|
11
|
+
EventRecord,
|
|
12
|
+
EventStore,
|
|
13
|
+
EventStoreOptions,
|
|
14
|
+
)
|
|
15
|
+
from neuroglia.dependency_injection.service_provider import ServiceProviderBase
|
|
16
|
+
from neuroglia.hosting.abstractions import HostedService
|
|
17
|
+
from neuroglia.mediation.mediator import Mediator
|
|
18
|
+
from neuroglia.reactive import AsyncRx
|
|
19
|
+
|
|
20
|
+
log = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class ReadModelConciliationOptions:
|
|
25
|
+
"""Represents the options used to configure the application's read model reconciliation features.
|
|
26
|
+
|
|
27
|
+
Attributes:
|
|
28
|
+
consumer_group: The name of the group of consumers the application's read model is maintained by
|
|
29
|
+
sequential_processing: If True, events are processed sequentially per aggregate to maintain
|
|
30
|
+
causal ordering. Events from different aggregates can still be processed in parallel.
|
|
31
|
+
Default is True to prevent race conditions in projections.
|
|
32
|
+
|
|
33
|
+
Examples:
|
|
34
|
+
```python
|
|
35
|
+
# Default: Sequential processing per aggregate (recommended)
|
|
36
|
+
options = ReadModelConciliationOptions(
|
|
37
|
+
consumer_group="my-read-model"
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
# Parallel processing (for independent handlers only)
|
|
41
|
+
options = ReadModelConciliationOptions(
|
|
42
|
+
consumer_group="my-read-model",
|
|
43
|
+
sequential_processing=False
|
|
44
|
+
)
|
|
45
|
+
```
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
consumer_group: str
|
|
49
|
+
""" Gets the name of the group of consumers the application's read model is maintained by """
|
|
50
|
+
|
|
51
|
+
sequential_processing: bool = field(default=True)
|
|
52
|
+
"""
|
|
53
|
+
If True (default), events from the same aggregate are processed sequentially to maintain
|
|
54
|
+
causal ordering. This prevents race conditions where a projection handler for a subsequent
|
|
55
|
+
event runs before the handler for a preceding event has completed.
|
|
56
|
+
|
|
57
|
+
When False, events are processed concurrently which may improve throughput but can cause
|
|
58
|
+
race conditions if projection handlers have dependencies on prior events being processed.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class AggregateEventQueue:
|
|
63
|
+
"""
|
|
64
|
+
Manages sequential event processing per aggregate.
|
|
65
|
+
|
|
66
|
+
This class ensures that events from the same aggregate are processed in order,
|
|
67
|
+
while allowing events from different aggregates to be processed concurrently.
|
|
68
|
+
|
|
69
|
+
This solves the race condition where:
|
|
70
|
+
1. Aggregate emits EventA followed by EventB
|
|
71
|
+
2. EventA handler creates a document in MongoDB
|
|
72
|
+
3. EventB handler updates that document
|
|
73
|
+
4. Without ordering, EventB handler may run before EventA completes
|
|
74
|
+
|
|
75
|
+
Attributes:
|
|
76
|
+
_queues: Maps aggregate_id to its event queue
|
|
77
|
+
_processors: Maps aggregate_id to its processing task
|
|
78
|
+
_lock: Protects concurrent access to queues and processors
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
def __init__(self):
|
|
82
|
+
self._queues: dict[str, asyncio.Queue] = defaultdict(asyncio.Queue)
|
|
83
|
+
self._processors: dict[str, Optional[asyncio.Task]] = {}
|
|
84
|
+
self._lock = asyncio.Lock()
|
|
85
|
+
|
|
86
|
+
async def enqueue(self, aggregate_id: str, event_record: Any, process_func: Callable[[Any], Coroutine[Any, Any, None]]) -> None:
|
|
87
|
+
"""
|
|
88
|
+
Enqueue an event for sequential processing.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
aggregate_id: The aggregate identifier to group events by
|
|
92
|
+
event_record: The event record to process (must have 'data' attribute)
|
|
93
|
+
process_func: Async function to call for processing the event
|
|
94
|
+
"""
|
|
95
|
+
async with self._lock:
|
|
96
|
+
await self._queues[aggregate_id].put((event_record, process_func))
|
|
97
|
+
|
|
98
|
+
# Start processor for this aggregate if not already running
|
|
99
|
+
if aggregate_id not in self._processors or self._processors[aggregate_id] is None:
|
|
100
|
+
self._processors[aggregate_id] = asyncio.create_task(self._process_queue(aggregate_id))
|
|
101
|
+
|
|
102
|
+
async def _process_queue(self, aggregate_id: str) -> None:
|
|
103
|
+
"""
|
|
104
|
+
Process events from an aggregate's queue sequentially.
|
|
105
|
+
|
|
106
|
+
This ensures causal ordering: each event completes before the next begins.
|
|
107
|
+
"""
|
|
108
|
+
queue = self._queues[aggregate_id]
|
|
109
|
+
|
|
110
|
+
while True:
|
|
111
|
+
try:
|
|
112
|
+
# Wait for next event with timeout to allow cleanup
|
|
113
|
+
try:
|
|
114
|
+
event_record, process_func = await asyncio.wait_for(queue.get(), timeout=30.0)
|
|
115
|
+
except asyncio.TimeoutError:
|
|
116
|
+
# No events for this aggregate in 30 seconds, clean up
|
|
117
|
+
async with self._lock:
|
|
118
|
+
if queue.empty():
|
|
119
|
+
self._processors[aggregate_id] = None
|
|
120
|
+
return
|
|
121
|
+
continue
|
|
122
|
+
|
|
123
|
+
# Process the event sequentially (await completion)
|
|
124
|
+
try:
|
|
125
|
+
await process_func(event_record)
|
|
126
|
+
except Exception as ex:
|
|
127
|
+
log.error(f"Error processing event for aggregate {aggregate_id}: {ex}", exc_info=True)
|
|
128
|
+
|
|
129
|
+
queue.task_done()
|
|
130
|
+
|
|
131
|
+
except asyncio.CancelledError:
|
|
132
|
+
log.debug(f"Event processor for aggregate {aggregate_id} cancelled")
|
|
133
|
+
break
|
|
134
|
+
except Exception as ex:
|
|
135
|
+
log.error(f"Unexpected error in event processor: {ex}", exc_info=True)
|
|
136
|
+
|
|
137
|
+
async def shutdown(self) -> None:
|
|
138
|
+
"""Cancel all processing tasks during shutdown."""
|
|
139
|
+
async with self._lock:
|
|
140
|
+
for task in self._processors.values():
|
|
141
|
+
if task is not None:
|
|
142
|
+
task.cancel()
|
|
143
|
+
self._processors.clear()
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class ReadModelReconciliator(HostedService):
|
|
147
|
+
"""
|
|
148
|
+
Reconciles the read model by streaming and handling events from the event store.
|
|
149
|
+
|
|
150
|
+
This service subscribes to the event store's category stream and processes events
|
|
151
|
+
to update read model projections. It supports two processing modes:
|
|
152
|
+
|
|
153
|
+
**Sequential Processing (Default - Recommended)**:
|
|
154
|
+
Events from the same aggregate are processed sequentially to maintain causal
|
|
155
|
+
ordering. This prevents race conditions where projection handlers for subsequent
|
|
156
|
+
events run before handlers for preceding events complete.
|
|
157
|
+
|
|
158
|
+
Example scenario protected against:
|
|
159
|
+
1. ToolGroupCreatedEvent → Creates ToolGroupDto in MongoDB
|
|
160
|
+
2. SelectorAddedEvent → Updates the ToolGroupDto with new selector
|
|
161
|
+
Without sequential processing, step 2 might run before step 1 completes,
|
|
162
|
+
causing "document not found" errors.
|
|
163
|
+
|
|
164
|
+
**Parallel Processing**:
|
|
165
|
+
Events are processed concurrently for higher throughput. Only use this mode
|
|
166
|
+
if your projection handlers are independent and don't rely on prior events
|
|
167
|
+
being processed.
|
|
168
|
+
|
|
169
|
+
Attributes:
|
|
170
|
+
_service_provider: The current service provider for dependency resolution
|
|
171
|
+
_mediator: The mediator for publishing events to handlers
|
|
172
|
+
_event_store_options: Configuration options for the event store
|
|
173
|
+
_event_store: The event store service for reading events
|
|
174
|
+
_options: Configuration options for read model reconciliation
|
|
175
|
+
_event_queue: Queue for sequential event processing per aggregate
|
|
176
|
+
_subscription: The event store subscription handle
|
|
177
|
+
|
|
178
|
+
Examples:
|
|
179
|
+
```python
|
|
180
|
+
# Configure with sequential processing (default)
|
|
181
|
+
options = ReadModelConciliationOptions(
|
|
182
|
+
consumer_group="my-projections"
|
|
183
|
+
)
|
|
184
|
+
reconciliator = ReadModelReconciliator(
|
|
185
|
+
service_provider, mediator, event_store_options, event_store, options
|
|
186
|
+
)
|
|
187
|
+
await reconciliator.start_async()
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
See Also:
|
|
191
|
+
- Event Sourcing: https://bvandewe.github.io/pyneuro/patterns/event-sourcing/
|
|
192
|
+
- Read Model Projections: https://bvandewe.github.io/pyneuro/patterns/cqrs/
|
|
193
|
+
"""
|
|
194
|
+
|
|
195
|
+
_service_provider: ServiceProviderBase
|
|
196
|
+
""" Gets the current service provider """
|
|
197
|
+
|
|
198
|
+
_mediator: Mediator
|
|
199
|
+
|
|
200
|
+
_event_store_options: EventStoreOptions
|
|
201
|
+
""" Gets the options used to configure the event store """
|
|
202
|
+
|
|
203
|
+
_event_store: EventStore
|
|
204
|
+
""" Gets the service used to persist and stream domain events """
|
|
205
|
+
|
|
206
|
+
_options: ReadModelConciliationOptions
|
|
207
|
+
""" Gets the options used to configure read model reconciliation """
|
|
208
|
+
|
|
209
|
+
_event_queue: Optional[AggregateEventQueue]
|
|
210
|
+
""" Queue for sequential event processing per aggregate """
|
|
211
|
+
|
|
212
|
+
_subscription: Optional[Disposable]
|
|
213
|
+
|
|
214
|
+
def __init__(self, service_provider: ServiceProviderBase, mediator: Mediator, event_store_options: EventStoreOptions, event_store: EventStore, options: Optional[ReadModelConciliationOptions] = None):
|
|
215
|
+
self._service_provider = service_provider
|
|
216
|
+
self._mediator = mediator
|
|
217
|
+
self._event_store_options = event_store_options
|
|
218
|
+
self._event_store = event_store
|
|
219
|
+
self._options = options or ReadModelConciliationOptions(consumer_group=event_store_options.consumer_group if hasattr(event_store_options, "consumer_group") else "default")
|
|
220
|
+
self._event_queue = AggregateEventQueue() if self._options.sequential_processing else None
|
|
221
|
+
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
|
222
|
+
self._subscription = None
|
|
223
|
+
|
|
224
|
+
async def start_async(self):
|
|
225
|
+
"""Start the read model reconciliator and begin processing events."""
|
|
226
|
+
self._loop = asyncio.get_event_loop()
|
|
227
|
+
await self.subscribe_async()
|
|
228
|
+
log.info(f"ReadModelReconciliator started with " f"{'sequential' if self._options.sequential_processing else 'parallel'} processing")
|
|
229
|
+
|
|
230
|
+
async def stop_async(self):
|
|
231
|
+
"""Stop the read model reconciliator and clean up resources."""
|
|
232
|
+
if self._subscription is not None:
|
|
233
|
+
self._subscription.dispose()
|
|
234
|
+
if self._event_queue:
|
|
235
|
+
await self._event_queue.shutdown()
|
|
236
|
+
log.info("ReadModelReconciliator stopped")
|
|
237
|
+
|
|
238
|
+
async def subscribe_async(self):
|
|
239
|
+
"""Subscribe to the event store's category stream."""
|
|
240
|
+
observable = await self._event_store.observe_async(f"$ce-{self._event_store_options.database_name}", self._event_store_options.consumer_group)
|
|
241
|
+
|
|
242
|
+
def on_next(e: EventRecord):
|
|
243
|
+
"""Schedule the async handler on the main event loop without closing it."""
|
|
244
|
+
try:
|
|
245
|
+
if self._loop is None:
|
|
246
|
+
log.warning("Event loop not initialized, skipping event")
|
|
247
|
+
return
|
|
248
|
+
|
|
249
|
+
# Use call_soon_threadsafe to schedule the coroutine on the main loop
|
|
250
|
+
# This prevents creating/closing new event loops which breaks Motor
|
|
251
|
+
self._loop.call_soon_threadsafe(lambda: asyncio.create_task(self._handle_event_async(e)))
|
|
252
|
+
except RuntimeError as ex:
|
|
253
|
+
log.warning(f"Event loop closed, skipping event: " f"{type(e.data).__name__ if hasattr(e, 'data') else 'unknown'} - {ex}")
|
|
254
|
+
|
|
255
|
+
self._subscription = AsyncRx.subscribe(observable, on_next)
|
|
256
|
+
|
|
257
|
+
async def _handle_event_async(self, e: EventRecord) -> None:
|
|
258
|
+
"""
|
|
259
|
+
Handle an incoming event record.
|
|
260
|
+
|
|
261
|
+
Routes to either sequential or parallel processing based on configuration.
|
|
262
|
+
"""
|
|
263
|
+
if self._options.sequential_processing and self._event_queue:
|
|
264
|
+
# Extract aggregate ID from the event for grouping
|
|
265
|
+
aggregate_id = self._extract_aggregate_id(e)
|
|
266
|
+
await self._event_queue.enqueue(aggregate_id, e, self.on_event_record_stream_next_async)
|
|
267
|
+
else:
|
|
268
|
+
# Parallel processing (legacy behavior)
|
|
269
|
+
await self.on_event_record_stream_next_async(e)
|
|
270
|
+
|
|
271
|
+
def _extract_aggregate_id(self, e: EventRecord) -> str:
|
|
272
|
+
"""
|
|
273
|
+
Extract the aggregate ID from an event record for sequential processing grouping.
|
|
274
|
+
|
|
275
|
+
Tries multiple strategies to find the aggregate ID:
|
|
276
|
+
1. From event data's aggregate_id attribute
|
|
277
|
+
2. From event record's stream_id (format: aggregatetype-aggregateid)
|
|
278
|
+
3. Falls back to "unknown" if not found
|
|
279
|
+
"""
|
|
280
|
+
# Try to get from event data
|
|
281
|
+
if hasattr(e, "data") and e.data is not None and hasattr(e.data, "aggregate_id"):
|
|
282
|
+
agg_id = getattr(e.data, "aggregate_id", None)
|
|
283
|
+
if agg_id is not None:
|
|
284
|
+
return str(agg_id)
|
|
285
|
+
|
|
286
|
+
# Try to get from stream ID (format: aggregatetype-aggregateid)
|
|
287
|
+
if hasattr(e, "stream_id") and e.stream_id:
|
|
288
|
+
parts = str(e.stream_id).split("-", 1)
|
|
289
|
+
if len(parts) > 1:
|
|
290
|
+
return parts[1]
|
|
291
|
+
|
|
292
|
+
# Fallback - all events go to same queue (fully sequential)
|
|
293
|
+
log.debug(f"Could not extract aggregate_id from event {type(e.data).__name__}")
|
|
294
|
+
return "unknown"
|
|
295
|
+
|
|
296
|
+
async def on_event_record_stream_next_async(self, e: EventRecord):
|
|
297
|
+
"""
|
|
298
|
+
Process a single event record by publishing it through the mediator.
|
|
299
|
+
|
|
300
|
+
This method is called either directly (parallel mode) or from the
|
|
301
|
+
aggregate event queue (sequential mode).
|
|
302
|
+
|
|
303
|
+
Args:
|
|
304
|
+
e: The event record to process
|
|
305
|
+
"""
|
|
306
|
+
try:
|
|
307
|
+
# Publish the event through the mediator
|
|
308
|
+
await self._mediator.publish_async(e.data)
|
|
309
|
+
|
|
310
|
+
# Acknowledge successful processing
|
|
311
|
+
# Use duck typing to support both AckableEventRecord and mock objects
|
|
312
|
+
if hasattr(e, "ack_async") and callable(getattr(e, "ack_async", None)):
|
|
313
|
+
await e.ack_async()
|
|
314
|
+
|
|
315
|
+
log.debug(f"Successfully processed event: {type(e.data).__name__}")
|
|
316
|
+
|
|
317
|
+
except Exception as ex:
|
|
318
|
+
log.error(f"An exception occurred while publishing an event of type " f"'{type(e.data).__name__}': {ex}", exc_info=True)
|
|
319
|
+
|
|
320
|
+
# Negative acknowledge on processing failure
|
|
321
|
+
# Use duck typing to support both AckableEventRecord and mock objects
|
|
322
|
+
if hasattr(e, "nack_async") and callable(getattr(e, "nack_async", None)):
|
|
323
|
+
await e.nack_async()
|
|
324
|
+
|
|
325
|
+
async def on_event_record_stream_error(self, ex: Exception):
|
|
326
|
+
"""Handle errors from the event record stream by resubscribing."""
|
|
327
|
+
log.error(f"Event stream error, resubscribing: {ex}")
|
|
328
|
+
await self.subscribe_async()
|
|
@@ -38,8 +38,8 @@ class MongoRepositoryOptions(Generic[TEntity, TKey]):
|
|
|
38
38
|
class MongoQuery(Generic[T], Queryable[T]):
|
|
39
39
|
"""Represents a Mongo query"""
|
|
40
40
|
|
|
41
|
-
def __init__(self, query_provider: "MongoQueryProvider", expression: Optional[expr] = None):
|
|
42
|
-
super().__init__(query_provider, expression)
|
|
41
|
+
def __init__(self, query_provider: "MongoQueryProvider", expression: Optional[expr] = None, element_type: Optional[type] = None):
|
|
42
|
+
super().__init__(query_provider, expression, element_type)
|
|
43
43
|
|
|
44
44
|
|
|
45
45
|
class MongoQueryBuilder(NodeVisitor):
|
|
@@ -114,7 +114,7 @@ class MongoQueryProvider(QueryProvider):
|
|
|
114
114
|
_collection: Collection
|
|
115
115
|
|
|
116
116
|
def create_query(self, element_type: type, expression: expr) -> Queryable:
|
|
117
|
-
return MongoQuery
|
|
117
|
+
return MongoQuery(self, expression, element_type)
|
|
118
118
|
|
|
119
119
|
def execute(self, expression: expr, query_type: type) -> Any:
|
|
120
120
|
query = MongoQueryBuilder(self._collection, JavaScriptExpressionTranslator()).build(expression)
|
|
@@ -64,15 +64,16 @@ class MotorQuery(Generic[T], Queryable[T]):
|
|
|
64
64
|
```
|
|
65
65
|
"""
|
|
66
66
|
|
|
67
|
-
def __init__(self, query_provider: "MotorQueryProvider", expression: Optional[expr] = None):
|
|
67
|
+
def __init__(self, query_provider: "MotorQueryProvider", expression: Optional[expr] = None, element_type: Optional[type] = None):
|
|
68
68
|
"""
|
|
69
69
|
Initialize a Motor query.
|
|
70
70
|
|
|
71
71
|
Args:
|
|
72
72
|
query_provider: The Motor query provider
|
|
73
73
|
expression: Optional AST expression for the query
|
|
74
|
+
element_type: Optional element type (used for chaining to preserve type info)
|
|
74
75
|
"""
|
|
75
|
-
super().__init__(query_provider, expression)
|
|
76
|
+
super().__init__(query_provider, expression, element_type)
|
|
76
77
|
|
|
77
78
|
async def to_list_async(self) -> list[T]:
|
|
78
79
|
"""
|
|
@@ -319,9 +320,9 @@ class MotorQueryProvider(QueryProvider):
|
|
|
319
320
|
expression: AST expression for the query
|
|
320
321
|
|
|
321
322
|
Returns:
|
|
322
|
-
New MotorQuery instance
|
|
323
|
+
New MotorQuery instance with element_type propagated
|
|
323
324
|
"""
|
|
324
|
-
return MotorQuery(self, expression)
|
|
325
|
+
return MotorQuery(self, expression, element_type)
|
|
325
326
|
|
|
326
327
|
def execute(self, expression: expr, query_type: type) -> Any:
|
|
327
328
|
"""
|
|
@@ -578,31 +578,68 @@ class MotorRepository(Generic[TEntity, TKey], QueryableRepository[TEntity, TKey]
|
|
|
578
578
|
|
|
579
579
|
return entities
|
|
580
580
|
|
|
581
|
-
async def find_async(
|
|
581
|
+
async def find_async(
|
|
582
|
+
self,
|
|
583
|
+
filter_dict: dict,
|
|
584
|
+
sort: Optional[list[tuple[str, int]]] = None,
|
|
585
|
+
limit: Optional[int] = None,
|
|
586
|
+
skip: Optional[int] = None,
|
|
587
|
+
projection: Optional[dict] = None,
|
|
588
|
+
) -> list[TEntity]:
|
|
582
589
|
"""
|
|
583
|
-
Find entities matching a MongoDB filter query.
|
|
590
|
+
Find entities matching a MongoDB filter query with optional sorting, pagination, and projection.
|
|
584
591
|
|
|
585
|
-
This provides direct access to MongoDB's query language for complex queries
|
|
592
|
+
This provides direct access to MongoDB's query language for complex queries
|
|
593
|
+
with full support for cursor operations.
|
|
586
594
|
|
|
587
595
|
Args:
|
|
588
596
|
filter_dict: MongoDB query filter (e.g., {"state.email": "user@example.com"})
|
|
597
|
+
sort: List of (field, direction) tuples. Direction: 1 for ascending, -1 for descending.
|
|
598
|
+
Example: [("name", 1), ("created_at", -1)]
|
|
599
|
+
limit: Maximum number of documents to return
|
|
600
|
+
skip: Number of documents to skip (for pagination)
|
|
601
|
+
projection: Fields to include/exclude. Example: {"name": 1, "email": 1}
|
|
589
602
|
|
|
590
603
|
Returns:
|
|
591
604
|
List of entities matching the filter
|
|
592
605
|
|
|
593
|
-
|
|
606
|
+
Examples:
|
|
594
607
|
```python
|
|
595
608
|
# Find all active users
|
|
596
609
|
active_users = await repository.find_async({"state.is_active": True})
|
|
597
610
|
|
|
598
|
-
# Find users by email domain
|
|
599
|
-
gmail_users = await repository.find_async(
|
|
600
|
-
"state.email": {"$regex": "@gmail.com$"}
|
|
601
|
-
|
|
611
|
+
# Find users by email domain with sorting
|
|
612
|
+
gmail_users = await repository.find_async(
|
|
613
|
+
{"state.email": {"$regex": "@gmail.com$"}},
|
|
614
|
+
sort=[("state.name", 1)]
|
|
615
|
+
)
|
|
616
|
+
|
|
617
|
+
# Paginated query with sorting
|
|
618
|
+
page_2_users = await repository.find_async(
|
|
619
|
+
{"state.is_active": True},
|
|
620
|
+
sort=[("state.created_at", -1)],
|
|
621
|
+
skip=20,
|
|
622
|
+
limit=10
|
|
623
|
+
)
|
|
624
|
+
|
|
625
|
+
# Query with field projection (only return specific fields)
|
|
626
|
+
users = await repository.find_async(
|
|
627
|
+
{"state.is_active": True},
|
|
628
|
+
projection={"state.name": 1, "state.email": 1}
|
|
629
|
+
)
|
|
602
630
|
```
|
|
603
631
|
"""
|
|
632
|
+
cursor = self.collection.find(filter_dict, projection)
|
|
633
|
+
|
|
634
|
+
if sort:
|
|
635
|
+
cursor = cursor.sort(sort)
|
|
636
|
+
if skip:
|
|
637
|
+
cursor = cursor.skip(skip)
|
|
638
|
+
if limit:
|
|
639
|
+
cursor = cursor.limit(limit)
|
|
640
|
+
|
|
604
641
|
entities = []
|
|
605
|
-
async for doc in
|
|
642
|
+
async for doc in cursor:
|
|
606
643
|
entity = self._deserialize_entity(doc)
|
|
607
644
|
entities.append(entity)
|
|
608
645
|
|
|
@@ -631,6 +668,58 @@ class MotorRepository(Generic[TEntity, TKey], QueryableRepository[TEntity, TKey]
|
|
|
631
668
|
|
|
632
669
|
return self._deserialize_entity(doc)
|
|
633
670
|
|
|
671
|
+
async def count_async(self, filter_dict: Optional[dict] = None) -> int:
|
|
672
|
+
"""
|
|
673
|
+
Count documents matching a MongoDB filter query.
|
|
674
|
+
|
|
675
|
+
Args:
|
|
676
|
+
filter_dict: MongoDB query filter. If None or empty dict, counts all documents.
|
|
677
|
+
|
|
678
|
+
Returns:
|
|
679
|
+
Count of documents matching the filter
|
|
680
|
+
|
|
681
|
+
Examples:
|
|
682
|
+
```python
|
|
683
|
+
# Count all documents
|
|
684
|
+
total_count = await repository.count_async()
|
|
685
|
+
|
|
686
|
+
# Count with filter
|
|
687
|
+
active_count = await repository.count_async({"state.is_active": True})
|
|
688
|
+
|
|
689
|
+
# Useful for pagination
|
|
690
|
+
filter_query = {"state.status": "pending"}
|
|
691
|
+
total_pages = (await repository.count_async(filter_query) + page_size - 1) // page_size
|
|
692
|
+
```
|
|
693
|
+
"""
|
|
694
|
+
filter_dict = filter_dict or {}
|
|
695
|
+
return await self.collection.count_documents(filter_dict)
|
|
696
|
+
|
|
697
|
+
async def exists_async(self, filter_dict: dict) -> bool:
|
|
698
|
+
"""
|
|
699
|
+
Check if any document matches the MongoDB filter query.
|
|
700
|
+
|
|
701
|
+
This is more efficient than count_async for existence checks as it
|
|
702
|
+
stops after finding the first match.
|
|
703
|
+
|
|
704
|
+
Args:
|
|
705
|
+
filter_dict: MongoDB query filter
|
|
706
|
+
|
|
707
|
+
Returns:
|
|
708
|
+
True if at least one document matches, False otherwise
|
|
709
|
+
|
|
710
|
+
Examples:
|
|
711
|
+
```python
|
|
712
|
+
# Check if email already exists
|
|
713
|
+
email_exists = await repository.exists_async({"state.email": "user@example.com"})
|
|
714
|
+
if email_exists:
|
|
715
|
+
raise ValueError("Email already registered")
|
|
716
|
+
|
|
717
|
+
# Check if any active users exist
|
|
718
|
+
has_active = await repository.exists_async({"state.is_active": True})
|
|
719
|
+
```
|
|
720
|
+
"""
|
|
721
|
+
return await self.collection.count_documents(filter_dict, limit=1) > 0
|
|
722
|
+
|
|
634
723
|
@staticmethod
|
|
635
724
|
def configure(
|
|
636
725
|
builder: ApplicationBuilderBase,
|
|
@@ -79,9 +79,10 @@ class Queryable(Generic[T]):
|
|
|
79
79
|
- Repository Pattern Guide: https://bvandewe.github.io/pyneuro/patterns/
|
|
80
80
|
"""
|
|
81
81
|
|
|
82
|
-
def __init__(self, provider: QueryProvider, expression: Optional[expr] = None):
|
|
82
|
+
def __init__(self, provider: QueryProvider, expression: Optional[expr] = None, element_type: Optional[type] = None):
|
|
83
83
|
self.provider = provider
|
|
84
84
|
self.expression = ast.Name(id="__query") if expression is None else expression
|
|
85
|
+
self._element_type = element_type
|
|
85
86
|
|
|
86
87
|
expression: expr
|
|
87
88
|
""" Gets the expression that is associated with the queryable """
|
|
@@ -91,6 +92,10 @@ class Queryable(Generic[T]):
|
|
|
91
92
|
|
|
92
93
|
def get_element_type(self) -> type:
|
|
93
94
|
"""Gets the type of elements to query against"""
|
|
95
|
+
# Try explicit element type first (set during chaining)
|
|
96
|
+
if hasattr(self, "_element_type") and self._element_type is not None:
|
|
97
|
+
return self._element_type
|
|
98
|
+
# Fall back to __orig_class__ for initial query creation
|
|
94
99
|
return self.__orig_class__.__args__[0]
|
|
95
100
|
|
|
96
101
|
def first_or_default(self, predicate: Callable[[T], bool] = None) -> T:
|
neuroglia_python-0.7.4/src/neuroglia/data/infrastructure/event_sourcing/read_model_reconciliator.py
DELETED
|
@@ -1,89 +0,0 @@
|
|
|
1
|
-
import asyncio
|
|
2
|
-
import logging
|
|
3
|
-
from dataclasses import dataclass
|
|
4
|
-
|
|
5
|
-
from rx.core.typing import Disposable
|
|
6
|
-
|
|
7
|
-
from neuroglia.data.infrastructure.event_sourcing.abstractions import (
|
|
8
|
-
AckableEventRecord,
|
|
9
|
-
EventRecord,
|
|
10
|
-
EventStore,
|
|
11
|
-
EventStoreOptions,
|
|
12
|
-
)
|
|
13
|
-
from neuroglia.dependency_injection.service_provider import ServiceProviderBase
|
|
14
|
-
from neuroglia.hosting.abstractions import HostedService
|
|
15
|
-
from neuroglia.mediation.mediator import Mediator
|
|
16
|
-
from neuroglia.reactive import AsyncRx
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
@dataclass
|
|
20
|
-
class ReadModelConciliationOptions:
|
|
21
|
-
"""Represents the options used to configure the application's read model reconciliation features"""
|
|
22
|
-
|
|
23
|
-
consumer_group: str
|
|
24
|
-
""" Gets the name of the group of consumers the application's read model is maintained by """
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
class ReadModelReconciliator(HostedService):
|
|
28
|
-
"""Represents the service used to reconciliate the read model by streaming and handling events recorded on the application's event store"""
|
|
29
|
-
|
|
30
|
-
_service_provider: ServiceProviderBase
|
|
31
|
-
""" Gets the current service provider """
|
|
32
|
-
|
|
33
|
-
_mediator: Mediator
|
|
34
|
-
|
|
35
|
-
_event_store_options: EventStoreOptions
|
|
36
|
-
""" Gets the options used to configure the event store """
|
|
37
|
-
|
|
38
|
-
_event_store: EventStore
|
|
39
|
-
""" Gets the service used to persist and stream domain events """
|
|
40
|
-
|
|
41
|
-
_subscription: Disposable
|
|
42
|
-
|
|
43
|
-
def __init__(self, service_provider: ServiceProviderBase, mediator: Mediator, event_store_options: EventStoreOptions, event_store: EventStore):
|
|
44
|
-
self._service_provider = service_provider
|
|
45
|
-
self._mediator = mediator
|
|
46
|
-
self._event_store_options = event_store_options
|
|
47
|
-
self._event_store = event_store
|
|
48
|
-
|
|
49
|
-
async def start_async(self):
|
|
50
|
-
await self.subscribe_async()
|
|
51
|
-
|
|
52
|
-
async def stop_async(self):
|
|
53
|
-
self._subscription.dispose()
|
|
54
|
-
|
|
55
|
-
async def subscribe_async(self):
|
|
56
|
-
observable = await self._event_store.observe_async(f"$ce-{self._event_store_options.database_name}", self._event_store_options.consumer_group)
|
|
57
|
-
|
|
58
|
-
# Get the current event loop to schedule tasks on
|
|
59
|
-
loop = asyncio.get_event_loop()
|
|
60
|
-
|
|
61
|
-
def on_next(e):
|
|
62
|
-
"""Schedule the async handler on the main event loop without closing it."""
|
|
63
|
-
try:
|
|
64
|
-
# Use call_soon_threadsafe to schedule the coroutine on the main loop
|
|
65
|
-
# This prevents creating/closing new event loops which breaks Motor
|
|
66
|
-
loop.call_soon_threadsafe(lambda: asyncio.create_task(self.on_event_record_stream_next_async(e)))
|
|
67
|
-
except RuntimeError as ex:
|
|
68
|
-
logging.warning(f"Event loop closed, skipping event: {type(e.data).__name__ if hasattr(e, 'data') else 'unknown'} - {ex}")
|
|
69
|
-
|
|
70
|
-
self._subscription = AsyncRx.subscribe(observable, on_next)
|
|
71
|
-
|
|
72
|
-
async def on_event_record_stream_next_async(self, e: EventRecord):
|
|
73
|
-
try:
|
|
74
|
-
# todo: migrate event
|
|
75
|
-
await self._mediator.publish_async(e.data)
|
|
76
|
-
|
|
77
|
-
# Acknowledge successful processing
|
|
78
|
-
if isinstance(e, AckableEventRecord):
|
|
79
|
-
await e.ack_async()
|
|
80
|
-
|
|
81
|
-
except Exception as ex:
|
|
82
|
-
logging.error(f"An exception occured while publishing an event of type '{type(e.data).__name__}': {ex}")
|
|
83
|
-
|
|
84
|
-
# Negative acknowledge on processing failure
|
|
85
|
-
if isinstance(e, AckableEventRecord):
|
|
86
|
-
await e.nack_async()
|
|
87
|
-
|
|
88
|
-
async def on_event_record_stream_error(self, ex: Exception):
|
|
89
|
-
await self.subscribe_async()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/application/background_scheduler.py
RENAMED
|
File without changes
|
|
File without changes
|
{neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/coordination/abstractions.py
RENAMED
|
File without changes
|
{neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/coordination/leader_election.py
RENAMED
|
File without changes
|
{neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/coordination/redis_backend.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
|
{neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/data/infrastructure/__init__.py
RENAMED
|
File without changes
|
{neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/data/infrastructure/abstractions.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
|
{neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/data/infrastructure/tracing_mixin.py
RENAMED
|
File without changes
|
{neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/data/queries/generic/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/data/queries/generic/list_query.py
RENAMED
|
File without changes
|
|
File without changes
|
{neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/data/resources/abstractions.py
RENAMED
|
File without changes
|
{neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/data/resources/controller.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/data/resources/state_machine.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/dependency_injection/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/eventing/cloud_events/__init__.py
RENAMED
|
File without changes
|
{neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/eventing/cloud_events/cloud_event.py
RENAMED
|
File without changes
|
{neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/eventing/cloud_events/decorators.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
|
{neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/extensions/mediator_extensions.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/integration/cache_repository.py
RENAMED
|
File without changes
|
{neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/integration/http_service_client.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
|
{neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/mediation/metrics_middleware.py
RENAMED
|
File without changes
|
{neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/mediation/pipeline_behavior.py
RENAMED
|
File without changes
|
|
File without changes
|
{neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/mediation/tracing_middleware.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
|
{neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/serialization/abstractions.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/validation/business_rules.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|