neuroglia-python 0.6.15__tar.gz → 0.6.16__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 (119) hide show
  1. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/PKG-INFO +1 -1
  2. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/pyproject.toml +1 -1
  3. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/__init__.py +1 -1
  4. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/data/infrastructure/event_sourcing/abstractions.py +2 -2
  5. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/data/infrastructure/event_sourcing/event_store/event_store.py +144 -39
  6. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/LICENSE +0 -0
  7. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/README.md +0 -0
  8. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/application/__init__.py +0 -0
  9. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/application/background_scheduler.py +0 -0
  10. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/coordination/__init__.py +0 -0
  11. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/coordination/abstractions.py +0 -0
  12. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/coordination/leader_election.py +0 -0
  13. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/coordination/redis_backend.py +0 -0
  14. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/core/__init__.py +0 -0
  15. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/core/module_loader.py +0 -0
  16. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/core/operation_result.py +0 -0
  17. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/core/problem_details.py +0 -0
  18. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/core/type_extensions.py +0 -0
  19. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/core/type_finder.py +0 -0
  20. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/core/type_registry.py +0 -0
  21. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/data/__init__.py +0 -0
  22. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/data/abstractions.py +0 -0
  23. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/data/exceptions.py +0 -0
  24. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/data/infrastructure/__init__.py +0 -0
  25. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/data/infrastructure/abstractions.py +0 -0
  26. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/data/infrastructure/event_sourcing/__init__.py +0 -0
  27. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/data/infrastructure/event_sourcing/event_sourcing_repository.py +0 -0
  28. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/data/infrastructure/event_sourcing/event_store/__init__.py +0 -0
  29. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/data/infrastructure/event_sourcing/read_model_reconciliator.py +0 -0
  30. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/data/infrastructure/filesystem/__init__.py +0 -0
  31. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/data/infrastructure/filesystem/filesystem_repository.py +0 -0
  32. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/data/infrastructure/memory/__init__.py +0 -0
  33. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/data/infrastructure/memory/memory_repository.py +0 -0
  34. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/data/infrastructure/mongo/__init__.py +0 -0
  35. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/data/infrastructure/mongo/enhanced_mongo_repository.py +0 -0
  36. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/data/infrastructure/mongo/mongo_repository.py +0 -0
  37. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/data/infrastructure/mongo/motor_repository.py +0 -0
  38. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/data/infrastructure/mongo/serialization_helper.py +0 -0
  39. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/data/infrastructure/mongo/typed_mongo_query.py +0 -0
  40. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/data/infrastructure/resources/__init__.py +0 -0
  41. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/data/infrastructure/resources/in_memory_storage_backend.py +0 -0
  42. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/data/infrastructure/resources/postgres_resource_store.py +0 -0
  43. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/data/infrastructure/resources/redis_resource_store.py +0 -0
  44. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/data/infrastructure/resources/resource_repository.py +0 -0
  45. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/data/infrastructure/tracing_mixin.py +0 -0
  46. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/data/queries/generic/__init__.py +0 -0
  47. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/data/queries/generic/get_by_id_query.py +0 -0
  48. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/data/queries/generic/list_query.py +0 -0
  49. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/data/queryable.py +0 -0
  50. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/data/resources/__init__.py +0 -0
  51. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/data/resources/abstractions.py +0 -0
  52. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/data/resources/controller.py +0 -0
  53. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/data/resources/serializers/__init__.py +0 -0
  54. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/data/resources/serializers/xml_serializer.py +0 -0
  55. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/data/resources/serializers/yaml_serializer.py +0 -0
  56. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/data/resources/state_machine.py +0 -0
  57. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/data/resources/watcher.py +0 -0
  58. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/data/unit_of_work.py +0 -0
  59. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/dependency_injection/__init__.py +0 -0
  60. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/dependency_injection/service_provider.py +0 -0
  61. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/dependency_injection/service_provider_backup.py +0 -0
  62. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/eventing/__init__.py +0 -0
  63. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/eventing/cloud_events/__init__.py +0 -0
  64. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/eventing/cloud_events/cloud_event.py +0 -0
  65. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/eventing/cloud_events/decorators.py +0 -0
  66. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/eventing/cloud_events/infrastructure/__init__.py +0 -0
  67. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/eventing/cloud_events/infrastructure/cloud_event_bus.py +0 -0
  68. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/eventing/cloud_events/infrastructure/cloud_event_ingestor.py +0 -0
  69. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/eventing/cloud_events/infrastructure/cloud_event_middleware.py +0 -0
  70. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/eventing/cloud_events/infrastructure/cloud_event_publisher.py +0 -0
  71. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/eventing/tracing.py +0 -0
  72. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/expressions/__init__.py +0 -0
  73. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/expressions/javascript_expression_translator.py +0 -0
  74. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/extensions/cqrs_metrics_extensions.py +0 -0
  75. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/extensions/mediator_extensions.py +0 -0
  76. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/extensions/state_persistence_extensions.py +0 -0
  77. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/hosting/__init__.py +0 -0
  78. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/hosting/abstractions.py +0 -0
  79. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/hosting/configuration/data_access_layer.py +0 -0
  80. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/hosting/web.py +0 -0
  81. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/integration/__init__.py +0 -0
  82. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/integration/cache_repository.py +0 -0
  83. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/integration/http_service_client.py +0 -0
  84. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/integration/models.py +0 -0
  85. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/logging/__init__.py +0 -0
  86. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/logging/logger.py +0 -0
  87. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/mapping/__init__.py +0 -0
  88. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/mapping/mapper.py +0 -0
  89. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/mediation/__init__.py +0 -0
  90. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/mediation/behaviors/domain_event_cloudevent_behavior.py +0 -0
  91. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/mediation/behaviors/domain_event_dispatching_middleware.py +0 -0
  92. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/mediation/mediator.py +0 -0
  93. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/mediation/metrics_middleware.py +0 -0
  94. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/mediation/pipeline_behavior.py +0 -0
  95. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/mediation/simple.py +0 -0
  96. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/mediation/tracing_middleware.py +0 -0
  97. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/mvc/__init__.py +0 -0
  98. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/mvc/controller_base.py +0 -0
  99. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/observability/__init__.py +0 -0
  100. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/observability/framework.py +0 -0
  101. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/observability/logging.py +0 -0
  102. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/observability/metrics.py +0 -0
  103. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/observability/otel_sdk.py +0 -0
  104. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/observability/settings.py +0 -0
  105. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/observability/tracing.py +0 -0
  106. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/py.typed +0 -0
  107. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/reactive/__init__.py +0 -0
  108. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/reactive/rx_async.py +0 -0
  109. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/serialization/__init__.py +0 -0
  110. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/serialization/abstractions.py +0 -0
  111. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/serialization/aggregate_serializer.py +0 -0
  112. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/serialization/json.py +0 -0
  113. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/utils/__init__.py +0 -0
  114. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/utils/camel_model.py +0 -0
  115. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/utils/case_conversion.py +0 -0
  116. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/validation/__init__.py +0 -0
  117. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/validation/business_rules.py +0 -0
  118. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/src/neuroglia/validation/exceptions.py +0 -0
  119. {neuroglia_python-0.6.15 → neuroglia_python-0.6.16}/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.6.15
3
+ Version: 0.6.16
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.6.15"
3
+ version = "0.6.16"
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.6.15"
65
+ __version__ = "0.6.16"
66
66
  __author__ = "Neuroglia Team"
67
67
  __email__ = "team@neuroglia.io"
68
68
  __license__ = "Apache"
@@ -109,11 +109,11 @@ class AckableEventRecord(EventRecord):
109
109
 
110
110
  async def ack_async(self) -> None:
111
111
  """Acks the event record"""
112
- self._ack_delegate()
112
+ await self._ack_delegate()
113
113
 
114
114
  async def nack_async(self) -> None:
115
115
  """Nacks the event record"""
116
- self._nack_delegate()
116
+ await self._nack_delegate()
117
117
 
118
118
 
119
119
  class StreamReadDirection(Enum):
@@ -1,11 +1,12 @@
1
+ import asyncio
1
2
  import inspect
2
3
  import logging
3
4
  import sys
4
- import threading
5
5
  from typing import Any, Dict, Optional
6
6
 
7
7
  import rx
8
- from esdbclient import EventStoreDBClient, NewEvent, RecordedEvent, StreamState
8
+ from esdbclient import AsyncioEventStoreDBClient as AsyncClientFactory
9
+ from esdbclient import NewEvent, RecordedEvent, StreamState
9
10
  from esdbclient.exceptions import AlreadyExists
10
11
  from rx.core.observable.observable import Observable
11
12
  from rx.disposable.disposable import Disposable
@@ -35,7 +36,10 @@ class ESEventStore(EventStore):
35
36
  _eventstore_options: EventStoreOptions
36
37
  """ Gets the options used to configure the EventStore """
37
38
 
38
- _eventstore_client: EventStoreDBClient
39
+ _connection_string: str
40
+ """ Gets the connection string for EventStoreDB """
41
+
42
+ _eventstore_client: Optional[Any] # _AsyncioEventStoreDBClient
39
43
  """ Gets the service used to interact with the EventStore DB"""
40
44
 
41
45
  _serializer: JsonSerializer
@@ -44,17 +48,34 @@ class ESEventStore(EventStore):
44
48
  def __init__(
45
49
  self,
46
50
  options: EventStoreOptions,
47
- eventstore_client: EventStoreDBClient,
51
+ connection_string_or_client: str | Any, # Can be connection string or pre-initialized client (for testing)
48
52
  serializer: JsonSerializer,
49
53
  ):
50
54
  self._eventstore_options = options
51
- self._eventstore_client = eventstore_client
52
55
  self._serializer = serializer
53
56
 
57
+ # Check if we got a connection string or an already-initialized client
58
+ if isinstance(connection_string_or_client, str):
59
+ self._connection_string = connection_string_or_client
60
+ self._eventstore_client = None # Will be lazily initialized
61
+ else:
62
+ # Pre-initialized client (typically for testing)
63
+ self._connection_string = None
64
+ self._eventstore_client = connection_string_or_client
65
+
66
+ async def _ensure_client(self) -> Any:
67
+ """Lazily initialize the async EventStoreDB client on first use"""
68
+ if self._eventstore_client is None:
69
+ if self._connection_string is None:
70
+ raise RuntimeError("Neither connection string nor client provided")
71
+ self._eventstore_client = await AsyncClientFactory(uri=self._connection_string)
72
+ return self._eventstore_client
73
+
54
74
  async def contains_async(self, stream_id: str) -> bool:
55
75
  return await self.get_async(stream_id) is not None
56
76
 
57
77
  async def append_async(self, stream_id: str, events: list[EventDescriptor], expected_version: Optional[int] = None):
78
+ client = await self._ensure_client()
58
79
  if expected_version is not None:
59
80
  expected_version = expected_version - 1
60
81
  stream_name = self._get_stream_name(stream_id)
@@ -70,31 +91,34 @@ class ESEventStore(EventStore):
70
91
  metadata=bytes(self._serializer.serialize(self._build_event_metadata(e.data, e.metadata))),
71
92
  )
72
93
  )
73
- self._eventstore_client.append_to_stream(stream_name=stream_name, current_version=stream_state, events=formatted_events)
94
+ await client.append_to_stream(stream_name=stream_name, current_version=stream_state, events=formatted_events)
74
95
 
75
96
  async def get_async(self, stream_id: str) -> Optional[StreamDescriptor]:
97
+ client = await self._ensure_client()
76
98
  stream_name = self._get_stream_name(stream_id)
77
- metadata, metadata_version = self._eventstore_client.get_stream_metadata(stream_name)
99
+ metadata, metadata_version = await client.get_stream_metadata(stream_name)
78
100
  if metadata_version == StreamState.NO_STREAM:
79
101
  return None
80
102
  truncate_before = metadata.get("$tb")
81
103
  offset = 0 if truncate_before is None else truncate_before
82
- read_response = self._eventstore_client.read_stream(
104
+ read_response = client.read_stream(
83
105
  stream_name=stream_name,
84
106
  stream_position=offset,
85
107
  backwards=False,
86
108
  resolve_links=True,
87
109
  limit=1,
88
110
  )
89
- recorded_events = tuple(read_response)
90
- read_response = self._eventstore_client.read_stream(
111
+ recorded_events = [event async for event in read_response]
112
+ read_response = client.read_stream(
91
113
  stream_name=stream_name,
92
114
  stream_position=offset,
93
115
  backwards=True,
94
116
  resolve_links=True,
95
117
  limit=1,
96
118
  )
97
- recorded_events = tuple(read_response)
119
+ recorded_events = [event async for event in read_response]
120
+ if not recorded_events:
121
+ return None
98
122
  last_event = recorded_events[0]
99
123
  return StreamDescriptor(stream_id, last_event.stream_position, None, None) # todo: esdbclient does not provide timestamps
100
124
 
@@ -105,15 +129,16 @@ class ESEventStore(EventStore):
105
129
  offset: int,
106
130
  length: Optional[int] = None,
107
131
  ) -> list[EventRecord]:
132
+ client = await self._ensure_client()
108
133
  stream_name = self._get_stream_name(stream_id)
109
- read_response = self._eventstore_client.read_stream(
134
+ read_response = client.read_stream(
110
135
  stream_name=stream_name,
111
136
  stream_position=offset,
112
137
  backwards=True if read_direction == StreamReadDirection.BACKWARDS else False,
113
138
  resolve_links=True,
114
139
  limit=sys.maxsize if length is None else length,
115
140
  )
116
- recorded_events = tuple(read_response)
141
+ recorded_events = [event async for event in read_response]
117
142
  return [self._decode_recorded_event(stream_id, recorded_event) for recorded_event in recorded_events]
118
143
 
119
144
  async def observe_async(
@@ -122,36 +147,42 @@ class ESEventStore(EventStore):
122
147
  consumer_group: Optional[str] = None,
123
148
  offset: Optional[int] = None,
124
149
  ) -> Observable:
150
+ client = await self._ensure_client()
125
151
  if stream_id is None:
126
152
  raise ValueError("stream_id cannot be None")
127
153
  stream_name = self._get_stream_name(stream_id)
128
154
  subscription = None
129
155
  if consumer_group is None:
130
156
  stream_position = offset if offset is not None else 0
131
- subscription = self._eventstore_client.subscribe_to_stream(stream_name=stream_name, resolve_links=True, stream_position=stream_position)
157
+ subscription = client.subscribe_to_stream(stream_name=stream_name, resolve_links=True, stream_position=stream_position)
132
158
  else:
133
159
  try:
134
- self._eventstore_client.create_subscription_to_stream(
160
+ await client.create_subscription_to_stream(
135
161
  group_name=consumer_group,
136
162
  stream_name=stream_name,
137
163
  resolve_links=True,
138
164
  consumer_strategy="RoundRobin",
165
+ # Checkpoint configuration for reliable ACK delivery
139
166
  min_checkpoint_count=1,
140
167
  max_checkpoint_count=1,
168
+ # Set message timeout to 60s (default is 30s)
169
+ message_timeout=60.0,
141
170
  )
142
171
  except AlreadyExists:
143
172
  pass
144
- subscription = self._eventstore_client.read_subscription_to_stream(
173
+ subscription = client.read_subscription_to_stream(
145
174
  group_name=consumer_group,
146
175
  stream_name=stream_name,
147
176
  )
148
177
  subject = Subject()
149
- thread = threading.Thread(
150
- target=self._consume_events_async,
151
- kwargs={"stream_id": stream_id, "subject": subject, "subscription": subscription},
152
- )
153
- thread.start()
154
- return rx.using(lambda: Disposable(lambda: subscription.stop() if subscription is not None else None), lambda s: subject)
178
+ # Start async task to consume events
179
+ asyncio.create_task(self._consume_events_async(stream_id, subject, subscription))
180
+
181
+ async def stop_subscription():
182
+ if subscription is not None:
183
+ await subscription.stop()
184
+
185
+ return rx.using(lambda: Disposable(lambda: asyncio.create_task(stop_subscription())), lambda s: subject)
155
186
 
156
187
  def _build_event_metadata(self, e: DomainEvent, additional_metadata: Optional[Any]) -> dict[str, Any]:
157
188
  module = inspect.getmodule(e)
@@ -200,33 +231,96 @@ class ESEventStore(EventStore):
200
231
  """Converts the specified stream id to a qualified stream id, which is prefixed with the current database name, if any"""
201
232
  return stream_id if self._eventstore_options.database_name is None or stream_id.startswith("$ce-") else f"{self._eventstore_options.database_name}-{stream_id}"
202
233
 
203
- def _consume_events_async(self, stream_id: str, subject: Subject, subscription):
204
- """Asynchronously enumerate events returned by a subscription"""
234
+ async def _consume_events_async(self, stream_id: str, subject: Subject, subscription):
235
+ """
236
+ Asynchronously enumerate events returned by a subscription using native async iteration.
237
+
238
+ With AsyncioEventStoreDBClient, subscriptions are async generators that properly handle
239
+ gRPC bidirectional streaming. ACKs are sent immediately without queuing issues.
240
+
241
+ For persistent subscriptions, events are wrapped in AckableEventRecord with async
242
+ ack/nack delegates that directly call the subscription's async methods.
243
+ """
244
+ # Check if this is a persistent subscription (has ack/nack methods)
245
+ is_persistent = hasattr(subscription, "ack") and hasattr(subscription, "nack")
246
+
205
247
  try:
206
248
  e: RecordedEvent
207
- for e in subscription:
249
+ async for e in subscription:
250
+ # Skip tombstone events (streams prefixed with $$)
251
+ # Tombstones are created by EventStoreDB when streams are hard-deleted
252
+ if e.stream_name.startswith("$$"):
253
+ logging.debug(f"Skipping tombstone event from stream: {e.stream_name}")
254
+ # Acknowledge tombstone to continue processing
255
+ if is_persistent:
256
+ await subscription.ack(e.id)
257
+ continue
258
+
259
+ # Skip system event types (prefixed with $)
260
+ # System events are internal EventStoreDB metadata events
261
+ if e.type.startswith("$"):
262
+ logging.debug(f"Skipping system event type '{e.type}' from stream: {e.stream_name}")
263
+ # Acknowledge system event to continue processing
264
+ if is_persistent:
265
+ await subscription.ack(e.id)
266
+ continue
267
+
208
268
  try:
209
269
  decoded_event = self._decode_recorded_event(stream_id, e)
210
270
  except Exception as ex:
211
- logging.error(f"An exception occurred while decoding event with offset '{e.stream_position}' from stream '{e.stream_name}': {ex}")
212
- # Negative acknowledge (park) on decoding failure
213
- if hasattr(subscription, "nack"):
214
- subscription.nack(e.id, action="park")
215
- raise
271
+ logging.warning(f"Could not decode event with offset '{e.stream_position}' from stream '{e.stream_name}': {ex}")
272
+ # Acknowledge failed decode to continue processing (don't park/retry invalid events)
273
+ if is_persistent:
274
+ await subscription.ack(e.id)
275
+ continue
216
276
 
217
277
  # Convert to AckableEventRecord if subscription supports ack/nack
218
- if hasattr(subscription, "ack") and hasattr(subscription, "nack"):
278
+ if is_persistent:
219
279
  event_id = e.id
220
- ackable_event = AckableEventRecord(stream_id=decoded_event.stream_id, id=decoded_event.id, offset=decoded_event.offset, position=decoded_event.position, timestamp=decoded_event.timestamp, type=decoded_event.type, data=decoded_event.data, metadata=decoded_event.metadata, replayed=decoded_event.replayed, _ack_delegate=lambda eid=event_id: subscription.ack(eid), _nack_delegate=lambda eid=event_id, action="retry": subscription.nack(eid, action=action))
280
+
281
+ async def ack_delegate(eid=event_id, sub=subscription):
282
+ """
283
+ Acknowledge event to EventStoreDB persistent subscription.
284
+
285
+ With async API, ACKs are sent immediately through the gRPC stream.
286
+ """
287
+ await sub.ack(eid)
288
+ logging.debug(f"ACK sent for event: {eid}")
289
+
290
+ async def nack_delegate(eid=event_id, sub=subscription, action="retry"):
291
+ """
292
+ Negative acknowledge event with specified action.
293
+
294
+ Actions:
295
+ - "retry": Retry event delivery (default)
296
+ - "park": Move event to parked messages
297
+ - "skip": Skip event without retry
298
+ """
299
+ await sub.nack(eid, action=action)
300
+ logging.debug(f"NACK sent for event: {eid} with action: {action}")
301
+
302
+ ackable_event = AckableEventRecord(
303
+ stream_id=decoded_event.stream_id,
304
+ id=decoded_event.id,
305
+ offset=decoded_event.offset,
306
+ position=decoded_event.position,
307
+ timestamp=decoded_event.timestamp,
308
+ type=decoded_event.type,
309
+ data=decoded_event.data,
310
+ metadata=decoded_event.metadata,
311
+ replayed=decoded_event.replayed,
312
+ _ack_delegate=ack_delegate,
313
+ _nack_delegate=nack_delegate,
314
+ )
221
315
  subject.on_next(ackable_event)
222
316
  else:
223
- # No ack/nack support, send regular EventRecord
317
+ # No ack/nack support (catchup subscription), send regular EventRecord
224
318
  subject.on_next(decoded_event)
225
319
 
226
320
  subject.on_completed()
227
321
  except Exception as ex:
228
- logging.error(f"An exception occurred while consuming events from stream '{stream_id}', consequently to which the related subscription will be stopped: {ex}") # todo: improve feedback
229
- subscription.stop()
322
+ logging.error(f"An exception occurred while consuming events from stream '{stream_id}': {ex}")
323
+ await subscription.stop()
230
324
 
231
325
  async def delete_async(self, stream_id: str) -> None:
232
326
  """
@@ -241,10 +335,11 @@ class ESEventStore(EventStore):
241
335
  Raises:
242
336
  Exception: If the stream does not exist or deletion fails
243
337
  """
338
+ client = await self._ensure_client()
244
339
  stream_name = self._get_stream_name(stream_id)
245
340
  try:
246
341
  # Delete the stream from EventStoreDB
247
- self._eventstore_client.delete_stream(stream_name=stream_name, current_version=StreamState.ANY)
342
+ await client.delete_stream(stream_name=stream_name, current_version=StreamState.ANY)
248
343
  except Exception as ex:
249
344
  raise Exception(f"Failed to delete stream '{stream_name}': {ex}") from ex
250
345
 
@@ -253,14 +348,24 @@ class ESEventStore(EventStore):
253
348
  """Registers and configures an EventStore implementation of the EventStore class.
254
349
 
255
350
  Args:
256
- services (ServiceCollection): the service collection to configure
351
+ builder: The application builder to configure
352
+ options: EventStore configuration options
257
353
  """
258
354
  connection_string_name = "eventstore"
259
355
  connection_string = builder.settings.connection_strings.get(connection_string_name, None)
260
356
  if connection_string is None:
261
357
  raise Exception(f"Missing '{connection_string_name}' connection string")
358
+
359
+ # Register dependencies
262
360
  builder.services.try_add_singleton(Aggregator)
263
361
  builder.services.try_add_singleton(EventStoreOptions, singleton=options)
264
- builder.services.try_add_singleton(EventStoreDBClient, singleton=EventStoreDBClient(uri=connection_string))
265
- builder.services.try_add_singleton(EventStore, ESEventStore)
362
+
363
+ # Factory function to create ESEventStore with connection string
364
+ def create_event_store(service_provider) -> ESEventStore:
365
+ from neuroglia.serialization.json import JsonSerializer
366
+
367
+ serializer = service_provider.get_service(JsonSerializer)
368
+ return ESEventStore(options, connection_string, serializer)
369
+
370
+ builder.services.try_add_singleton(EventStore, factory=create_event_store)
266
371
  return builder