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.
Files changed (121) hide show
  1. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/PKG-INFO +1 -1
  2. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/pyproject.toml +1 -1
  3. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/__init__.py +1 -1
  4. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/data/infrastructure/event_sourcing/__init__.py +6 -0
  5. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/data/infrastructure/event_sourcing/abstractions.py +15 -0
  6. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/data/infrastructure/event_sourcing/event_sourcing_repository.py +13 -3
  7. neuroglia_python-0.7.6/src/neuroglia/data/infrastructure/event_sourcing/read_model_reconciliator.py +328 -0
  8. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/data/infrastructure/mongo/mongo_repository.py +3 -3
  9. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/data/infrastructure/mongo/motor_query.py +5 -4
  10. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/data/infrastructure/mongo/motor_repository.py +98 -9
  11. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/data/queryable.py +6 -1
  12. neuroglia_python-0.7.4/src/neuroglia/data/infrastructure/event_sourcing/read_model_reconciliator.py +0 -89
  13. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/LICENSE +0 -0
  14. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/README.md +0 -0
  15. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/application/__init__.py +0 -0
  16. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/application/background_scheduler.py +0 -0
  17. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/coordination/__init__.py +0 -0
  18. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/coordination/abstractions.py +0 -0
  19. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/coordination/leader_election.py +0 -0
  20. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/coordination/redis_backend.py +0 -0
  21. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/core/__init__.py +0 -0
  22. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/core/module_loader.py +0 -0
  23. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/core/operation_result.py +0 -0
  24. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/core/problem_details.py +0 -0
  25. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/core/type_extensions.py +0 -0
  26. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/core/type_finder.py +0 -0
  27. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/core/type_registry.py +0 -0
  28. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/data/__init__.py +0 -0
  29. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/data/abstractions.py +0 -0
  30. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/data/exceptions.py +0 -0
  31. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/data/infrastructure/__init__.py +0 -0
  32. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/data/infrastructure/abstractions.py +0 -0
  33. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/data/infrastructure/event_sourcing/event_store/__init__.py +0 -0
  34. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/data/infrastructure/event_sourcing/event_store/event_store.py +0 -0
  35. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/data/infrastructure/filesystem/__init__.py +0 -0
  36. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/data/infrastructure/filesystem/filesystem_repository.py +0 -0
  37. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/data/infrastructure/memory/__init__.py +0 -0
  38. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/data/infrastructure/memory/memory_repository.py +0 -0
  39. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/data/infrastructure/mongo/__init__.py +0 -0
  40. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/data/infrastructure/mongo/enhanced_mongo_repository.py +0 -0
  41. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/data/infrastructure/mongo/serialization_helper.py +0 -0
  42. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/data/infrastructure/mongo/typed_mongo_query.py +0 -0
  43. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/data/infrastructure/resources/__init__.py +0 -0
  44. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/data/infrastructure/resources/in_memory_storage_backend.py +0 -0
  45. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/data/infrastructure/resources/postgres_resource_store.py +0 -0
  46. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/data/infrastructure/resources/redis_resource_store.py +0 -0
  47. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/data/infrastructure/resources/resource_repository.py +0 -0
  48. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/data/infrastructure/tracing_mixin.py +0 -0
  49. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/data/queries/generic/__init__.py +0 -0
  50. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/data/queries/generic/get_by_id_query.py +0 -0
  51. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/data/queries/generic/list_query.py +0 -0
  52. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/data/resources/__init__.py +0 -0
  53. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/data/resources/abstractions.py +0 -0
  54. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/data/resources/controller.py +0 -0
  55. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/data/resources/serializers/__init__.py +0 -0
  56. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/data/resources/serializers/xml_serializer.py +0 -0
  57. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/data/resources/serializers/yaml_serializer.py +0 -0
  58. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/data/resources/state_machine.py +0 -0
  59. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/data/resources/watcher.py +0 -0
  60. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/data/unit_of_work.py +0 -0
  61. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/dependency_injection/__init__.py +0 -0
  62. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/dependency_injection/service_provider.py +0 -0
  63. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/dependency_injection/service_provider_backup.py +0 -0
  64. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/eventing/__init__.py +0 -0
  65. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/eventing/cloud_events/__init__.py +0 -0
  66. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/eventing/cloud_events/cloud_event.py +0 -0
  67. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/eventing/cloud_events/decorators.py +0 -0
  68. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/eventing/cloud_events/infrastructure/__init__.py +0 -0
  69. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/eventing/cloud_events/infrastructure/cloud_event_bus.py +0 -0
  70. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/eventing/cloud_events/infrastructure/cloud_event_ingestor.py +0 -0
  71. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/eventing/cloud_events/infrastructure/cloud_event_middleware.py +0 -0
  72. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/eventing/cloud_events/infrastructure/cloud_event_publisher.py +0 -0
  73. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/eventing/tracing.py +0 -0
  74. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/expressions/__init__.py +0 -0
  75. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/expressions/javascript_expression_translator.py +0 -0
  76. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/extensions/cqrs_metrics_extensions.py +0 -0
  77. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/extensions/mediator_extensions.py +0 -0
  78. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/extensions/state_persistence_extensions.py +0 -0
  79. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/hosting/__init__.py +0 -0
  80. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/hosting/abstractions.py +0 -0
  81. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/hosting/configuration/data_access_layer.py +0 -0
  82. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/hosting/web.py +0 -0
  83. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/integration/__init__.py +0 -0
  84. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/integration/cache_repository.py +0 -0
  85. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/integration/http_service_client.py +0 -0
  86. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/integration/models.py +0 -0
  87. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/logging/__init__.py +0 -0
  88. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/logging/logger.py +0 -0
  89. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/mapping/__init__.py +0 -0
  90. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/mapping/mapper.py +0 -0
  91. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/mediation/__init__.py +0 -0
  92. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/mediation/behaviors/domain_event_cloudevent_behavior.py +0 -0
  93. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/mediation/behaviors/domain_event_dispatching_middleware.py +0 -0
  94. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/mediation/mediator.py +0 -0
  95. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/mediation/metrics_middleware.py +0 -0
  96. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/mediation/pipeline_behavior.py +0 -0
  97. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/mediation/simple.py +0 -0
  98. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/mediation/tracing_middleware.py +0 -0
  99. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/mvc/__init__.py +0 -0
  100. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/mvc/controller_base.py +0 -0
  101. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/observability/__init__.py +0 -0
  102. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/observability/framework.py +0 -0
  103. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/observability/logging.py +0 -0
  104. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/observability/metrics.py +0 -0
  105. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/observability/otel_sdk.py +0 -0
  106. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/observability/settings.py +0 -0
  107. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/observability/tracing.py +0 -0
  108. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/py.typed +0 -0
  109. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/reactive/__init__.py +0 -0
  110. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/reactive/rx_async.py +0 -0
  111. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/serialization/__init__.py +0 -0
  112. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/serialization/abstractions.py +0 -0
  113. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/serialization/aggregate_serializer.py +0 -0
  114. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/serialization/json.py +0 -0
  115. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/utils/__init__.py +0 -0
  116. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/utils/camel_model.py +0 -0
  117. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/utils/case_conversion.py +0 -0
  118. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/validation/__init__.py +0 -0
  119. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/validation/business_rules.py +0 -0
  120. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/validation/exceptions.py +0 -0
  121. {neuroglia_python-0.7.4 → neuroglia_python-0.7.6}/src/neuroglia/validation/validators.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: neuroglia-python
3
- Version: 0.7.4
3
+ Version: 0.7.6
4
4
  Summary: Port from .NET to Python of the Neuroglia Framework
5
5
  License: Apache
6
6
  License-File: LICENSE
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "neuroglia-python"
3
- version = "0.7.4"
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"
@@ -62,7 +62,7 @@ __all__ = [
62
62
  ]
63
63
 
64
64
  # Framework metadata
65
- __version__ = "0.7.4"
65
+ __version__ = "0.7.6"
66
66
  __author__ = "Neuroglia Team"
67
67
  __email__ = "team@neuroglia.io"
68
68
  __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
- """Gets the aggregate with the specified id, if any"""
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
- events = await self._eventstore.read_async(stream_id, StreamReadDirection.FORWARDS, 0)
119
- return self._aggregator.aggregate(events, self.__orig_class__.__args__[0])
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"""
@@ -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[element_type](self, expression)
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(self, filter_dict: dict) -> list[TEntity]:
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
- Example:
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 self.collection.find(filter_dict):
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:
@@ -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()