hexcore 2.3.0__tar.gz → 2.4.0__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.
- {hexcore-2.3.0/hexcore.egg-info → hexcore-2.4.0}/PKG-INFO +31 -24
- {hexcore-2.3.0 → hexcore-2.4.0}/README.md +28 -23
- hexcore-2.4.0/hexcore/infrastructure/cqrs/postgres_bus.py +126 -0
- hexcore-2.4.0/hexcore/infrastructure/cqrs/redis_bus.py +143 -0
- hexcore-2.4.0/hexcore/infrastructure/task_queues/__init__.py +3 -0
- hexcore-2.4.0/hexcore/infrastructure/task_queues/celery_adapter.py +83 -0
- hexcore-2.4.0/hexcore/infrastructure/task_queues/procrastinate_adapter.py +61 -0
- {hexcore-2.3.0 → hexcore-2.4.0/hexcore.egg-info}/PKG-INFO +31 -24
- {hexcore-2.3.0 → hexcore-2.4.0}/hexcore.egg-info/SOURCES.txt +8 -0
- {hexcore-2.3.0 → hexcore-2.4.0}/hexcore.egg-info/requires.txt +3 -0
- {hexcore-2.3.0 → hexcore-2.4.0}/pyproject.toml +2 -1
- hexcore-2.4.0/tests/test_postgres_bus.py +73 -0
- hexcore-2.4.0/tests/test_redis_bus.py +75 -0
- hexcore-2.4.0/tests/test_task_queues_adapters.py +82 -0
- {hexcore-2.3.0 → hexcore-2.4.0}/LICENSE +0 -0
- {hexcore-2.3.0 → hexcore-2.4.0}/MANIFEST.in +0 -0
- {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/__init__.py +0 -0
- {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/__main__.py +0 -0
- {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/application/__init__.py +0 -0
- {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/application/cqrs/__init__.py +0 -0
- {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/application/cqrs/adapters.py +0 -0
- {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/application/cqrs/config.py +0 -0
- {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/application/cqrs/factory.py +0 -0
- {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/application/cqrs/in_memory_buses.py +0 -0
- {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/application/cqrs/pipeline.py +0 -0
- {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/application/cqrs/registry.py +0 -0
- {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/application/dtos/__init__.py +0 -0
- {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/application/dtos/base.py +0 -0
- {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/application/dtos/query.py +0 -0
- {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/application/use_cases/__init__.py +0 -0
- {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/application/use_cases/base.py +0 -0
- {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/application/use_cases/query.py +0 -0
- {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/config.py +0 -0
- {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/domain/__init__.py +0 -0
- {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/domain/auth/__init__.py +0 -0
- {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/domain/auth/permissions.py +0 -0
- {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/domain/auth/value_objects.py +0 -0
- {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/domain/base.py +0 -0
- {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/domain/cqrs/__init__.py +0 -0
- {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/domain/cqrs/buses.py +0 -0
- {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/domain/cqrs/commands.py +0 -0
- {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/domain/cqrs/decorators.py +0 -0
- {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/domain/cqrs/exceptions.py +0 -0
- {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/domain/cqrs/handlers.py +0 -0
- {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/domain/cqrs/middleware.py +0 -0
- {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/domain/cqrs/queries.py +0 -0
- {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/domain/cqrs/serializer.py +0 -0
- {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/domain/cqrs/task_queues.py +0 -0
- {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/domain/events.py +0 -0
- {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/domain/exceptions.py +0 -0
- {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/domain/repositories.py +0 -0
- {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/domain/services.py +0 -0
- {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/domain/uow.py +0 -0
- {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/infrastructure/__init__.py +0 -0
- {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/infrastructure/api/__init__.py +0 -0
- {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/infrastructure/api/utils.py +0 -0
- {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/infrastructure/cache/__init__.py +0 -0
- {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/infrastructure/cache/cache_backends/__init__.py +0 -0
- {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/infrastructure/cache/cache_backends/memory.py +0 -0
- {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/infrastructure/cache/cache_backends/redis.py +0 -0
- {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/infrastructure/cli.py +0 -0
- {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/infrastructure/cqrs/__init__.py +0 -0
- {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/infrastructure/cqrs/middlewares.py +0 -0
- {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/infrastructure/cqrs/procrastinate.py +0 -0
- {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/infrastructure/cqrs/pydantic_serializer.py +0 -0
- {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/infrastructure/cqrs/rabbitmq.py +0 -0
- {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/infrastructure/events/__init__.py +0 -0
- {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/infrastructure/events/events_backends/__init__.py +0 -0
- {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/infrastructure/events/events_backends/memory.py +0 -0
- {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/infrastructure/repositories/__init__.py +0 -0
- {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/infrastructure/repositories/base.py +0 -0
- {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/infrastructure/repositories/decorators.py +0 -0
- {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/infrastructure/repositories/implementations.py +0 -0
- {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/infrastructure/repositories/orms/__init__.py +0 -0
- {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/infrastructure/repositories/orms/beanie/__init__.py +0 -0
- {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/infrastructure/repositories/orms/beanie/utils.py +0 -0
- {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/infrastructure/repositories/orms/sqlalchemy/__init__.py +0 -0
- {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/infrastructure/repositories/orms/sqlalchemy/session.py +0 -0
- {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/infrastructure/repositories/orms/sqlalchemy/utils.py +0 -0
- {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/infrastructure/repositories/utils.py +0 -0
- {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/infrastructure/uow/__init__.py +0 -0
- {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/infrastructure/uow/decorators.py +0 -0
- {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/infrastructure/uow/helpers.py +0 -0
- {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/infrastructure/workers/__init__.py +0 -0
- {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/infrastructure/workers/consumer.py +0 -0
- {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/infrastructure/workers/rabbitmq_worker.py +0 -0
- {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/py.typed +0 -0
- {hexcore-2.3.0 → hexcore-2.4.0}/hexcore/types.py +0 -0
- {hexcore-2.3.0 → hexcore-2.4.0}/hexcore.egg-info/dependency_links.txt +0 -0
- {hexcore-2.3.0 → hexcore-2.4.0}/hexcore.egg-info/entry_points.txt +0 -0
- {hexcore-2.3.0 → hexcore-2.4.0}/hexcore.egg-info/top_level.txt +0 -0
- {hexcore-2.3.0 → hexcore-2.4.0}/scripts/__init__.py +0 -0
- {hexcore-2.3.0 → hexcore-2.4.0}/scripts/main.py +0 -0
- {hexcore-2.3.0 → hexcore-2.4.0}/setup.cfg +0 -0
- {hexcore-2.3.0 → hexcore-2.4.0}/tests/conftest.py +0 -0
- {hexcore-2.3.0 → hexcore-2.4.0}/tests/test_basic.py +0 -0
- {hexcore-2.3.0 → hexcore-2.4.0}/tests/test_beanie_query_utils.py +0 -0
- {hexcore-2.3.0 → hexcore-2.4.0}/tests/test_config_loading.py +0 -0
- {hexcore-2.3.0 → hexcore-2.4.0}/tests/test_cqrs.py +0 -0
- {hexcore-2.3.0 → hexcore-2.4.0}/tests/test_domain_service_query.py +0 -0
- {hexcore-2.3.0 → hexcore-2.4.0}/tests/test_infrastructure_query_path.py +0 -0
- {hexcore-2.3.0 → hexcore-2.4.0}/tests/test_query_field_validation.py +0 -0
- {hexcore-2.3.0 → hexcore-2.4.0}/tests/test_rabbitmq_bus.py +0 -0
- {hexcore-2.3.0 → hexcore-2.4.0}/tests/test_repositories_utils.py +0 -0
- {hexcore-2.3.0 → hexcore-2.4.0}/tests/test_smart_routing.py +0 -0
- {hexcore-2.3.0 → hexcore-2.4.0}/tests/test_uow_session_regression.py +0 -0
- {hexcore-2.3.0 → hexcore-2.4.0}/tests/test_use_cases_query.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: hexcore
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.4.0
|
|
4
4
|
Summary: Núcleo reutilizable para proyectos Python con arquitectura hexagonal y event handling. Provee abstracciones, utilidades y contratos para DDD, eventos y desacoplamiento de infraestructura.
|
|
5
5
|
Author-email: "David Latosefki (Indroic)" <indroic@outlook.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -22,6 +22,8 @@ Provides-Extra: procrastinate
|
|
|
22
22
|
Requires-Dist: procrastinate>=3.0.0; extra == "procrastinate"
|
|
23
23
|
Provides-Extra: rabbitmq
|
|
24
24
|
Requires-Dist: aio-pika>=9.4.0; extra == "rabbitmq"
|
|
25
|
+
Provides-Extra: celery
|
|
26
|
+
Requires-Dist: celery>=5.4.0; extra == "celery"
|
|
25
27
|
Dynamic: license-file
|
|
26
28
|
|
|
27
29
|
# HexCore [](https://pepy.tech/projects/hexcore)
|
|
@@ -516,23 +518,39 @@ class ProcrastinateEnqueuer(ITaskEnqueuer):
|
|
|
516
518
|
await process_generic_task.defer_async(task_name=task_name, payload=payload)
|
|
517
519
|
```
|
|
518
520
|
|
|
519
|
-
####
|
|
521
|
+
#### 2. Configurar tus Buses con un Adaptador Oficial
|
|
520
522
|
|
|
521
|
-
|
|
523
|
+
HexCore provee adaptadores *plug & play* para **Celery** y **Procrastinate**. Simplemente importa el enqueuer, pásale tu app y configúralo en los buses de memoria.
|
|
522
524
|
|
|
523
|
-
|
|
524
|
-
from hexcore.application.cqrs.in_memory_buses import InMemoryCommandBus, InMemoryEventBus
|
|
525
|
+
Si además deseas persistencia o distribución de Eventos entre múltiples workers/servidores (Pub/Sub), puedes cambiar el `InMemoryEventBus` por `RedisEventBus`, `PostgresEventBus` o `RabbitMQEventBus`:
|
|
525
526
|
|
|
526
|
-
|
|
527
|
+
```python
|
|
528
|
+
from hexcore.application.cqrs.in_memory_buses import InMemoryCommandBus
|
|
529
|
+
from hexcore.infrastructure.task_queues.celery_adapter import CeleryEnqueuer
|
|
530
|
+
from hexcore.infrastructure.cqrs.redis_bus import RedisEventBus
|
|
531
|
+
from celery import Celery
|
|
532
|
+
import redis.asyncio as redis
|
|
533
|
+
|
|
534
|
+
# 1. Adaptador de Task Queue (Para Comandos asíncronos y Event Handlers asíncronos)
|
|
535
|
+
app = Celery("my_app", broker="redis://localhost:6379/0")
|
|
536
|
+
enqueuer = CeleryEnqueuer(app)
|
|
527
537
|
serializer = PydanticSerializer()
|
|
528
538
|
|
|
529
|
-
# El bus evalúa: ¿Tiene el comando @background_command? Si es así, usa el enqueuer.
|
|
530
539
|
command_bus = InMemoryCommandBus(registry=registry, enqueuer=enqueuer, serializer=serializer)
|
|
531
540
|
|
|
532
|
-
#
|
|
533
|
-
|
|
541
|
+
# 2. Event Bus (Para enviar los Eventos por la red)
|
|
542
|
+
redis_client = redis.from_url("redis://localhost:6379/0")
|
|
543
|
+
event_bus = RedisEventBus(
|
|
544
|
+
redis_client=redis_client,
|
|
545
|
+
serializer=serializer,
|
|
546
|
+
stream_name="hexcore:events",
|
|
547
|
+
group_name="api_workers",
|
|
548
|
+
enqueuer=enqueuer # <-- Importante para inyectarle la habilidad de Smart Routing
|
|
549
|
+
)
|
|
534
550
|
```
|
|
535
551
|
|
|
552
|
+
> **Tip:** También dispones de `PostgresEventBus(pool, serializer, channel_name)` que usa `LISTEN/NOTIFY` nativo si quieres 0 dependencias externas aparte de tu BD de siempre.
|
|
553
|
+
|
|
536
554
|
#### 4. Ejecutar tareas genéricas
|
|
537
555
|
|
|
538
556
|
Para encolar la tarea genérica (`@background_task`), la llamas indirectamente pasándola por el enqueuer:
|
|
@@ -548,10 +566,11 @@ await enqueuer.enqueue_task(
|
|
|
548
566
|
|
|
549
567
|
#### 5. Levantar el Worker (Consumidor Universal)
|
|
550
568
|
|
|
551
|
-
En el entrypoint de tu worker
|
|
569
|
+
En el entrypoint de tu worker, usa la función utilitaria `register_hexcore_celery_tasks` para autoconfigurar las rutas en una sola línea:
|
|
552
570
|
|
|
553
571
|
```python
|
|
554
572
|
from hexcore.infrastructure.workers.consumer import CQRSConsumer
|
|
573
|
+
from hexcore.infrastructure.task_queues.celery_adapter import register_hexcore_celery_tasks
|
|
555
574
|
|
|
556
575
|
consumer = CQRSConsumer(
|
|
557
576
|
command_bus=command_bus, # Tu CommandBus configurado
|
|
@@ -559,20 +578,8 @@ consumer = CQRSConsumer(
|
|
|
559
578
|
serializer=serializer
|
|
560
579
|
)
|
|
561
580
|
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
# HexCore deserializa y ejecuta el Command usando el CommandBus local
|
|
565
|
-
await consumer.process_command(payload)
|
|
566
|
-
|
|
567
|
-
@app.task(name="process_cqrs_handler")
|
|
568
|
-
async def process_cqrs_handler(handler_name: str, payload: dict):
|
|
569
|
-
# HexCore resuelve y ejecuta exclusivamente el Event Handler asíncrono
|
|
570
|
-
await consumer.process_handler(handler_name, payload)
|
|
571
|
-
|
|
572
|
-
@app.task(name="process_generic_task")
|
|
573
|
-
async def process_generic_task(task_name: str, payload: dict):
|
|
574
|
-
# HexCore resuelve e inyecta los kwargs a la función pura
|
|
575
|
-
await consumer.process_task(task_name, payload)
|
|
581
|
+
# ¡Magia! Registra las tareas 'hexcore.process_command', 'hexcore.process_handler', etc.
|
|
582
|
+
register_hexcore_celery_tasks(app, consumer)
|
|
576
583
|
```
|
|
577
584
|
|
|
578
585
|
---
|
|
@@ -490,23 +490,39 @@ class ProcrastinateEnqueuer(ITaskEnqueuer):
|
|
|
490
490
|
await process_generic_task.defer_async(task_name=task_name, payload=payload)
|
|
491
491
|
```
|
|
492
492
|
|
|
493
|
-
####
|
|
493
|
+
#### 2. Configurar tus Buses con un Adaptador Oficial
|
|
494
494
|
|
|
495
|
-
|
|
495
|
+
HexCore provee adaptadores *plug & play* para **Celery** y **Procrastinate**. Simplemente importa el enqueuer, pásale tu app y configúralo en los buses de memoria.
|
|
496
496
|
|
|
497
|
-
|
|
498
|
-
from hexcore.application.cqrs.in_memory_buses import InMemoryCommandBus, InMemoryEventBus
|
|
497
|
+
Si además deseas persistencia o distribución de Eventos entre múltiples workers/servidores (Pub/Sub), puedes cambiar el `InMemoryEventBus` por `RedisEventBus`, `PostgresEventBus` o `RabbitMQEventBus`:
|
|
499
498
|
|
|
500
|
-
|
|
499
|
+
```python
|
|
500
|
+
from hexcore.application.cqrs.in_memory_buses import InMemoryCommandBus
|
|
501
|
+
from hexcore.infrastructure.task_queues.celery_adapter import CeleryEnqueuer
|
|
502
|
+
from hexcore.infrastructure.cqrs.redis_bus import RedisEventBus
|
|
503
|
+
from celery import Celery
|
|
504
|
+
import redis.asyncio as redis
|
|
505
|
+
|
|
506
|
+
# 1. Adaptador de Task Queue (Para Comandos asíncronos y Event Handlers asíncronos)
|
|
507
|
+
app = Celery("my_app", broker="redis://localhost:6379/0")
|
|
508
|
+
enqueuer = CeleryEnqueuer(app)
|
|
501
509
|
serializer = PydanticSerializer()
|
|
502
510
|
|
|
503
|
-
# El bus evalúa: ¿Tiene el comando @background_command? Si es así, usa el enqueuer.
|
|
504
511
|
command_bus = InMemoryCommandBus(registry=registry, enqueuer=enqueuer, serializer=serializer)
|
|
505
512
|
|
|
506
|
-
#
|
|
507
|
-
|
|
513
|
+
# 2. Event Bus (Para enviar los Eventos por la red)
|
|
514
|
+
redis_client = redis.from_url("redis://localhost:6379/0")
|
|
515
|
+
event_bus = RedisEventBus(
|
|
516
|
+
redis_client=redis_client,
|
|
517
|
+
serializer=serializer,
|
|
518
|
+
stream_name="hexcore:events",
|
|
519
|
+
group_name="api_workers",
|
|
520
|
+
enqueuer=enqueuer # <-- Importante para inyectarle la habilidad de Smart Routing
|
|
521
|
+
)
|
|
508
522
|
```
|
|
509
523
|
|
|
524
|
+
> **Tip:** También dispones de `PostgresEventBus(pool, serializer, channel_name)` que usa `LISTEN/NOTIFY` nativo si quieres 0 dependencias externas aparte de tu BD de siempre.
|
|
525
|
+
|
|
510
526
|
#### 4. Ejecutar tareas genéricas
|
|
511
527
|
|
|
512
528
|
Para encolar la tarea genérica (`@background_task`), la llamas indirectamente pasándola por el enqueuer:
|
|
@@ -522,10 +538,11 @@ await enqueuer.enqueue_task(
|
|
|
522
538
|
|
|
523
539
|
#### 5. Levantar el Worker (Consumidor Universal)
|
|
524
540
|
|
|
525
|
-
En el entrypoint de tu worker
|
|
541
|
+
En el entrypoint de tu worker, usa la función utilitaria `register_hexcore_celery_tasks` para autoconfigurar las rutas en una sola línea:
|
|
526
542
|
|
|
527
543
|
```python
|
|
528
544
|
from hexcore.infrastructure.workers.consumer import CQRSConsumer
|
|
545
|
+
from hexcore.infrastructure.task_queues.celery_adapter import register_hexcore_celery_tasks
|
|
529
546
|
|
|
530
547
|
consumer = CQRSConsumer(
|
|
531
548
|
command_bus=command_bus, # Tu CommandBus configurado
|
|
@@ -533,20 +550,8 @@ consumer = CQRSConsumer(
|
|
|
533
550
|
serializer=serializer
|
|
534
551
|
)
|
|
535
552
|
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
# HexCore deserializa y ejecuta el Command usando el CommandBus local
|
|
539
|
-
await consumer.process_command(payload)
|
|
540
|
-
|
|
541
|
-
@app.task(name="process_cqrs_handler")
|
|
542
|
-
async def process_cqrs_handler(handler_name: str, payload: dict):
|
|
543
|
-
# HexCore resuelve y ejecuta exclusivamente el Event Handler asíncrono
|
|
544
|
-
await consumer.process_handler(handler_name, payload)
|
|
545
|
-
|
|
546
|
-
@app.task(name="process_generic_task")
|
|
547
|
-
async def process_generic_task(task_name: str, payload: dict):
|
|
548
|
-
# HexCore resuelve e inyecta los kwargs a la función pura
|
|
549
|
-
await consumer.process_task(task_name, payload)
|
|
553
|
+
# ¡Magia! Registra las tareas 'hexcore.process_command', 'hexcore.process_handler', etc.
|
|
554
|
+
register_hexcore_celery_tasks(app, consumer)
|
|
550
555
|
```
|
|
551
556
|
|
|
552
557
|
---
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Event Bus basado en PostgreSQL LISTEN / NOTIFY.
|
|
3
|
+
Ligero, rápido y no requiere infraestructura adicional si ya usas PostgreSQL.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
import typing as t
|
|
11
|
+
|
|
12
|
+
from hexcore.domain.cqrs.buses import IEventBus
|
|
13
|
+
from hexcore.domain.cqrs.task_queues import ITaskEnqueuer
|
|
14
|
+
from hexcore.domain.events import DomainEvent
|
|
15
|
+
|
|
16
|
+
if t.TYPE_CHECKING:
|
|
17
|
+
from asyncpg import Pool, Connection
|
|
18
|
+
from hexcore.infrastructure.cqrs.pydantic_serializer import PydanticSerializer
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class PostgresEventBus(IEventBus):
|
|
25
|
+
"""
|
|
26
|
+
Implementación de IEventBus utilizando PostgreSQL LISTEN/NOTIFY.
|
|
27
|
+
Nota: LISTEN/NOTIFY no persiste los eventos una vez entregados.
|
|
28
|
+
Ideal para eventos efímeros o arquitecturas más simples.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
pool: "Pool",
|
|
34
|
+
serializer: "PydanticSerializer",
|
|
35
|
+
channel_name: str,
|
|
36
|
+
enqueuer: ITaskEnqueuer | None = None,
|
|
37
|
+
) -> None:
|
|
38
|
+
self.pool = pool
|
|
39
|
+
self._serializer = serializer
|
|
40
|
+
self.channel_name = channel_name
|
|
41
|
+
self.enqueuer = enqueuer
|
|
42
|
+
|
|
43
|
+
self._handlers: dict[type[DomainEvent], list[t.Callable[..., t.Any]]] = {}
|
|
44
|
+
self._event_types_by_name: dict[str, type[DomainEvent]] = {}
|
|
45
|
+
self._stop_event = asyncio.Event()
|
|
46
|
+
self._listener_connection: "Connection | None" = None
|
|
47
|
+
|
|
48
|
+
def subscribe(self, event_type: type[DomainEvent], handler: t.Callable[..., t.Any]) -> None:
|
|
49
|
+
if event_type not in self._handlers:
|
|
50
|
+
self._handlers[event_type] = []
|
|
51
|
+
self._handlers[event_type].append(handler)
|
|
52
|
+
|
|
53
|
+
fqn = f"{event_type.__module__}.{event_type.__qualname__}"
|
|
54
|
+
self._event_types_by_name[fqn] = event_type
|
|
55
|
+
|
|
56
|
+
async def publish(self, event: DomainEvent) -> None:
|
|
57
|
+
payload = self._serializer.serialize(event)
|
|
58
|
+
json_payload = json.dumps(payload)
|
|
59
|
+
|
|
60
|
+
async with self.pool.acquire() as conn:
|
|
61
|
+
# En PostgreSQL, pg_notify recibe el canal y el payload en texto.
|
|
62
|
+
await conn.execute("SELECT pg_notify($1, $2)", self.channel_name, json_payload)
|
|
63
|
+
|
|
64
|
+
async def start_consuming(self) -> None:
|
|
65
|
+
"""
|
|
66
|
+
Inicia el consumo adquiriendo una conexión dedicada y escuchando el canal.
|
|
67
|
+
"""
|
|
68
|
+
self._stop_event.clear()
|
|
69
|
+
|
|
70
|
+
# Adquirir una conexión persistente para el listener
|
|
71
|
+
self._listener_connection = await self.pool.acquire()
|
|
72
|
+
try:
|
|
73
|
+
await self._listener_connection.add_listener(self.channel_name, self._handle_notify)
|
|
74
|
+
logger.info(f"[*] PostgresEventBus listening on channel '{self.channel_name}'")
|
|
75
|
+
|
|
76
|
+
# Bloquear hasta que alguien llame a stop()
|
|
77
|
+
await self._stop_event.wait()
|
|
78
|
+
|
|
79
|
+
except asyncio.CancelledError:
|
|
80
|
+
pass
|
|
81
|
+
finally:
|
|
82
|
+
if self._listener_connection:
|
|
83
|
+
try:
|
|
84
|
+
await self._listener_connection.remove_listener(self.channel_name, self._handle_notify)
|
|
85
|
+
except Exception as e:
|
|
86
|
+
logger.warning(f"Error removing postgres listener: {e}")
|
|
87
|
+
await self.pool.release(self._listener_connection)
|
|
88
|
+
self._listener_connection = None
|
|
89
|
+
|
|
90
|
+
def _handle_notify(self, connection: "Connection", pid: int, channel: str, payload: str) -> None:
|
|
91
|
+
"""
|
|
92
|
+
Callback invocada por asyncpg cuando recibe un NOTIFY.
|
|
93
|
+
Dado que es síncrona/concurrente desde el driver, delegamos la carga real
|
|
94
|
+
a un asyncio.create_task para evitar bloquear la conexión.
|
|
95
|
+
"""
|
|
96
|
+
asyncio.create_task(self._process_message(payload))
|
|
97
|
+
|
|
98
|
+
async def _process_message(self, json_payload: str) -> None:
|
|
99
|
+
try:
|
|
100
|
+
payload_dict = json.loads(json_payload)
|
|
101
|
+
event_name = payload_dict.get("__type__")
|
|
102
|
+
|
|
103
|
+
event_type = self._event_types_by_name.get(event_name)
|
|
104
|
+
if not event_type:
|
|
105
|
+
return
|
|
106
|
+
|
|
107
|
+
event = self._serializer.deserialize(payload_dict)
|
|
108
|
+
handlers = self._handlers.get(event_type, [])
|
|
109
|
+
|
|
110
|
+
for handler in handlers:
|
|
111
|
+
is_background = getattr(handler, "__cqrs_background_handler__", False)
|
|
112
|
+
if is_background:
|
|
113
|
+
queue_name = getattr(handler, "__cqrs_queue__", "default")
|
|
114
|
+
if not self.enqueuer:
|
|
115
|
+
raise RuntimeError(f"El handler asíncrono {handler.__name__} requiere un enqueuer.")
|
|
116
|
+
|
|
117
|
+
handler_ref = getattr(handler, "__cqrs_handler_name__", f"{handler.__module__}.{handler.__name__}")
|
|
118
|
+
await self.enqueuer.enqueue_handler(handler_ref, payload_dict, queue_name)
|
|
119
|
+
else:
|
|
120
|
+
await handler(event)
|
|
121
|
+
|
|
122
|
+
except Exception as e:
|
|
123
|
+
logger.error(f"Error parseando o ejecutando evento de PostgreSQL NOTIFY: {e}")
|
|
124
|
+
|
|
125
|
+
async def stop(self) -> None:
|
|
126
|
+
self._stop_event.set()
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Event Bus basado en Redis Streams.
|
|
3
|
+
Soporta alta concurrencia mediante Consumer Groups.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
import typing as t
|
|
11
|
+
import uuid
|
|
12
|
+
|
|
13
|
+
from hexcore.domain.cqrs.buses import IEventBus
|
|
14
|
+
from hexcore.domain.cqrs.task_queues import ITaskEnqueuer
|
|
15
|
+
from hexcore.domain.events import DomainEvent
|
|
16
|
+
|
|
17
|
+
if t.TYPE_CHECKING:
|
|
18
|
+
from redis.asyncio import Redis
|
|
19
|
+
from hexcore.infrastructure.cqrs.pydantic_serializer import PydanticSerializer
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class RedisEventBus(IEventBus):
|
|
26
|
+
"""
|
|
27
|
+
Implementación de IEventBus usando Redis Streams.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
redis_client: "Redis",
|
|
33
|
+
serializer: "PydanticSerializer",
|
|
34
|
+
stream_name: str,
|
|
35
|
+
group_name: str,
|
|
36
|
+
consumer_name: str | None = None,
|
|
37
|
+
enqueuer: ITaskEnqueuer | None = None,
|
|
38
|
+
) -> None:
|
|
39
|
+
self.redis = redis_client
|
|
40
|
+
self._serializer = serializer
|
|
41
|
+
self.stream_name = stream_name
|
|
42
|
+
self.group_name = group_name
|
|
43
|
+
self.consumer_name = consumer_name or f"consumer-{uuid.uuid4()}"
|
|
44
|
+
self.enqueuer = enqueuer
|
|
45
|
+
|
|
46
|
+
self._handlers: dict[type[DomainEvent], list[t.Callable[..., t.Any]]] = {}
|
|
47
|
+
self._event_types_by_name: dict[str, type[DomainEvent]] = {}
|
|
48
|
+
self._stop_event = asyncio.Event()
|
|
49
|
+
|
|
50
|
+
def subscribe(self, event_type: type[DomainEvent], handler: t.Callable[..., t.Any]) -> None:
|
|
51
|
+
if event_type not in self._handlers:
|
|
52
|
+
self._handlers[event_type] = []
|
|
53
|
+
self._handlers[event_type].append(handler)
|
|
54
|
+
|
|
55
|
+
fqn = f"{event_type.__module__}.{event_type.__qualname__}"
|
|
56
|
+
self._event_types_by_name[fqn] = event_type
|
|
57
|
+
|
|
58
|
+
async def publish(self, event: DomainEvent) -> None:
|
|
59
|
+
payload = self._serializer.serialize(event)
|
|
60
|
+
# Redis streams accepts dicts with string keys and string values
|
|
61
|
+
json_payload = json.dumps(payload)
|
|
62
|
+
await self.redis.xadd(self.stream_name, {"payload": json_payload})
|
|
63
|
+
|
|
64
|
+
async def start_consuming(self) -> None:
|
|
65
|
+
"""
|
|
66
|
+
Inicia el consumo utilizando Grupos de Consumidores de Redis.
|
|
67
|
+
"""
|
|
68
|
+
import redis.exceptions
|
|
69
|
+
|
|
70
|
+
# Asegurar que el grupo existe, creándolo si no existe (con MKSTREAM)
|
|
71
|
+
try:
|
|
72
|
+
await self.redis.xgroup_create(self.stream_name, self.group_name, id="$", mkstream=True)
|
|
73
|
+
except redis.exceptions.ResponseError as e:
|
|
74
|
+
if "BUSYGROUP Consumer Group name already exists" not in str(e):
|
|
75
|
+
raise e
|
|
76
|
+
|
|
77
|
+
logger.info(f"[*] RedisEventBus consuming from stream '{self.stream_name}' (group: {self.group_name}, consumer: {self.consumer_name})")
|
|
78
|
+
self._stop_event.clear()
|
|
79
|
+
|
|
80
|
+
while not self._stop_event.is_set():
|
|
81
|
+
try:
|
|
82
|
+
# Bloquea hasta 1000ms esperando mensajes no leídos ('>')
|
|
83
|
+
messages = await self.redis.xreadgroup(
|
|
84
|
+
groupname=self.group_name,
|
|
85
|
+
consumername=self.consumer_name,
|
|
86
|
+
streams={self.stream_name: ">"},
|
|
87
|
+
count=10,
|
|
88
|
+
block=1000,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
if not messages:
|
|
92
|
+
continue
|
|
93
|
+
|
|
94
|
+
# messages: [[b'stream_name', [(b'message_id', {b'payload': b'...'}), ...]]]
|
|
95
|
+
for stream, stream_messages in messages:
|
|
96
|
+
for message_id, message_data in stream_messages:
|
|
97
|
+
await self._handle_message(message_id, message_data)
|
|
98
|
+
|
|
99
|
+
except asyncio.CancelledError:
|
|
100
|
+
break
|
|
101
|
+
except Exception as e:
|
|
102
|
+
logger.error(f"Error procesando mensaje de Redis: {e}")
|
|
103
|
+
await asyncio.sleep(1)
|
|
104
|
+
|
|
105
|
+
async def _handle_message(self, message_id: bytes, message_data: dict[bytes, bytes]) -> None:
|
|
106
|
+
try:
|
|
107
|
+
payload_bytes = message_data.get(b"payload")
|
|
108
|
+
if not payload_bytes:
|
|
109
|
+
return
|
|
110
|
+
|
|
111
|
+
payload_dict = json.loads(payload_bytes)
|
|
112
|
+
event_name = payload_dict.get("__type__")
|
|
113
|
+
|
|
114
|
+
event_type = self._event_types_by_name.get(event_name)
|
|
115
|
+
if not event_type:
|
|
116
|
+
# Evento no registrado localmente, lo saltamos y confirmamos
|
|
117
|
+
await self.redis.xack(self.stream_name, self.group_name, message_id)
|
|
118
|
+
return
|
|
119
|
+
|
|
120
|
+
event = self._serializer.deserialize(payload_dict)
|
|
121
|
+
handlers = self._handlers.get(event_type, [])
|
|
122
|
+
|
|
123
|
+
# Ejecutar handlers (respetando Smart Routing)
|
|
124
|
+
for handler in handlers:
|
|
125
|
+
is_background = getattr(handler, "__cqrs_background_handler__", False)
|
|
126
|
+
if is_background:
|
|
127
|
+
queue_name = getattr(handler, "__cqrs_queue__", "default")
|
|
128
|
+
if not self.enqueuer:
|
|
129
|
+
raise RuntimeError(f"El handler asíncrono {handler.__name__} requiere un enqueuer.")
|
|
130
|
+
|
|
131
|
+
handler_ref = getattr(handler, "__cqrs_handler_name__", f"{handler.__module__}.{handler.__name__}")
|
|
132
|
+
await self.enqueuer.enqueue_handler(handler_ref, payload_dict, queue_name)
|
|
133
|
+
else:
|
|
134
|
+
await handler(event)
|
|
135
|
+
|
|
136
|
+
# Confirmar mensaje
|
|
137
|
+
await self.redis.xack(self.stream_name, self.group_name, message_id)
|
|
138
|
+
|
|
139
|
+
except Exception as e:
|
|
140
|
+
logger.error(f"Error parseando o ejecutando evento de Redis (ID {message_id}): {e}")
|
|
141
|
+
|
|
142
|
+
async def stop(self) -> None:
|
|
143
|
+
self._stop_event.set()
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Adaptador para Celery.
|
|
3
|
+
Permite encolar comandos y tareas usando Celery, y proporciona
|
|
4
|
+
utilidades para auto-registrar las tareas asíncronas en el Worker.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import typing as t
|
|
10
|
+
|
|
11
|
+
from hexcore.domain.cqrs.task_queues import ITaskEnqueuer
|
|
12
|
+
|
|
13
|
+
if t.TYPE_CHECKING:
|
|
14
|
+
try:
|
|
15
|
+
from celery import Celery
|
|
16
|
+
except ImportError:
|
|
17
|
+
Celery = t.Any
|
|
18
|
+
|
|
19
|
+
from hexcore.infrastructure.workers.consumer import CQRSConsumer
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class CeleryEnqueuer(ITaskEnqueuer):
|
|
23
|
+
"""
|
|
24
|
+
Adaptador que utiliza Celery para encolar comandos, eventos y tareas.
|
|
25
|
+
Dado que `ITaskEnqueuer` tiene métodos asíncronos y Celery `.send_task`
|
|
26
|
+
es síncrono (bloqueante), utiliza `asyncio.to_thread` para no congelar
|
|
27
|
+
el Event Loop principal (ej. FastAPI).
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, app: "Celery") -> None:
|
|
31
|
+
self.app = app
|
|
32
|
+
|
|
33
|
+
async def enqueue_command(self, command_name: str, payload: dict[str, t.Any], queue: str) -> None:
|
|
34
|
+
await asyncio.to_thread(
|
|
35
|
+
self.app.send_task,
|
|
36
|
+
"hexcore.process_command",
|
|
37
|
+
kwargs={"payload": payload},
|
|
38
|
+
queue=queue,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
async def enqueue_event(self, event_name: str, payload: dict[str, t.Any], queue: str) -> None:
|
|
42
|
+
# Los eventos genéricos (publish a todos los workers) rara vez se envían a Celery
|
|
43
|
+
# de esta forma. Normalmente se usa enqueue_handler. Se provee para completitud.
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
async def enqueue_handler(self, handler_name: str, payload: dict[str, t.Any], queue: str) -> None:
|
|
47
|
+
await asyncio.to_thread(
|
|
48
|
+
self.app.send_task,
|
|
49
|
+
"hexcore.process_handler",
|
|
50
|
+
kwargs={"handler_name": handler_name, "payload": payload},
|
|
51
|
+
queue=queue,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
async def enqueue_task(self, task_name: str, payload: dict[str, t.Any], queue: str) -> None:
|
|
55
|
+
await asyncio.to_thread(
|
|
56
|
+
self.app.send_task,
|
|
57
|
+
"hexcore.process_task",
|
|
58
|
+
kwargs={"task_name": task_name, "payload": payload},
|
|
59
|
+
queue=queue,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def register_hexcore_celery_tasks(app: "Celery", consumer: "CQRSConsumer") -> None:
|
|
64
|
+
"""
|
|
65
|
+
Auto-registra las tareas base de HexCore en una aplicación Celery.
|
|
66
|
+
Esto permite que un worker de Celery esté listo para recibir mensajes
|
|
67
|
+
generados por el Smart Routing de HexCore.
|
|
68
|
+
|
|
69
|
+
Dado que las tareas de Celery son síncronas y el Consumer de HexCore es
|
|
70
|
+
asíncrono, se envuelven en `asyncio.run()`.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
@app.task(name="hexcore.process_command", bind=True)
|
|
74
|
+
def process_command(self: t.Any, payload: dict[str, t.Any]) -> None:
|
|
75
|
+
asyncio.run(consumer.process_command(payload))
|
|
76
|
+
|
|
77
|
+
@app.task(name="hexcore.process_handler", bind=True)
|
|
78
|
+
def process_handler(self: t.Any, handler_name: str, payload: dict[str, t.Any]) -> None:
|
|
79
|
+
asyncio.run(consumer.process_handler(handler_name, payload))
|
|
80
|
+
|
|
81
|
+
@app.task(name="hexcore.process_task", bind=True)
|
|
82
|
+
def process_task(self: t.Any, task_name: str, payload: dict[str, t.Any]) -> None:
|
|
83
|
+
asyncio.run(consumer.process_task(task_name, payload))
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Adaptador para Procrastinate.
|
|
3
|
+
Permite encolar comandos y tareas usando Procrastinate, y proporciona
|
|
4
|
+
utilidades para auto-registrar las tareas asíncronas en el Worker.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import typing as t
|
|
9
|
+
|
|
10
|
+
from hexcore.domain.cqrs.task_queues import ITaskEnqueuer
|
|
11
|
+
|
|
12
|
+
if t.TYPE_CHECKING:
|
|
13
|
+
try:
|
|
14
|
+
import procrastinate
|
|
15
|
+
except ImportError:
|
|
16
|
+
procrastinate = t.Any
|
|
17
|
+
|
|
18
|
+
from hexcore.infrastructure.workers.consumer import CQRSConsumer
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ProcrastinateEnqueuer(ITaskEnqueuer):
|
|
22
|
+
"""
|
|
23
|
+
Adaptador que utiliza Procrastinate (PostgreSQL) para encolar comandos y tareas.
|
|
24
|
+
Totalmente asíncrono y nativo.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, app: "procrastinate.App") -> None:
|
|
28
|
+
self.app = app
|
|
29
|
+
|
|
30
|
+
async def enqueue_command(self, command_name: str, payload: dict[str, t.Any], queue: str) -> None:
|
|
31
|
+
task = self.app.configure_task(name="hexcore.process_command", queue=queue)
|
|
32
|
+
await task.defer_async(payload=payload)
|
|
33
|
+
|
|
34
|
+
async def enqueue_event(self, event_name: str, payload: dict[str, t.Any], queue: str) -> None:
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
async def enqueue_handler(self, handler_name: str, payload: dict[str, t.Any], queue: str) -> None:
|
|
38
|
+
task = self.app.configure_task(name="hexcore.process_handler", queue=queue)
|
|
39
|
+
await task.defer_async(handler_name=handler_name, payload=payload)
|
|
40
|
+
|
|
41
|
+
async def enqueue_task(self, task_name: str, payload: dict[str, t.Any], queue: str) -> None:
|
|
42
|
+
task = self.app.configure_task(name="hexcore.process_task", queue=queue)
|
|
43
|
+
await task.defer_async(task_name=task_name, payload=payload)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def register_hexcore_procrastinate_tasks(app: "procrastinate.App", consumer: "CQRSConsumer") -> None:
|
|
47
|
+
"""
|
|
48
|
+
Auto-registra las tareas base de HexCore en una aplicación Procrastinate.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
@app.task(name="hexcore.process_command")
|
|
52
|
+
async def process_command(payload: dict[str, t.Any]) -> None:
|
|
53
|
+
await consumer.process_command(payload)
|
|
54
|
+
|
|
55
|
+
@app.task(name="hexcore.process_handler")
|
|
56
|
+
async def process_handler(handler_name: str, payload: dict[str, t.Any]) -> None:
|
|
57
|
+
await consumer.process_handler(handler_name, payload)
|
|
58
|
+
|
|
59
|
+
@app.task(name="hexcore.process_task")
|
|
60
|
+
async def process_task(task_name: str, payload: dict[str, t.Any]) -> None:
|
|
61
|
+
await consumer.process_task(task_name, payload)
|