hexcore 2.2.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.2.0/hexcore.egg-info → hexcore-2.4.0}/PKG-INFO +129 -1
- {hexcore-2.2.0 → hexcore-2.4.0}/README.md +126 -0
- hexcore-2.4.0/hexcore/application/cqrs/in_memory_buses.py +164 -0
- hexcore-2.4.0/hexcore/domain/cqrs/decorators.py +78 -0
- hexcore-2.4.0/hexcore/domain/cqrs/task_queues.py +64 -0
- 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.4.0/hexcore/infrastructure/workers/consumer.py +123 -0
- {hexcore-2.2.0 → hexcore-2.4.0/hexcore.egg-info}/PKG-INFO +129 -1
- {hexcore-2.2.0 → hexcore-2.4.0}/hexcore.egg-info/SOURCES.txt +12 -0
- {hexcore-2.2.0 → hexcore-2.4.0}/hexcore.egg-info/requires.txt +3 -0
- {hexcore-2.2.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_smart_routing.py +218 -0
- hexcore-2.4.0/tests/test_task_queues_adapters.py +82 -0
- hexcore-2.2.0/hexcore/application/cqrs/in_memory_buses.py +0 -99
- {hexcore-2.2.0 → hexcore-2.4.0}/LICENSE +0 -0
- {hexcore-2.2.0 → hexcore-2.4.0}/MANIFEST.in +0 -0
- {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/__init__.py +0 -0
- {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/__main__.py +0 -0
- {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/application/__init__.py +0 -0
- {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/application/cqrs/__init__.py +0 -0
- {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/application/cqrs/adapters.py +0 -0
- {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/application/cqrs/config.py +0 -0
- {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/application/cqrs/factory.py +0 -0
- {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/application/cqrs/pipeline.py +0 -0
- {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/application/cqrs/registry.py +0 -0
- {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/application/dtos/__init__.py +0 -0
- {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/application/dtos/base.py +0 -0
- {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/application/dtos/query.py +0 -0
- {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/application/use_cases/__init__.py +0 -0
- {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/application/use_cases/base.py +0 -0
- {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/application/use_cases/query.py +0 -0
- {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/config.py +0 -0
- {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/domain/__init__.py +0 -0
- {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/domain/auth/__init__.py +0 -0
- {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/domain/auth/permissions.py +0 -0
- {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/domain/auth/value_objects.py +0 -0
- {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/domain/base.py +0 -0
- {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/domain/cqrs/__init__.py +0 -0
- {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/domain/cqrs/buses.py +0 -0
- {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/domain/cqrs/commands.py +0 -0
- {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/domain/cqrs/exceptions.py +0 -0
- {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/domain/cqrs/handlers.py +0 -0
- {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/domain/cqrs/middleware.py +0 -0
- {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/domain/cqrs/queries.py +0 -0
- {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/domain/cqrs/serializer.py +0 -0
- {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/domain/events.py +0 -0
- {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/domain/exceptions.py +0 -0
- {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/domain/repositories.py +0 -0
- {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/domain/services.py +0 -0
- {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/domain/uow.py +0 -0
- {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/infrastructure/__init__.py +0 -0
- {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/infrastructure/api/__init__.py +0 -0
- {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/infrastructure/api/utils.py +0 -0
- {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/infrastructure/cache/__init__.py +0 -0
- {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/infrastructure/cache/cache_backends/__init__.py +0 -0
- {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/infrastructure/cache/cache_backends/memory.py +0 -0
- {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/infrastructure/cache/cache_backends/redis.py +0 -0
- {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/infrastructure/cli.py +0 -0
- {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/infrastructure/cqrs/__init__.py +0 -0
- {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/infrastructure/cqrs/middlewares.py +0 -0
- {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/infrastructure/cqrs/procrastinate.py +0 -0
- {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/infrastructure/cqrs/pydantic_serializer.py +0 -0
- {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/infrastructure/cqrs/rabbitmq.py +0 -0
- {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/infrastructure/events/__init__.py +0 -0
- {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/infrastructure/events/events_backends/__init__.py +0 -0
- {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/infrastructure/events/events_backends/memory.py +0 -0
- {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/infrastructure/repositories/__init__.py +0 -0
- {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/infrastructure/repositories/base.py +0 -0
- {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/infrastructure/repositories/decorators.py +0 -0
- {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/infrastructure/repositories/implementations.py +0 -0
- {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/infrastructure/repositories/orms/__init__.py +0 -0
- {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/infrastructure/repositories/orms/beanie/__init__.py +0 -0
- {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/infrastructure/repositories/orms/beanie/utils.py +0 -0
- {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/infrastructure/repositories/orms/sqlalchemy/__init__.py +0 -0
- {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/infrastructure/repositories/orms/sqlalchemy/session.py +0 -0
- {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/infrastructure/repositories/orms/sqlalchemy/utils.py +0 -0
- {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/infrastructure/repositories/utils.py +0 -0
- {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/infrastructure/uow/__init__.py +0 -0
- {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/infrastructure/uow/decorators.py +0 -0
- {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/infrastructure/uow/helpers.py +0 -0
- {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/infrastructure/workers/__init__.py +0 -0
- {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/infrastructure/workers/rabbitmq_worker.py +0 -0
- {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/py.typed +0 -0
- {hexcore-2.2.0 → hexcore-2.4.0}/hexcore/types.py +0 -0
- {hexcore-2.2.0 → hexcore-2.4.0}/hexcore.egg-info/dependency_links.txt +0 -0
- {hexcore-2.2.0 → hexcore-2.4.0}/hexcore.egg-info/entry_points.txt +0 -0
- {hexcore-2.2.0 → hexcore-2.4.0}/hexcore.egg-info/top_level.txt +0 -0
- {hexcore-2.2.0 → hexcore-2.4.0}/scripts/__init__.py +0 -0
- {hexcore-2.2.0 → hexcore-2.4.0}/scripts/main.py +0 -0
- {hexcore-2.2.0 → hexcore-2.4.0}/setup.cfg +0 -0
- {hexcore-2.2.0 → hexcore-2.4.0}/tests/conftest.py +0 -0
- {hexcore-2.2.0 → hexcore-2.4.0}/tests/test_basic.py +0 -0
- {hexcore-2.2.0 → hexcore-2.4.0}/tests/test_beanie_query_utils.py +0 -0
- {hexcore-2.2.0 → hexcore-2.4.0}/tests/test_config_loading.py +0 -0
- {hexcore-2.2.0 → hexcore-2.4.0}/tests/test_cqrs.py +0 -0
- {hexcore-2.2.0 → hexcore-2.4.0}/tests/test_domain_service_query.py +0 -0
- {hexcore-2.2.0 → hexcore-2.4.0}/tests/test_infrastructure_query_path.py +0 -0
- {hexcore-2.2.0 → hexcore-2.4.0}/tests/test_query_field_validation.py +0 -0
- {hexcore-2.2.0 → hexcore-2.4.0}/tests/test_rabbitmq_bus.py +0 -0
- {hexcore-2.2.0 → hexcore-2.4.0}/tests/test_repositories_utils.py +0 -0
- {hexcore-2.2.0 → hexcore-2.4.0}/tests/test_uow_session_regression.py +0 -0
- {hexcore-2.2.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)
|
|
@@ -456,6 +458,132 @@ En ambos casos, es tu **EventBus** (o un consumidor como Procrastinate) el encar
|
|
|
456
458
|
|
|
457
459
|
---
|
|
458
460
|
|
|
461
|
+
### Integración con Task Queues (Smart Routing)
|
|
462
|
+
|
|
463
|
+
HexCore v2 hace que la delegación de tareas a **Celery**, **Procrastinate** o **ARQ** sea increíblemente sencilla y mágica a través del patrón de **Smart Routing**.
|
|
464
|
+
|
|
465
|
+
Ya no necesitas instanciar buses separados para código síncrono y asíncrono. HexCore enruta automáticamente tus comandos y eventos hacia las colas de background usando simples decoradores.
|
|
466
|
+
|
|
467
|
+
#### 1. Decoradores de Background
|
|
468
|
+
|
|
469
|
+
HexCore ofrece 3 decoradores esenciales en `hexcore.domain.cqrs.decorators` para cubrir todos los casos de uso:
|
|
470
|
+
|
|
471
|
+
1. **`@background_command(queue="...")`**: Aplícalo sobre una clase `Command`. Todo el comando y su handler se ejecutarán asíncronamente en el Worker. Ideal para operaciones pesadas iniciadas por el usuario (ej. Generar un reporte PDF masivo).
|
|
472
|
+
2. **`@background_handler(queue="...")`**: Aplícalo sobre una función que maneje un evento (`DomainEvent`). Permite que un solo evento dispare algunas acciones síncronas rápidas y otras asíncronas lentas (ej. Enviar emails).
|
|
473
|
+
3. **`@background_task(queue="...")`**: Aplícalo sobre funciones o utilidades genéricas que no pertenecen al modelo estricto de CQRS (ej. Limpiar base de datos, tareas tipo CRON).
|
|
474
|
+
|
|
475
|
+
**Ejemplos de uso:**
|
|
476
|
+
|
|
477
|
+
```python
|
|
478
|
+
from hexcore.domain.cqrs.decorators import background_command, background_handler, background_task
|
|
479
|
+
from hexcore.domain.cqrs.commands import Command
|
|
480
|
+
|
|
481
|
+
# 1. Comando de ejecución asíncrona obligatoria
|
|
482
|
+
@background_command(queue="high_priority")
|
|
483
|
+
class SendEmailCommand(Command):
|
|
484
|
+
user_id: str
|
|
485
|
+
template: str
|
|
486
|
+
|
|
487
|
+
# 2. Handler de evento asíncrono
|
|
488
|
+
@background_handler(queue="analytics")
|
|
489
|
+
async def send_analytics_on_user_created(event: UserCreatedEvent):
|
|
490
|
+
# Lógica costosa...
|
|
491
|
+
pass
|
|
492
|
+
|
|
493
|
+
# 3. Tarea genérica (Non-CQRS)
|
|
494
|
+
@background_task(queue="maintenance")
|
|
495
|
+
async def clean_old_records_task(days_retention: int):
|
|
496
|
+
# Limpieza de base de datos...
|
|
497
|
+
pass
|
|
498
|
+
```
|
|
499
|
+
|
|
500
|
+
#### 2. Crear el Enqueuer (Adaptador)
|
|
501
|
+
|
|
502
|
+
Implementa la interfaz genérica `ITaskEnqueuer` usando la SDK de tu Task Queue favorito:
|
|
503
|
+
|
|
504
|
+
```python
|
|
505
|
+
from hexcore.domain.cqrs.task_queues import ITaskEnqueuer
|
|
506
|
+
|
|
507
|
+
class ProcrastinateEnqueuer(ITaskEnqueuer):
|
|
508
|
+
async def enqueue_command(self, command_name: str, payload: dict, queue: str) -> None:
|
|
509
|
+
await process_cqrs_command.defer_async(payload=payload)
|
|
510
|
+
|
|
511
|
+
async def enqueue_handler(self, handler_name: str, payload: dict, queue: str) -> None:
|
|
512
|
+
await process_cqrs_handler.defer_async(handler_name=handler_name, payload=payload)
|
|
513
|
+
|
|
514
|
+
async def enqueue_event(self, event_name: str, payload: dict, queue: str) -> None:
|
|
515
|
+
pass # Usualmente no se usa directamente si utilizas @background_handler
|
|
516
|
+
|
|
517
|
+
async def enqueue_task(self, task_name: str, payload: dict, queue: str) -> None:
|
|
518
|
+
await process_generic_task.defer_async(task_name=task_name, payload=payload)
|
|
519
|
+
```
|
|
520
|
+
|
|
521
|
+
#### 2. Configurar tus Buses con un Adaptador Oficial
|
|
522
|
+
|
|
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.
|
|
524
|
+
|
|
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`:
|
|
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)
|
|
537
|
+
serializer = PydanticSerializer()
|
|
538
|
+
|
|
539
|
+
command_bus = InMemoryCommandBus(registry=registry, enqueuer=enqueuer, serializer=serializer)
|
|
540
|
+
|
|
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
|
+
)
|
|
550
|
+
```
|
|
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
|
+
|
|
554
|
+
#### 4. Ejecutar tareas genéricas
|
|
555
|
+
|
|
556
|
+
Para encolar la tarea genérica (`@background_task`), la llamas indirectamente pasándola por el enqueuer:
|
|
557
|
+
|
|
558
|
+
```python
|
|
559
|
+
# Así se encola una tarea genérica sin CQRS:
|
|
560
|
+
await enqueuer.enqueue_task(
|
|
561
|
+
task_name=clean_old_records_task.__cqrs_task_name__,
|
|
562
|
+
payload={"days_retention": 30},
|
|
563
|
+
queue=clean_old_records_task.__cqrs_queue__
|
|
564
|
+
)
|
|
565
|
+
```
|
|
566
|
+
|
|
567
|
+
#### 5. Levantar el Worker (Consumidor Universal)
|
|
568
|
+
|
|
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:
|
|
570
|
+
|
|
571
|
+
```python
|
|
572
|
+
from hexcore.infrastructure.workers.consumer import CQRSConsumer
|
|
573
|
+
from hexcore.infrastructure.task_queues.celery_adapter import register_hexcore_celery_tasks
|
|
574
|
+
|
|
575
|
+
consumer = CQRSConsumer(
|
|
576
|
+
command_bus=command_bus, # Tu CommandBus configurado
|
|
577
|
+
event_bus=event_bus,
|
|
578
|
+
serializer=serializer
|
|
579
|
+
)
|
|
580
|
+
|
|
581
|
+
# ¡Magia! Registra las tareas 'hexcore.process_command', 'hexcore.process_handler', etc.
|
|
582
|
+
register_hexcore_celery_tasks(app, consumer)
|
|
583
|
+
```
|
|
584
|
+
|
|
585
|
+
---
|
|
586
|
+
|
|
459
587
|
## Referencias
|
|
460
588
|
|
|
461
589
|
- [CONTRIBUTING.md](./CONTRIBUTING.md): Pautas de colaboración.
|
|
@@ -430,6 +430,132 @@ En ambos casos, es tu **EventBus** (o un consumidor como Procrastinate) el encar
|
|
|
430
430
|
|
|
431
431
|
---
|
|
432
432
|
|
|
433
|
+
### Integración con Task Queues (Smart Routing)
|
|
434
|
+
|
|
435
|
+
HexCore v2 hace que la delegación de tareas a **Celery**, **Procrastinate** o **ARQ** sea increíblemente sencilla y mágica a través del patrón de **Smart Routing**.
|
|
436
|
+
|
|
437
|
+
Ya no necesitas instanciar buses separados para código síncrono y asíncrono. HexCore enruta automáticamente tus comandos y eventos hacia las colas de background usando simples decoradores.
|
|
438
|
+
|
|
439
|
+
#### 1. Decoradores de Background
|
|
440
|
+
|
|
441
|
+
HexCore ofrece 3 decoradores esenciales en `hexcore.domain.cqrs.decorators` para cubrir todos los casos de uso:
|
|
442
|
+
|
|
443
|
+
1. **`@background_command(queue="...")`**: Aplícalo sobre una clase `Command`. Todo el comando y su handler se ejecutarán asíncronamente en el Worker. Ideal para operaciones pesadas iniciadas por el usuario (ej. Generar un reporte PDF masivo).
|
|
444
|
+
2. **`@background_handler(queue="...")`**: Aplícalo sobre una función que maneje un evento (`DomainEvent`). Permite que un solo evento dispare algunas acciones síncronas rápidas y otras asíncronas lentas (ej. Enviar emails).
|
|
445
|
+
3. **`@background_task(queue="...")`**: Aplícalo sobre funciones o utilidades genéricas que no pertenecen al modelo estricto de CQRS (ej. Limpiar base de datos, tareas tipo CRON).
|
|
446
|
+
|
|
447
|
+
**Ejemplos de uso:**
|
|
448
|
+
|
|
449
|
+
```python
|
|
450
|
+
from hexcore.domain.cqrs.decorators import background_command, background_handler, background_task
|
|
451
|
+
from hexcore.domain.cqrs.commands import Command
|
|
452
|
+
|
|
453
|
+
# 1. Comando de ejecución asíncrona obligatoria
|
|
454
|
+
@background_command(queue="high_priority")
|
|
455
|
+
class SendEmailCommand(Command):
|
|
456
|
+
user_id: str
|
|
457
|
+
template: str
|
|
458
|
+
|
|
459
|
+
# 2. Handler de evento asíncrono
|
|
460
|
+
@background_handler(queue="analytics")
|
|
461
|
+
async def send_analytics_on_user_created(event: UserCreatedEvent):
|
|
462
|
+
# Lógica costosa...
|
|
463
|
+
pass
|
|
464
|
+
|
|
465
|
+
# 3. Tarea genérica (Non-CQRS)
|
|
466
|
+
@background_task(queue="maintenance")
|
|
467
|
+
async def clean_old_records_task(days_retention: int):
|
|
468
|
+
# Limpieza de base de datos...
|
|
469
|
+
pass
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
#### 2. Crear el Enqueuer (Adaptador)
|
|
473
|
+
|
|
474
|
+
Implementa la interfaz genérica `ITaskEnqueuer` usando la SDK de tu Task Queue favorito:
|
|
475
|
+
|
|
476
|
+
```python
|
|
477
|
+
from hexcore.domain.cqrs.task_queues import ITaskEnqueuer
|
|
478
|
+
|
|
479
|
+
class ProcrastinateEnqueuer(ITaskEnqueuer):
|
|
480
|
+
async def enqueue_command(self, command_name: str, payload: dict, queue: str) -> None:
|
|
481
|
+
await process_cqrs_command.defer_async(payload=payload)
|
|
482
|
+
|
|
483
|
+
async def enqueue_handler(self, handler_name: str, payload: dict, queue: str) -> None:
|
|
484
|
+
await process_cqrs_handler.defer_async(handler_name=handler_name, payload=payload)
|
|
485
|
+
|
|
486
|
+
async def enqueue_event(self, event_name: str, payload: dict, queue: str) -> None:
|
|
487
|
+
pass # Usualmente no se usa directamente si utilizas @background_handler
|
|
488
|
+
|
|
489
|
+
async def enqueue_task(self, task_name: str, payload: dict, queue: str) -> None:
|
|
490
|
+
await process_generic_task.defer_async(task_name=task_name, payload=payload)
|
|
491
|
+
```
|
|
492
|
+
|
|
493
|
+
#### 2. Configurar tus Buses con un Adaptador Oficial
|
|
494
|
+
|
|
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
|
+
|
|
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`:
|
|
498
|
+
|
|
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)
|
|
509
|
+
serializer = PydanticSerializer()
|
|
510
|
+
|
|
511
|
+
command_bus = InMemoryCommandBus(registry=registry, enqueuer=enqueuer, serializer=serializer)
|
|
512
|
+
|
|
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
|
+
)
|
|
522
|
+
```
|
|
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
|
+
|
|
526
|
+
#### 4. Ejecutar tareas genéricas
|
|
527
|
+
|
|
528
|
+
Para encolar la tarea genérica (`@background_task`), la llamas indirectamente pasándola por el enqueuer:
|
|
529
|
+
|
|
530
|
+
```python
|
|
531
|
+
# Así se encola una tarea genérica sin CQRS:
|
|
532
|
+
await enqueuer.enqueue_task(
|
|
533
|
+
task_name=clean_old_records_task.__cqrs_task_name__,
|
|
534
|
+
payload={"days_retention": 30},
|
|
535
|
+
queue=clean_old_records_task.__cqrs_queue__
|
|
536
|
+
)
|
|
537
|
+
```
|
|
538
|
+
|
|
539
|
+
#### 5. Levantar el Worker (Consumidor Universal)
|
|
540
|
+
|
|
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:
|
|
542
|
+
|
|
543
|
+
```python
|
|
544
|
+
from hexcore.infrastructure.workers.consumer import CQRSConsumer
|
|
545
|
+
from hexcore.infrastructure.task_queues.celery_adapter import register_hexcore_celery_tasks
|
|
546
|
+
|
|
547
|
+
consumer = CQRSConsumer(
|
|
548
|
+
command_bus=command_bus, # Tu CommandBus configurado
|
|
549
|
+
event_bus=event_bus,
|
|
550
|
+
serializer=serializer
|
|
551
|
+
)
|
|
552
|
+
|
|
553
|
+
# ¡Magia! Registra las tareas 'hexcore.process_command', 'hexcore.process_handler', etc.
|
|
554
|
+
register_hexcore_celery_tasks(app, consumer)
|
|
555
|
+
```
|
|
556
|
+
|
|
557
|
+
---
|
|
558
|
+
|
|
433
559
|
## Referencias
|
|
434
560
|
|
|
435
561
|
- [CONTRIBUTING.md](./CONTRIBUTING.md): Pautas de colaboración.
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Implementaciones In-Memory de los buses CQRS con soporte para Enrutamiento Inteligente (Smart Routing).
|
|
3
|
+
"""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import typing as t
|
|
7
|
+
import logging
|
|
8
|
+
|
|
9
|
+
from hexcore.domain.cqrs.buses import ICommandBus, IQueryBus, IEventBus
|
|
10
|
+
from hexcore.domain.cqrs.commands import Command
|
|
11
|
+
from hexcore.domain.cqrs.queries import Query
|
|
12
|
+
from hexcore.domain.events import DomainEvent
|
|
13
|
+
from hexcore.domain.cqrs.task_queues import ITaskEnqueuer
|
|
14
|
+
from hexcore.domain.cqrs.serializer import ISerializer
|
|
15
|
+
|
|
16
|
+
from .registry import HandlerRegistry
|
|
17
|
+
from .pipeline import MiddlewarePipeline
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger("hexcore.cqrs.buses")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class InMemoryCommandBus(ICommandBus):
|
|
23
|
+
"""
|
|
24
|
+
Bus de commands síncrono en memoria con Smart Routing.
|
|
25
|
+
Resuelve el handler desde el registry, ejecuta el pipeline de middlewares
|
|
26
|
+
y retorna el resultado.
|
|
27
|
+
|
|
28
|
+
Si el comando está decorado con `@background_command` y se provee un `enqueuer`,
|
|
29
|
+
el comando es automáticamente encolado para su ejecución en segundo plano
|
|
30
|
+
sin bloquear el proceso actual (retornando None).
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
registry: HandlerRegistry,
|
|
36
|
+
pipeline: MiddlewarePipeline | None = None,
|
|
37
|
+
enqueuer: ITaskEnqueuer | None = None,
|
|
38
|
+
serializer: ISerializer | None = None,
|
|
39
|
+
) -> None:
|
|
40
|
+
self._registry = registry
|
|
41
|
+
self._pipeline = pipeline or MiddlewarePipeline()
|
|
42
|
+
self._enqueuer = enqueuer
|
|
43
|
+
self._serializer = serializer
|
|
44
|
+
|
|
45
|
+
async def dispatch(self, command: Command) -> t.Any:
|
|
46
|
+
cmd_type = type(command)
|
|
47
|
+
|
|
48
|
+
# 1. Smart Routing: ¿Debe irse a background?
|
|
49
|
+
is_background = getattr(cmd_type, "__cqrs_background__", False)
|
|
50
|
+
|
|
51
|
+
if is_background:
|
|
52
|
+
if not self._enqueuer or not self._serializer:
|
|
53
|
+
raise RuntimeError(
|
|
54
|
+
f"El comando '{cmd_type.__name__}' requiere ejecución en background, "
|
|
55
|
+
"pero el InMemoryCommandBus no tiene configurado un 'enqueuer' o 'serializer'."
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
async def background_dispatcher(cmd: Command) -> None:
|
|
59
|
+
queue_name = getattr(cmd_type, "__cqrs_queue__", "default")
|
|
60
|
+
payload = self._serializer.serialize(cmd) # type: ignore
|
|
61
|
+
await self._enqueuer.enqueue_command(cmd_type.__name__, payload, queue=queue_name) # type: ignore
|
|
62
|
+
logger.debug("[SmartRouting] Comando %s enrutado a background (queue=%s)", cmd_type.__name__, queue_name)
|
|
63
|
+
|
|
64
|
+
await self._pipeline.execute(command, background_dispatcher)
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
# 2. Ejecución Local Estándar
|
|
68
|
+
handler = self._registry.resolve_command_handler(cmd_type)
|
|
69
|
+
|
|
70
|
+
async def final_handler(cmd: t.Any) -> t.Any:
|
|
71
|
+
return await handler.handle(cmd)
|
|
72
|
+
|
|
73
|
+
return await self._pipeline.execute(command, final_handler)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class InMemoryQueryBus(IQueryBus):
|
|
77
|
+
"""
|
|
78
|
+
Bus de queries síncrono en memoria.
|
|
79
|
+
Las queries NUNCA pasan por buses asíncronos
|
|
80
|
+
(principio CQRS: las lecturas son siempre síncronas).
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
def __init__(
|
|
84
|
+
self,
|
|
85
|
+
registry: HandlerRegistry,
|
|
86
|
+
pipeline: MiddlewarePipeline | None = None,
|
|
87
|
+
) -> None:
|
|
88
|
+
self._registry = registry
|
|
89
|
+
self._pipeline = pipeline or MiddlewarePipeline()
|
|
90
|
+
|
|
91
|
+
async def ask(self, query: Query[t.Any]) -> t.Any:
|
|
92
|
+
handler = self._registry.resolve_query_handler(type(query))
|
|
93
|
+
|
|
94
|
+
async def final_handler(q: t.Any) -> t.Any:
|
|
95
|
+
return await handler.handle(q)
|
|
96
|
+
|
|
97
|
+
return await self._pipeline.execute(query, final_handler)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class InMemoryEventBus(IEventBus):
|
|
101
|
+
"""
|
|
102
|
+
Bus de eventos en memoria con Smart Routing para Handlers Asíncronos.
|
|
103
|
+
|
|
104
|
+
Permite publicar eventos a todos los suscriptores. Si un suscriptor
|
|
105
|
+
fue decorado con `@background_handler`, se enviará un mensaje al Task Queue
|
|
106
|
+
para que *solo* ese handler se ejecute en background para este evento.
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
def __init__(
|
|
110
|
+
self,
|
|
111
|
+
pipeline: MiddlewarePipeline | None = None,
|
|
112
|
+
enqueuer: ITaskEnqueuer | None = None,
|
|
113
|
+
serializer: ISerializer | None = None,
|
|
114
|
+
) -> None:
|
|
115
|
+
self._handlers: dict[
|
|
116
|
+
type[DomainEvent],
|
|
117
|
+
list[t.Callable[[DomainEvent], t.Awaitable[None]]],
|
|
118
|
+
] = {}
|
|
119
|
+
self._pipeline = pipeline or MiddlewarePipeline()
|
|
120
|
+
self._enqueuer = enqueuer
|
|
121
|
+
self._serializer = serializer
|
|
122
|
+
|
|
123
|
+
def subscribe(
|
|
124
|
+
self,
|
|
125
|
+
event_type: type[DomainEvent],
|
|
126
|
+
handler: t.Callable[[DomainEvent], t.Awaitable[None]],
|
|
127
|
+
) -> None:
|
|
128
|
+
self._handlers.setdefault(event_type, []).append(handler)
|
|
129
|
+
|
|
130
|
+
async def publish(self, event: DomainEvent) -> None:
|
|
131
|
+
handlers = self._handlers.get(type(event), [])
|
|
132
|
+
|
|
133
|
+
for event_handler in handlers:
|
|
134
|
+
is_background = getattr(event_handler, "__cqrs_background_handler__", False)
|
|
135
|
+
|
|
136
|
+
if is_background:
|
|
137
|
+
# Enrutamiento hacia background
|
|
138
|
+
if not self._enqueuer or not self._serializer:
|
|
139
|
+
raise RuntimeError(
|
|
140
|
+
f"El handler de eventos '{event_handler.__name__}' requiere ejecución en background, "
|
|
141
|
+
"pero el InMemoryEventBus no tiene configurado un 'enqueuer' o 'serializer'."
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
async def background_dispatcher(
|
|
145
|
+
evt: t.Any,
|
|
146
|
+
_h: t.Callable[..., t.Awaitable[None]] = event_handler,
|
|
147
|
+
) -> None:
|
|
148
|
+
handler_name = getattr(_h, "__cqrs_handler_name__")
|
|
149
|
+
queue_name = getattr(_h, "__cqrs_queue__", "default")
|
|
150
|
+
payload = self._serializer.serialize(evt) # type: ignore
|
|
151
|
+
await self._enqueuer.enqueue_handler(handler_name, payload, queue=queue_name) # type: ignore
|
|
152
|
+
logger.debug("[SmartRouting] EventHandler %s enrutado a background (queue=%s)", handler_name, queue_name)
|
|
153
|
+
|
|
154
|
+
await self._pipeline.execute(event, background_dispatcher)
|
|
155
|
+
|
|
156
|
+
else:
|
|
157
|
+
# Ejecución local síncrona
|
|
158
|
+
async def final_handler(
|
|
159
|
+
evt: t.Any,
|
|
160
|
+
_h: t.Callable[..., t.Awaitable[None]] = event_handler,
|
|
161
|
+
) -> None:
|
|
162
|
+
await _h(evt)
|
|
163
|
+
|
|
164
|
+
await self._pipeline.execute(event, final_handler)
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Decoradores para habilitar el enrutamiento inteligente (Smart Routing) de tareas, comandos
|
|
3
|
+
y manejadores de eventos hacia Task Queues (Celery, Procrastinate, etc.) de forma transparente.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import typing as t
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def background_command(queue: str = "default") -> t.Callable[[t.Type[t.Any]], t.Type[t.Any]]:
|
|
11
|
+
"""
|
|
12
|
+
Decora una clase `Command` para indicar que su ejecución debe enviarse siempre
|
|
13
|
+
a un procesador en segundo plano (Task Queue).
|
|
14
|
+
|
|
15
|
+
Cuando el CommandBus detecta este decorador, no ejecutará el comando localmente,
|
|
16
|
+
sino que llamará a `ITaskEnqueuer.enqueue_command()`.
|
|
17
|
+
|
|
18
|
+
Ejemplo:
|
|
19
|
+
@background_command(queue="emails")
|
|
20
|
+
class SendEmailCommand(Command):
|
|
21
|
+
...
|
|
22
|
+
"""
|
|
23
|
+
def wrapper(cls: t.Type[t.Any]) -> t.Type[t.Any]:
|
|
24
|
+
cls.__cqrs_background__ = True
|
|
25
|
+
cls.__cqrs_queue__ = queue
|
|
26
|
+
return cls
|
|
27
|
+
|
|
28
|
+
return wrapper
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def background_handler(queue: str = "default") -> t.Callable[[t.Callable[..., t.Any]], t.Callable[..., t.Any]]:
|
|
32
|
+
"""
|
|
33
|
+
Decora un manejador de eventos (Event Handler) para indicar que su ejecución
|
|
34
|
+
debe ocurrir de forma asíncrona en un Worker de background.
|
|
35
|
+
|
|
36
|
+
Cuando el EventBus detecta este decorador en un handler suscrito, en lugar de
|
|
37
|
+
ejecutarlo directamente, delegará la tarea a `ITaskEnqueuer.enqueue_handler()`.
|
|
38
|
+
|
|
39
|
+
Ejemplo:
|
|
40
|
+
@background_handler(queue="analytics")
|
|
41
|
+
async def on_user_created(event: UserCreatedEvent):
|
|
42
|
+
...
|
|
43
|
+
"""
|
|
44
|
+
def wrapper(func: t.Callable[..., t.Any]) -> t.Callable[..., t.Any]:
|
|
45
|
+
func.__cqrs_background_handler__ = True
|
|
46
|
+
func.__cqrs_queue__ = queue
|
|
47
|
+
|
|
48
|
+
# Guardamos el nombre calificado del handler para poder resolverlo luego en el Worker
|
|
49
|
+
if not hasattr(func, "__qualname__"):
|
|
50
|
+
func.__cqrs_handler_name__ = func.__name__
|
|
51
|
+
else:
|
|
52
|
+
func.__cqrs_handler_name__ = f"{func.__module__}.{func.__qualname__}"
|
|
53
|
+
|
|
54
|
+
return func
|
|
55
|
+
|
|
56
|
+
return wrapper
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def background_task(queue: str = "default") -> t.Callable[[t.Callable[..., t.Any]], t.Callable[..., t.Any]]:
|
|
60
|
+
"""
|
|
61
|
+
Decora una función genérica para convertirla en una Tarea de Background
|
|
62
|
+
(independiente de CQRS).
|
|
63
|
+
|
|
64
|
+
La función original queda marcada, y puede ser registrada en el
|
|
65
|
+
`TaskManager` de HexCore o ejecutada directamente usando el `ITaskEnqueuer`.
|
|
66
|
+
"""
|
|
67
|
+
def wrapper(func: t.Callable[..., t.Any]) -> t.Callable[..., t.Any]:
|
|
68
|
+
func.__cqrs_background_task__ = True
|
|
69
|
+
func.__cqrs_queue__ = queue
|
|
70
|
+
|
|
71
|
+
if not hasattr(func, "__qualname__"):
|
|
72
|
+
func.__cqrs_task_name__ = func.__name__
|
|
73
|
+
else:
|
|
74
|
+
func.__cqrs_task_name__ = f"{func.__module__}.{func.__qualname__}"
|
|
75
|
+
|
|
76
|
+
return func
|
|
77
|
+
|
|
78
|
+
return wrapper
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Interfaces fundamentales para delegar mensajes a Task Queues (Celery, Procrastinate, RQ, ARQ).
|
|
3
|
+
"""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import abc
|
|
7
|
+
import typing as t
|
|
8
|
+
|
|
9
|
+
if t.TYPE_CHECKING:
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ITaskEnqueuer(abc.ABC):
|
|
14
|
+
"""
|
|
15
|
+
Puerto (Puerto de Salida) genérico para encolar tareas asíncronas
|
|
16
|
+
o en paralelo hacia un backend de colas de mensajes (Task Queue).
|
|
17
|
+
|
|
18
|
+
Un adaptador concreto (ej. `CeleryTaskEnqueuer`) usará la SDK
|
|
19
|
+
nativa del worker para empujar el `payload`.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
@abc.abstractmethod
|
|
23
|
+
async def enqueue_command(self, command_name: str, payload: dict[str, t.Any], queue: str) -> None:
|
|
24
|
+
"""
|
|
25
|
+
Encola un comando de dominio serializado para ser ejecutado en background.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
command_name: Nombre calificado del comando (e.g. 'CreateUserCommand').
|
|
29
|
+
payload: Payload pre-serializado.
|
|
30
|
+
queue: Nombre de la cola de destino.
|
|
31
|
+
"""
|
|
32
|
+
raise NotImplementedError
|
|
33
|
+
|
|
34
|
+
@abc.abstractmethod
|
|
35
|
+
async def enqueue_event(self, event_name: str, payload: dict[str, t.Any], queue: str) -> None:
|
|
36
|
+
"""
|
|
37
|
+
Encola un evento de dominio serializado para ser distribuido genéricamente.
|
|
38
|
+
"""
|
|
39
|
+
raise NotImplementedError
|
|
40
|
+
|
|
41
|
+
@abc.abstractmethod
|
|
42
|
+
async def enqueue_handler(self, handler_name: str, payload: dict[str, t.Any], queue: str) -> None:
|
|
43
|
+
"""
|
|
44
|
+
Encola la ejecución de un *Event Handler Específico* para un evento.
|
|
45
|
+
Esto permite que un Evento dispare handlers asíncronos individuales.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
handler_name: Nombre calificado del handler (func.__cqrs_handler_name__).
|
|
49
|
+
payload: Payload pre-serializado del evento.
|
|
50
|
+
queue: Nombre de la cola.
|
|
51
|
+
"""
|
|
52
|
+
raise NotImplementedError
|
|
53
|
+
|
|
54
|
+
@abc.abstractmethod
|
|
55
|
+
async def enqueue_task(self, task_name: str, payload: dict[str, t.Any], queue: str) -> None:
|
|
56
|
+
"""
|
|
57
|
+
Encola una tarea genérica de background (independiente de CQRS).
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
task_name: Nombre calificado de la tarea (func.__cqrs_task_name__).
|
|
61
|
+
payload: Argumentos serializados para la tarea.
|
|
62
|
+
queue: Nombre de la cola.
|
|
63
|
+
"""
|
|
64
|
+
raise NotImplementedError
|