neuroglia-python 0.7.5__tar.gz → 0.7.7__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.5 → neuroglia_python-0.7.7}/PKG-INFO +1 -1
  2. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/pyproject.toml +1 -1
  3. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/__init__.py +1 -1
  4. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/data/infrastructure/event_sourcing/__init__.py +6 -0
  5. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/data/infrastructure/event_sourcing/abstractions.py +15 -0
  6. neuroglia_python-0.7.7/src/neuroglia/data/infrastructure/event_sourcing/read_model_reconciliator.py +328 -0
  7. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/serialization/json.py +41 -3
  8. neuroglia_python-0.7.5/src/neuroglia/data/infrastructure/event_sourcing/read_model_reconciliator.py +0 -89
  9. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/LICENSE +0 -0
  10. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/README.md +0 -0
  11. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/application/__init__.py +0 -0
  12. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/application/background_scheduler.py +0 -0
  13. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/coordination/__init__.py +0 -0
  14. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/coordination/abstractions.py +0 -0
  15. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/coordination/leader_election.py +0 -0
  16. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/coordination/redis_backend.py +0 -0
  17. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/core/__init__.py +0 -0
  18. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/core/module_loader.py +0 -0
  19. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/core/operation_result.py +0 -0
  20. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/core/problem_details.py +0 -0
  21. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/core/type_extensions.py +0 -0
  22. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/core/type_finder.py +0 -0
  23. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/core/type_registry.py +0 -0
  24. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/data/__init__.py +0 -0
  25. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/data/abstractions.py +0 -0
  26. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/data/exceptions.py +0 -0
  27. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/data/infrastructure/__init__.py +0 -0
  28. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/data/infrastructure/abstractions.py +0 -0
  29. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/data/infrastructure/event_sourcing/event_sourcing_repository.py +0 -0
  30. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/data/infrastructure/event_sourcing/event_store/__init__.py +0 -0
  31. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/data/infrastructure/event_sourcing/event_store/event_store.py +0 -0
  32. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/data/infrastructure/filesystem/__init__.py +0 -0
  33. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/data/infrastructure/filesystem/filesystem_repository.py +0 -0
  34. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/data/infrastructure/memory/__init__.py +0 -0
  35. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/data/infrastructure/memory/memory_repository.py +0 -0
  36. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/data/infrastructure/mongo/__init__.py +0 -0
  37. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/data/infrastructure/mongo/enhanced_mongo_repository.py +0 -0
  38. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/data/infrastructure/mongo/mongo_repository.py +0 -0
  39. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/data/infrastructure/mongo/motor_query.py +0 -0
  40. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/data/infrastructure/mongo/motor_repository.py +0 -0
  41. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/data/infrastructure/mongo/serialization_helper.py +0 -0
  42. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/data/infrastructure/mongo/typed_mongo_query.py +0 -0
  43. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/data/infrastructure/resources/__init__.py +0 -0
  44. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/data/infrastructure/resources/in_memory_storage_backend.py +0 -0
  45. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/data/infrastructure/resources/postgres_resource_store.py +0 -0
  46. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/data/infrastructure/resources/redis_resource_store.py +0 -0
  47. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/data/infrastructure/resources/resource_repository.py +0 -0
  48. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/data/infrastructure/tracing_mixin.py +0 -0
  49. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/data/queries/generic/__init__.py +0 -0
  50. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/data/queries/generic/get_by_id_query.py +0 -0
  51. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/data/queries/generic/list_query.py +0 -0
  52. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/data/queryable.py +0 -0
  53. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/data/resources/__init__.py +0 -0
  54. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/data/resources/abstractions.py +0 -0
  55. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/data/resources/controller.py +0 -0
  56. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/data/resources/serializers/__init__.py +0 -0
  57. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/data/resources/serializers/xml_serializer.py +0 -0
  58. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/data/resources/serializers/yaml_serializer.py +0 -0
  59. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/data/resources/state_machine.py +0 -0
  60. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/data/resources/watcher.py +0 -0
  61. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/data/unit_of_work.py +0 -0
  62. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/dependency_injection/__init__.py +0 -0
  63. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/dependency_injection/service_provider.py +0 -0
  64. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/dependency_injection/service_provider_backup.py +0 -0
  65. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/eventing/__init__.py +0 -0
  66. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/eventing/cloud_events/__init__.py +0 -0
  67. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/eventing/cloud_events/cloud_event.py +0 -0
  68. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/eventing/cloud_events/decorators.py +0 -0
  69. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/eventing/cloud_events/infrastructure/__init__.py +0 -0
  70. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/eventing/cloud_events/infrastructure/cloud_event_bus.py +0 -0
  71. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/eventing/cloud_events/infrastructure/cloud_event_ingestor.py +0 -0
  72. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/eventing/cloud_events/infrastructure/cloud_event_middleware.py +0 -0
  73. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/eventing/cloud_events/infrastructure/cloud_event_publisher.py +0 -0
  74. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/eventing/tracing.py +0 -0
  75. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/expressions/__init__.py +0 -0
  76. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/expressions/javascript_expression_translator.py +0 -0
  77. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/extensions/cqrs_metrics_extensions.py +0 -0
  78. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/extensions/mediator_extensions.py +0 -0
  79. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/extensions/state_persistence_extensions.py +0 -0
  80. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/hosting/__init__.py +0 -0
  81. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/hosting/abstractions.py +0 -0
  82. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/hosting/configuration/data_access_layer.py +0 -0
  83. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/hosting/web.py +0 -0
  84. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/integration/__init__.py +0 -0
  85. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/integration/cache_repository.py +0 -0
  86. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/integration/http_service_client.py +0 -0
  87. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/integration/models.py +0 -0
  88. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/logging/__init__.py +0 -0
  89. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/logging/logger.py +0 -0
  90. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/mapping/__init__.py +0 -0
  91. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/mapping/mapper.py +0 -0
  92. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/mediation/__init__.py +0 -0
  93. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/mediation/behaviors/domain_event_cloudevent_behavior.py +0 -0
  94. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/mediation/behaviors/domain_event_dispatching_middleware.py +0 -0
  95. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/mediation/mediator.py +0 -0
  96. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/mediation/metrics_middleware.py +0 -0
  97. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/mediation/pipeline_behavior.py +0 -0
  98. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/mediation/simple.py +0 -0
  99. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/mediation/tracing_middleware.py +0 -0
  100. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/mvc/__init__.py +0 -0
  101. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/mvc/controller_base.py +0 -0
  102. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/observability/__init__.py +0 -0
  103. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/observability/framework.py +0 -0
  104. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/observability/logging.py +0 -0
  105. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/observability/metrics.py +0 -0
  106. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/observability/otel_sdk.py +0 -0
  107. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/observability/settings.py +0 -0
  108. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/observability/tracing.py +0 -0
  109. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/py.typed +0 -0
  110. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/reactive/__init__.py +0 -0
  111. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/reactive/rx_async.py +0 -0
  112. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/serialization/__init__.py +0 -0
  113. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/serialization/abstractions.py +0 -0
  114. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/serialization/aggregate_serializer.py +0 -0
  115. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/utils/__init__.py +0 -0
  116. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/utils/camel_model.py +0 -0
  117. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/utils/case_conversion.py +0 -0
  118. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/validation/__init__.py +0 -0
  119. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/validation/business_rules.py +0 -0
  120. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/src/neuroglia/validation/exceptions.py +0 -0
  121. {neuroglia_python-0.7.5 → neuroglia_python-0.7.7}/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.5
3
+ Version: 0.7.7
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.5"
3
+ version = "0.7.7"
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.5"
65
+ __version__ = "0.7.7"
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
@@ -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()
@@ -502,12 +502,14 @@ class JsonSerializer(TextSerializer):
502
502
  return datetime.fromisoformat(value.replace("Z", "+00:00"))
503
503
 
504
504
  # Try to detect decimal/money fields by name patterns
505
- if any(pattern in field_name.lower() for pattern in ["price", "cost", "amount", "total", "fee"]):
505
+ # Only match when the field name ENDS with the pattern to avoid false positives
506
+ # from nested paths like "input_schema_properties_price_type"
507
+ if self._is_monetary_field(field_name) and self._looks_like_decimal(value):
506
508
  try:
507
- from decimal import Decimal
509
+ from decimal import Decimal, InvalidOperation
508
510
 
509
511
  return Decimal(value)
510
- except (ValueError, TypeError):
512
+ except (ValueError, TypeError, InvalidOperation):
511
513
  pass
512
514
 
513
515
  # Try to find matching enum types in the target class
@@ -524,6 +526,32 @@ class JsonSerializer(TextSerializer):
524
526
 
525
527
  return value
526
528
 
529
+ # Monetary field patterns for decimal detection
530
+ _MONETARY_PATTERNS: tuple[str, ...] = ("price", "cost", "amount", "total", "fee", "balance", "rate", "tax")
531
+
532
+ def _is_monetary_field(self, field_name: str) -> bool:
533
+ """
534
+ Check if field name represents a monetary/decimal field.
535
+ Only matches when the field name ENDS with a monetary pattern to avoid
536
+ false positives from nested paths like 'input_schema_properties_price_type'.
537
+ """
538
+ field_name_lower = field_name.lower()
539
+ # Split by underscore and check if the last part is a monetary pattern
540
+ parts = field_name_lower.split("_")
541
+ last_part = parts[-1] if parts else ""
542
+ return last_part in self._MONETARY_PATTERNS
543
+
544
+ def _looks_like_decimal(self, value: str) -> bool:
545
+ """
546
+ Check if a string value looks like a valid decimal number.
547
+ This prevents attempting Decimal conversion on arbitrary strings.
548
+ """
549
+ import re
550
+
551
+ # Match optional negative sign, digits, optional decimal point with more digits
552
+ # Also match scientific notation like "1.5e-10"
553
+ return bool(re.match(r"^-?\d+\.?\d*(?:[eE][+-]?\d+)?$", value.strip()))
554
+
527
555
  def _is_datetime_string(self, value: str) -> bool:
528
556
  """Check if a string looks like an ISO datetime."""
529
557
  try:
@@ -585,6 +613,16 @@ class JsonSerializer(TextSerializer):
585
613
  if isinstance(expected_type, str):
586
614
  return value
587
615
 
616
+ # Handle typing.Any - return value as-is for primitives, recursively process containers
617
+ if expected_type is Any:
618
+ if isinstance(value, dict):
619
+ return {k: self._deserialize_nested(v, Any) for k, v in value.items()}
620
+ elif isinstance(value, list):
621
+ return [self._deserialize_nested(item, Any) for item in value]
622
+ else:
623
+ # For primitives (str, int, float, bool, etc.), return as-is
624
+ return value
625
+
588
626
  origin_type = get_origin(expected_type)
589
627
  if origin_type is not None:
590
628
  # This is a generic type (e.g., Optional[SomeType], List[SomeType])
@@ -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()