hexcore 2.4.0__tar.gz → 3.0.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.4.0 → hexcore-3.0.0}/PKG-INFO +131 -14
- hexcore-2.4.0/hexcore.egg-info/PKG-INFO → hexcore-3.0.0/README.md +103 -31
- {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/application/cqrs/__init__.py +2 -0
- {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/application/cqrs/config.py +6 -7
- hexcore-3.0.0/hexcore/application/cqrs/factory.py +215 -0
- {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/application/cqrs/in_memory_buses.py +24 -7
- hexcore-3.0.0/hexcore/application/cqrs/registry.py +185 -0
- hexcore-3.0.0/hexcore/application/cqrs/scheduler.py +242 -0
- {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/domain/cqrs/__init__.py +14 -0
- hexcore-3.0.0/hexcore/domain/cqrs/context.py +61 -0
- hexcore-3.0.0/hexcore/domain/cqrs/cron.py +69 -0
- {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/domain/cqrs/decorators.py +27 -17
- hexcore-3.0.0/hexcore/domain/cqrs/resolution.py +87 -0
- {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/infrastructure/api/utils.py +21 -0
- {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/infrastructure/cqrs/middlewares.py +29 -20
- {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/infrastructure/cqrs/postgres_bus.py +11 -3
- hexcore-3.0.0/hexcore/infrastructure/cqrs/postgres_lock.py +190 -0
- {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/infrastructure/cqrs/pydantic_serializer.py +6 -6
- {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/infrastructure/cqrs/redis_bus.py +12 -4
- hexcore-3.0.0/hexcore/infrastructure/cqrs/redis_lock.py +110 -0
- {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/infrastructure/repositories/base.py +6 -3
- hexcore-3.0.0/hexcore/infrastructure/repositories/implementations.py +198 -0
- hexcore-3.0.0/hexcore/infrastructure/repositories/orms/sqlalchemy/session.py +208 -0
- {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/infrastructure/repositories/utils.py +15 -6
- {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/infrastructure/task_queues/celery_adapter.py +51 -6
- hexcore-3.0.0/hexcore/infrastructure/task_queues/procrastinate_adapter.py +120 -0
- hexcore-3.0.0/hexcore/infrastructure/uow/__init__.py +209 -0
- hexcore-3.0.0/hexcore/infrastructure/uow/scopes.py +93 -0
- {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/infrastructure/workers/consumer.py +67 -22
- {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/infrastructure/workers/rabbitmq_worker.py +10 -6
- {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/types.py +4 -1
- hexcore-2.4.0/README.md → hexcore-3.0.0/hexcore.egg-info/PKG-INFO +148 -3
- {hexcore-2.4.0 → hexcore-3.0.0}/hexcore.egg-info/SOURCES.txt +21 -1
- {hexcore-2.4.0 → hexcore-3.0.0}/hexcore.egg-info/requires.txt +30 -8
- {hexcore-2.4.0 → hexcore-3.0.0}/pyproject.toml +26 -10
- {hexcore-2.4.0 → hexcore-3.0.0}/tests/test_beanie_query_utils.py +2 -0
- hexcore-3.0.0/tests/test_consumer_optional_event_bus.py +68 -0
- hexcore-3.0.0/tests/test_cqrs_factory.py +141 -0
- hexcore-3.0.0/tests/test_cqrs_middlewares.py +61 -0
- hexcore-3.0.0/tests/test_dotted_resolution.py +138 -0
- hexcore-3.0.0/tests/test_handler_registry.py +199 -0
- {hexcore-2.4.0 → hexcore-3.0.0}/tests/test_infrastructure_query_path.py +3 -0
- hexcore-3.0.0/tests/test_lock_error_policy.py +132 -0
- hexcore-3.0.0/tests/test_optional_dependencies.py +50 -0
- hexcore-3.0.0/tests/test_postgres_lock.py +152 -0
- {hexcore-2.4.0 → hexcore-3.0.0}/tests/test_query_field_validation.py +2 -0
- hexcore-3.0.0/tests/test_redis_lock.py +69 -0
- {hexcore-2.4.0 → hexcore-3.0.0}/tests/test_repositories_utils.py +2 -0
- hexcore-3.0.0/tests/test_scheduler.py +117 -0
- hexcore-3.0.0/tests/test_scheduler_catchup.py +361 -0
- {hexcore-2.4.0 → hexcore-3.0.0}/tests/test_smart_routing.py +33 -23
- hexcore-3.0.0/tests/test_sql_session_layer.py +338 -0
- hexcore-3.0.0/tests/test_task_queues_adapters.py +162 -0
- {hexcore-2.4.0 → hexcore-3.0.0}/tests/test_uow_session_regression.py +2 -0
- {hexcore-2.4.0 → hexcore-3.0.0}/tests/test_use_cases_query.py +2 -0
- hexcore-3.0.0/tests/test_worker_bus_integration.py +271 -0
- hexcore-2.4.0/hexcore/application/cqrs/factory.py +0 -131
- hexcore-2.4.0/hexcore/application/cqrs/registry.py +0 -115
- hexcore-2.4.0/hexcore/infrastructure/repositories/implementations.py +0 -212
- hexcore-2.4.0/hexcore/infrastructure/repositories/orms/sqlalchemy/session.py +0 -66
- hexcore-2.4.0/hexcore/infrastructure/task_queues/procrastinate_adapter.py +0 -61
- hexcore-2.4.0/hexcore/infrastructure/uow/__init__.py +0 -181
- hexcore-2.4.0/tests/test_task_queues_adapters.py +0 -82
- {hexcore-2.4.0 → hexcore-3.0.0}/LICENSE +0 -0
- {hexcore-2.4.0 → hexcore-3.0.0}/MANIFEST.in +0 -0
- {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/__init__.py +0 -0
- {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/__main__.py +0 -0
- {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/application/__init__.py +0 -0
- {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/application/cqrs/adapters.py +0 -0
- {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/application/cqrs/pipeline.py +0 -0
- {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/application/dtos/__init__.py +0 -0
- {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/application/dtos/base.py +0 -0
- {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/application/dtos/query.py +0 -0
- {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/application/use_cases/__init__.py +0 -0
- {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/application/use_cases/base.py +0 -0
- {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/application/use_cases/query.py +0 -0
- {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/config.py +0 -0
- {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/domain/__init__.py +0 -0
- {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/domain/auth/__init__.py +0 -0
- {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/domain/auth/permissions.py +0 -0
- {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/domain/auth/value_objects.py +0 -0
- {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/domain/base.py +0 -0
- {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/domain/cqrs/buses.py +0 -0
- {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/domain/cqrs/commands.py +0 -0
- {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/domain/cqrs/exceptions.py +0 -0
- {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/domain/cqrs/handlers.py +0 -0
- {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/domain/cqrs/middleware.py +0 -0
- {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/domain/cqrs/queries.py +0 -0
- {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/domain/cqrs/serializer.py +0 -0
- {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/domain/cqrs/task_queues.py +0 -0
- {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/domain/events.py +0 -0
- {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/domain/exceptions.py +0 -0
- {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/domain/repositories.py +0 -0
- {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/domain/services.py +0 -0
- {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/domain/uow.py +0 -0
- {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/infrastructure/__init__.py +0 -0
- {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/infrastructure/api/__init__.py +0 -0
- {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/infrastructure/cache/__init__.py +0 -0
- {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/infrastructure/cache/cache_backends/__init__.py +0 -0
- {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/infrastructure/cache/cache_backends/memory.py +0 -0
- {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/infrastructure/cache/cache_backends/redis.py +0 -0
- {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/infrastructure/cli.py +0 -0
- {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/infrastructure/cqrs/__init__.py +0 -0
- {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/infrastructure/cqrs/procrastinate.py +0 -0
- {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/infrastructure/cqrs/rabbitmq.py +0 -0
- {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/infrastructure/events/__init__.py +0 -0
- {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/infrastructure/events/events_backends/__init__.py +0 -0
- {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/infrastructure/events/events_backends/memory.py +0 -0
- {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/infrastructure/repositories/__init__.py +0 -0
- {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/infrastructure/repositories/decorators.py +0 -0
- {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/infrastructure/repositories/orms/__init__.py +0 -0
- {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/infrastructure/repositories/orms/beanie/__init__.py +0 -0
- {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/infrastructure/repositories/orms/beanie/utils.py +0 -0
- {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/infrastructure/repositories/orms/sqlalchemy/__init__.py +0 -0
- {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/infrastructure/repositories/orms/sqlalchemy/utils.py +0 -0
- {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/infrastructure/task_queues/__init__.py +0 -0
- {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/infrastructure/uow/decorators.py +0 -0
- {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/infrastructure/uow/helpers.py +0 -0
- {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/infrastructure/workers/__init__.py +0 -0
- {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/py.typed +0 -0
- {hexcore-2.4.0 → hexcore-3.0.0}/hexcore.egg-info/dependency_links.txt +0 -0
- {hexcore-2.4.0 → hexcore-3.0.0}/hexcore.egg-info/entry_points.txt +0 -0
- {hexcore-2.4.0 → hexcore-3.0.0}/hexcore.egg-info/top_level.txt +0 -0
- {hexcore-2.4.0 → hexcore-3.0.0}/scripts/__init__.py +0 -0
- {hexcore-2.4.0 → hexcore-3.0.0}/scripts/main.py +0 -0
- {hexcore-2.4.0 → hexcore-3.0.0}/setup.cfg +0 -0
- {hexcore-2.4.0 → hexcore-3.0.0}/tests/conftest.py +0 -0
- {hexcore-2.4.0 → hexcore-3.0.0}/tests/test_basic.py +0 -0
- {hexcore-2.4.0 → hexcore-3.0.0}/tests/test_config_loading.py +0 -0
- {hexcore-2.4.0 → hexcore-3.0.0}/tests/test_cqrs.py +0 -0
- {hexcore-2.4.0 → hexcore-3.0.0}/tests/test_domain_service_query.py +0 -0
- {hexcore-2.4.0 → hexcore-3.0.0}/tests/test_postgres_bus.py +0 -0
- {hexcore-2.4.0 → hexcore-3.0.0}/tests/test_rabbitmq_bus.py +0 -0
- {hexcore-2.4.0 → hexcore-3.0.0}/tests/test_redis_bus.py +0 -0
|
@@ -1,29 +1,46 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: hexcore
|
|
3
|
-
Version:
|
|
3
|
+
Version: 3.0.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
|
|
7
7
|
Requires-Python: >=3.12
|
|
8
8
|
Description-Content-Type: text/markdown
|
|
9
9
|
License-File: LICENSE
|
|
10
|
-
Requires-Dist:
|
|
11
|
-
Requires-Dist: alembic>=1.16.5
|
|
12
|
-
Requires-Dist: asyncpg>=0.30.0
|
|
13
|
-
Requires-Dist: beanie>=2.0.0
|
|
14
|
-
Requires-Dist: fastapi>=0.116.1
|
|
15
|
-
Requires-Dist: pika>=1.3.2
|
|
10
|
+
Requires-Dist: croniter>=2.0.0
|
|
16
11
|
Requires-Dist: pydantic>=2.11.7
|
|
17
|
-
Requires-Dist: redis>=6.4.0
|
|
18
|
-
Requires-Dist: sqlalchemy>=2.0.43
|
|
19
12
|
Requires-Dist: typer>=0.17.3
|
|
20
13
|
Requires-Dist: ruff>=0.12.11
|
|
21
|
-
Provides-Extra:
|
|
22
|
-
Requires-Dist:
|
|
14
|
+
Provides-Extra: api
|
|
15
|
+
Requires-Dist: fastapi>=0.116.1; extra == "api"
|
|
16
|
+
Provides-Extra: redis
|
|
17
|
+
Requires-Dist: redis>=6.4.0; extra == "redis"
|
|
18
|
+
Provides-Extra: mongo
|
|
19
|
+
Requires-Dist: beanie>=2.0.0; extra == "mongo"
|
|
20
|
+
Provides-Extra: sql
|
|
21
|
+
Requires-Dist: sqlalchemy>=2.0.43; extra == "sql"
|
|
22
|
+
Requires-Dist: alembic>=1.16.5; extra == "sql"
|
|
23
|
+
Requires-Dist: asyncpg>=0.30.0; extra == "sql"
|
|
24
|
+
Requires-Dist: aiosqlite>=0.21.0; extra == "sql"
|
|
23
25
|
Provides-Extra: rabbitmq
|
|
24
26
|
Requires-Dist: aio-pika>=9.4.0; extra == "rabbitmq"
|
|
27
|
+
Requires-Dist: pika>=1.3.2; extra == "rabbitmq"
|
|
28
|
+
Provides-Extra: procrastinate
|
|
29
|
+
Requires-Dist: procrastinate>=3.0.0; extra == "procrastinate"
|
|
25
30
|
Provides-Extra: celery
|
|
26
31
|
Requires-Dist: celery>=5.4.0; extra == "celery"
|
|
32
|
+
Provides-Extra: all
|
|
33
|
+
Requires-Dist: fastapi>=0.116.1; extra == "all"
|
|
34
|
+
Requires-Dist: redis>=6.4.0; extra == "all"
|
|
35
|
+
Requires-Dist: beanie>=2.0.0; extra == "all"
|
|
36
|
+
Requires-Dist: sqlalchemy>=2.0.43; extra == "all"
|
|
37
|
+
Requires-Dist: alembic>=1.16.5; extra == "all"
|
|
38
|
+
Requires-Dist: asyncpg>=0.30.0; extra == "all"
|
|
39
|
+
Requires-Dist: aiosqlite>=0.21.0; extra == "all"
|
|
40
|
+
Requires-Dist: aio-pika>=9.4.0; extra == "all"
|
|
41
|
+
Requires-Dist: pika>=1.3.2; extra == "all"
|
|
42
|
+
Requires-Dist: procrastinate>=3.0.0; extra == "all"
|
|
43
|
+
Requires-Dist: celery>=5.4.0; extra == "all"
|
|
27
44
|
Dynamic: license-file
|
|
28
45
|
|
|
29
46
|
# HexCore [](https://pepy.tech/projects/hexcore)
|
|
@@ -280,7 +297,7 @@ HexCore v2 integra de forma nativa soporte para el patrón **CQRS (Command Query
|
|
|
280
297
|
|
|
281
298
|
El sistema se basa en 3 buses principales, configurables e independientes:
|
|
282
299
|
|
|
283
|
-
1. **`AbstractCommandBus`**: Despacha inteniones de mutación (`Command`) a un único `AbstractCommandHandler`. Los commands modifican el estado del sistema
|
|
300
|
+
1. **`AbstractCommandBus`**: Despacha inteniones de mutación (`Command`) a un único `AbstractCommandHandler`. Los commands modifican el estado del sistema. La transacción la gestiona el handler (el patrón que enseñan los ejemplos de use case); si preferís que la gestione el bus, añadí `TransactionMiddleware` explícitamente con su `uow_factory`.
|
|
284
301
|
2. **`AbstractQueryBus`**: Despacha intenciones de lectura (`Query`) a un único `AbstractQueryHandler`. Retornan un resultado sin mutar el estado.
|
|
285
302
|
3. **`EventBus`**: Distribuye eventos de dominio (`DomainEvent`) a múltiples suscriptores asíncronamente (vía `subscribe`/`publish`).
|
|
286
303
|
|
|
@@ -293,8 +310,9 @@ from hexcore.application.cqrs.config import CQRSConfig, BusConfig
|
|
|
293
310
|
config = ServerConfig(
|
|
294
311
|
cqrs=CQRSConfig(
|
|
295
312
|
command_bus=BusConfig(
|
|
296
|
-
#
|
|
297
|
-
|
|
313
|
+
# Sin middlewares por defecto. Los que no necesitan configuración se
|
|
314
|
+
# pueden declarar por dotted path:
|
|
315
|
+
middlewares=["hexcore.infrastructure.cqrs.middlewares.LoggingMiddleware"]
|
|
298
316
|
),
|
|
299
317
|
# Puedes sustituir el backend en memoria por uno distribuido (Ej: Celery, Procrastinate)
|
|
300
318
|
# backend="mi_app.infrastructure.ProcrastinateCommandBus"
|
|
@@ -302,6 +320,15 @@ config = ServerConfig(
|
|
|
302
320
|
)
|
|
303
321
|
```
|
|
304
322
|
|
|
323
|
+
> **`TransactionMiddleware` no es el default.** Comitea después del handler, así que
|
|
324
|
+
> con un handler que ya gestiona su propia transacción comitearías dos veces. Y
|
|
325
|
+
> necesita un `uow_factory` construido con *tu* engine, cosa que no se puede expresar
|
|
326
|
+
> como dotted path: instancialo a mano y pasá el pipeline al bus.
|
|
327
|
+
>
|
|
328
|
+
> ```python
|
|
329
|
+
> TransactionMiddleware(uow_factory=lambda: SqlAlchemyUnitOfWork(session=session_factory()))
|
|
330
|
+
> ```
|
|
331
|
+
|
|
305
332
|
---
|
|
306
333
|
|
|
307
334
|
### Guía de Migración: De Casos de Uso Clásicos a CQRS
|
|
@@ -584,6 +611,96 @@ register_hexcore_celery_tasks(app, consumer)
|
|
|
584
611
|
|
|
585
612
|
---
|
|
586
613
|
|
|
614
|
+
## Tareas Periódicas Dinámicas (Cronjobs en Caliente)
|
|
615
|
+
|
|
616
|
+
HexCore incluye un **`DynamicScheduler`** que te permite programar tareas (`@background_task`) para que se ejecuten periódicamente. La ventaja clave es que lee la configuración desde un repositorio (como tu Base de Datos), permitiendo activar, desactivar o cambiar los horarios **sin necesidad de reiniciar tus servidores**.
|
|
617
|
+
|
|
618
|
+
### 1. Implementa tu Repositorio
|
|
619
|
+
Implementa `ICronJobRepository` para decirle al Scheduler de dónde leer la configuración (ej. usando SQLAlchemy, MongoDB o Redis):
|
|
620
|
+
|
|
621
|
+
```python
|
|
622
|
+
from hexcore.domain.cqrs.cron import ICronJobRepository, CronJobDefinition
|
|
623
|
+
from datetime import datetime
|
|
624
|
+
|
|
625
|
+
class MiCronRepository(ICronJobRepository):
|
|
626
|
+
async def get_active_jobs(self) -> list[CronJobDefinition]:
|
|
627
|
+
# SELECT * FROM cronjobs WHERE is_active = true
|
|
628
|
+
return [
|
|
629
|
+
CronJobDefinition(
|
|
630
|
+
job_id="1",
|
|
631
|
+
task_name="hexcore.process_task", # o cualquier @background_task
|
|
632
|
+
cron_expression="*/5 * * * *", # cada 5 minutos
|
|
633
|
+
payload={"task_name": "clean_db", "payload": {}}
|
|
634
|
+
)
|
|
635
|
+
]
|
|
636
|
+
|
|
637
|
+
async def update_last_run(self, job_id: str, run_time: datetime) -> None:
|
|
638
|
+
# UPDATE cronjobs SET last_run = run_time WHERE id = job_id
|
|
639
|
+
pass
|
|
640
|
+
```
|
|
641
|
+
|
|
642
|
+
### 2. Levanta el Scheduler
|
|
643
|
+
En un proceso en background de tu API o en un microservicio separado, arranca el Scheduler inyectándole tu Enqueuer favorito (Celery, Procrastinate):
|
|
644
|
+
|
|
645
|
+
```python
|
|
646
|
+
from hexcore.application.cqrs.scheduler import DynamicScheduler
|
|
647
|
+
import asyncio
|
|
648
|
+
|
|
649
|
+
async def run_scheduler():
|
|
650
|
+
repo = MiCronRepository()
|
|
651
|
+
scheduler = DynamicScheduler(
|
|
652
|
+
repository=repo,
|
|
653
|
+
enqueuer=enqueuer,
|
|
654
|
+
tick_interval_seconds=60
|
|
655
|
+
)
|
|
656
|
+
|
|
657
|
+
await scheduler.start() # Bucle infinito
|
|
658
|
+
|
|
659
|
+
# En FastAPI puedes usar lifespan para lanzarlo:
|
|
660
|
+
# asyncio.create_task(run_scheduler())
|
|
661
|
+
```
|
|
662
|
+
|
|
663
|
+
El Scheduler evaluará las expresiones (gracias a `croniter`) y delegará la carga pesada al enqueuer. ¡Tu Worker no necesita saber de horarios, solo ejecuta las tareas cuando le llegan!
|
|
664
|
+
|
|
665
|
+
### 3. Distributed Locks (Evitar ejecuciones dobles)
|
|
666
|
+
|
|
667
|
+
Si corres tu aplicación en múltiples contenedores o réplicas (ej. Kubernetes), podrías tener múltiples instancias del `DynamicScheduler` ejecutándose al mismo tiempo. Para evitar que el mismo cronjob se encole dos veces en el mismo minuto, HexCore soporta **Locks Distribuidos**.
|
|
668
|
+
|
|
669
|
+
Puedes inyectar un proveedor de locks (`ILockProvider`) usando Redis o PostgreSQL (si lo usas como tu DB). Al inyectarlo, el Scheduler bloqueará atómicamente la tarea a través de toda tu red.
|
|
670
|
+
|
|
671
|
+
#### Usando Redis
|
|
672
|
+
```python
|
|
673
|
+
from hexcore.infrastructure.cqrs.redis_lock import RedisLockProvider
|
|
674
|
+
import redis.asyncio as redis
|
|
675
|
+
|
|
676
|
+
redis_client = redis.from_url("redis://localhost:6379/0")
|
|
677
|
+
lock_provider = RedisLockProvider(redis_client)
|
|
678
|
+
|
|
679
|
+
scheduler = DynamicScheduler(
|
|
680
|
+
repository=repo,
|
|
681
|
+
enqueuer=enqueuer,
|
|
682
|
+
lock_provider=lock_provider
|
|
683
|
+
)
|
|
684
|
+
```
|
|
685
|
+
|
|
686
|
+
#### Usando PostgreSQL (asyncpg)
|
|
687
|
+
Si usas Procrastinate o bases de datos SQL y no quieres levantar Redis:
|
|
688
|
+
|
|
689
|
+
```python
|
|
690
|
+
from hexcore.infrastructure.cqrs.postgres_lock import PostgresLockProvider
|
|
691
|
+
|
|
692
|
+
lock_provider = PostgresLockProvider(my_asyncpg_pool)
|
|
693
|
+
await lock_provider.setup() # Crea la tabla de locks si no existe
|
|
694
|
+
|
|
695
|
+
scheduler = DynamicScheduler(
|
|
696
|
+
repository=repo,
|
|
697
|
+
enqueuer=enqueuer,
|
|
698
|
+
lock_provider=lock_provider
|
|
699
|
+
)
|
|
700
|
+
```
|
|
701
|
+
|
|
702
|
+
---
|
|
703
|
+
|
|
587
704
|
## Referencias
|
|
588
705
|
|
|
589
706
|
- [CONTRIBUTING.md](./CONTRIBUTING.md): Pautas de colaboración.
|
|
@@ -1,31 +1,3 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: hexcore
|
|
3
|
-
Version: 2.4.0
|
|
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
|
-
Author-email: "David Latosefki (Indroic)" <indroic@outlook.com>
|
|
6
|
-
License-Expression: MIT
|
|
7
|
-
Requires-Python: >=3.12
|
|
8
|
-
Description-Content-Type: text/markdown
|
|
9
|
-
License-File: LICENSE
|
|
10
|
-
Requires-Dist: aiosqlite>=0.21.0
|
|
11
|
-
Requires-Dist: alembic>=1.16.5
|
|
12
|
-
Requires-Dist: asyncpg>=0.30.0
|
|
13
|
-
Requires-Dist: beanie>=2.0.0
|
|
14
|
-
Requires-Dist: fastapi>=0.116.1
|
|
15
|
-
Requires-Dist: pika>=1.3.2
|
|
16
|
-
Requires-Dist: pydantic>=2.11.7
|
|
17
|
-
Requires-Dist: redis>=6.4.0
|
|
18
|
-
Requires-Dist: sqlalchemy>=2.0.43
|
|
19
|
-
Requires-Dist: typer>=0.17.3
|
|
20
|
-
Requires-Dist: ruff>=0.12.11
|
|
21
|
-
Provides-Extra: procrastinate
|
|
22
|
-
Requires-Dist: procrastinate>=3.0.0; extra == "procrastinate"
|
|
23
|
-
Provides-Extra: rabbitmq
|
|
24
|
-
Requires-Dist: aio-pika>=9.4.0; extra == "rabbitmq"
|
|
25
|
-
Provides-Extra: celery
|
|
26
|
-
Requires-Dist: celery>=5.4.0; extra == "celery"
|
|
27
|
-
Dynamic: license-file
|
|
28
|
-
|
|
29
1
|
# HexCore [](https://pepy.tech/projects/hexcore)
|
|
30
2
|
HexCore es un módulo base reutilizable para proyectos Python que implementan arquitectura hexagonal y event handling.
|
|
31
3
|
|
|
@@ -280,7 +252,7 @@ HexCore v2 integra de forma nativa soporte para el patrón **CQRS (Command Query
|
|
|
280
252
|
|
|
281
253
|
El sistema se basa en 3 buses principales, configurables e independientes:
|
|
282
254
|
|
|
283
|
-
1. **`AbstractCommandBus`**: Despacha inteniones de mutación (`Command`) a un único `AbstractCommandHandler`. Los commands modifican el estado del sistema
|
|
255
|
+
1. **`AbstractCommandBus`**: Despacha inteniones de mutación (`Command`) a un único `AbstractCommandHandler`. Los commands modifican el estado del sistema. La transacción la gestiona el handler (el patrón que enseñan los ejemplos de use case); si preferís que la gestione el bus, añadí `TransactionMiddleware` explícitamente con su `uow_factory`.
|
|
284
256
|
2. **`AbstractQueryBus`**: Despacha intenciones de lectura (`Query`) a un único `AbstractQueryHandler`. Retornan un resultado sin mutar el estado.
|
|
285
257
|
3. **`EventBus`**: Distribuye eventos de dominio (`DomainEvent`) a múltiples suscriptores asíncronamente (vía `subscribe`/`publish`).
|
|
286
258
|
|
|
@@ -293,8 +265,9 @@ from hexcore.application.cqrs.config import CQRSConfig, BusConfig
|
|
|
293
265
|
config = ServerConfig(
|
|
294
266
|
cqrs=CQRSConfig(
|
|
295
267
|
command_bus=BusConfig(
|
|
296
|
-
#
|
|
297
|
-
|
|
268
|
+
# Sin middlewares por defecto. Los que no necesitan configuración se
|
|
269
|
+
# pueden declarar por dotted path:
|
|
270
|
+
middlewares=["hexcore.infrastructure.cqrs.middlewares.LoggingMiddleware"]
|
|
298
271
|
),
|
|
299
272
|
# Puedes sustituir el backend en memoria por uno distribuido (Ej: Celery, Procrastinate)
|
|
300
273
|
# backend="mi_app.infrastructure.ProcrastinateCommandBus"
|
|
@@ -302,6 +275,15 @@ config = ServerConfig(
|
|
|
302
275
|
)
|
|
303
276
|
```
|
|
304
277
|
|
|
278
|
+
> **`TransactionMiddleware` no es el default.** Comitea después del handler, así que
|
|
279
|
+
> con un handler que ya gestiona su propia transacción comitearías dos veces. Y
|
|
280
|
+
> necesita un `uow_factory` construido con *tu* engine, cosa que no se puede expresar
|
|
281
|
+
> como dotted path: instancialo a mano y pasá el pipeline al bus.
|
|
282
|
+
>
|
|
283
|
+
> ```python
|
|
284
|
+
> TransactionMiddleware(uow_factory=lambda: SqlAlchemyUnitOfWork(session=session_factory()))
|
|
285
|
+
> ```
|
|
286
|
+
|
|
305
287
|
---
|
|
306
288
|
|
|
307
289
|
### Guía de Migración: De Casos de Uso Clásicos a CQRS
|
|
@@ -584,6 +566,96 @@ register_hexcore_celery_tasks(app, consumer)
|
|
|
584
566
|
|
|
585
567
|
---
|
|
586
568
|
|
|
569
|
+
## Tareas Periódicas Dinámicas (Cronjobs en Caliente)
|
|
570
|
+
|
|
571
|
+
HexCore incluye un **`DynamicScheduler`** que te permite programar tareas (`@background_task`) para que se ejecuten periódicamente. La ventaja clave es que lee la configuración desde un repositorio (como tu Base de Datos), permitiendo activar, desactivar o cambiar los horarios **sin necesidad de reiniciar tus servidores**.
|
|
572
|
+
|
|
573
|
+
### 1. Implementa tu Repositorio
|
|
574
|
+
Implementa `ICronJobRepository` para decirle al Scheduler de dónde leer la configuración (ej. usando SQLAlchemy, MongoDB o Redis):
|
|
575
|
+
|
|
576
|
+
```python
|
|
577
|
+
from hexcore.domain.cqrs.cron import ICronJobRepository, CronJobDefinition
|
|
578
|
+
from datetime import datetime
|
|
579
|
+
|
|
580
|
+
class MiCronRepository(ICronJobRepository):
|
|
581
|
+
async def get_active_jobs(self) -> list[CronJobDefinition]:
|
|
582
|
+
# SELECT * FROM cronjobs WHERE is_active = true
|
|
583
|
+
return [
|
|
584
|
+
CronJobDefinition(
|
|
585
|
+
job_id="1",
|
|
586
|
+
task_name="hexcore.process_task", # o cualquier @background_task
|
|
587
|
+
cron_expression="*/5 * * * *", # cada 5 minutos
|
|
588
|
+
payload={"task_name": "clean_db", "payload": {}}
|
|
589
|
+
)
|
|
590
|
+
]
|
|
591
|
+
|
|
592
|
+
async def update_last_run(self, job_id: str, run_time: datetime) -> None:
|
|
593
|
+
# UPDATE cronjobs SET last_run = run_time WHERE id = job_id
|
|
594
|
+
pass
|
|
595
|
+
```
|
|
596
|
+
|
|
597
|
+
### 2. Levanta el Scheduler
|
|
598
|
+
En un proceso en background de tu API o en un microservicio separado, arranca el Scheduler inyectándole tu Enqueuer favorito (Celery, Procrastinate):
|
|
599
|
+
|
|
600
|
+
```python
|
|
601
|
+
from hexcore.application.cqrs.scheduler import DynamicScheduler
|
|
602
|
+
import asyncio
|
|
603
|
+
|
|
604
|
+
async def run_scheduler():
|
|
605
|
+
repo = MiCronRepository()
|
|
606
|
+
scheduler = DynamicScheduler(
|
|
607
|
+
repository=repo,
|
|
608
|
+
enqueuer=enqueuer,
|
|
609
|
+
tick_interval_seconds=60
|
|
610
|
+
)
|
|
611
|
+
|
|
612
|
+
await scheduler.start() # Bucle infinito
|
|
613
|
+
|
|
614
|
+
# En FastAPI puedes usar lifespan para lanzarlo:
|
|
615
|
+
# asyncio.create_task(run_scheduler())
|
|
616
|
+
```
|
|
617
|
+
|
|
618
|
+
El Scheduler evaluará las expresiones (gracias a `croniter`) y delegará la carga pesada al enqueuer. ¡Tu Worker no necesita saber de horarios, solo ejecuta las tareas cuando le llegan!
|
|
619
|
+
|
|
620
|
+
### 3. Distributed Locks (Evitar ejecuciones dobles)
|
|
621
|
+
|
|
622
|
+
Si corres tu aplicación en múltiples contenedores o réplicas (ej. Kubernetes), podrías tener múltiples instancias del `DynamicScheduler` ejecutándose al mismo tiempo. Para evitar que el mismo cronjob se encole dos veces en el mismo minuto, HexCore soporta **Locks Distribuidos**.
|
|
623
|
+
|
|
624
|
+
Puedes inyectar un proveedor de locks (`ILockProvider`) usando Redis o PostgreSQL (si lo usas como tu DB). Al inyectarlo, el Scheduler bloqueará atómicamente la tarea a través de toda tu red.
|
|
625
|
+
|
|
626
|
+
#### Usando Redis
|
|
627
|
+
```python
|
|
628
|
+
from hexcore.infrastructure.cqrs.redis_lock import RedisLockProvider
|
|
629
|
+
import redis.asyncio as redis
|
|
630
|
+
|
|
631
|
+
redis_client = redis.from_url("redis://localhost:6379/0")
|
|
632
|
+
lock_provider = RedisLockProvider(redis_client)
|
|
633
|
+
|
|
634
|
+
scheduler = DynamicScheduler(
|
|
635
|
+
repository=repo,
|
|
636
|
+
enqueuer=enqueuer,
|
|
637
|
+
lock_provider=lock_provider
|
|
638
|
+
)
|
|
639
|
+
```
|
|
640
|
+
|
|
641
|
+
#### Usando PostgreSQL (asyncpg)
|
|
642
|
+
Si usas Procrastinate o bases de datos SQL y no quieres levantar Redis:
|
|
643
|
+
|
|
644
|
+
```python
|
|
645
|
+
from hexcore.infrastructure.cqrs.postgres_lock import PostgresLockProvider
|
|
646
|
+
|
|
647
|
+
lock_provider = PostgresLockProvider(my_asyncpg_pool)
|
|
648
|
+
await lock_provider.setup() # Crea la tabla de locks si no existe
|
|
649
|
+
|
|
650
|
+
scheduler = DynamicScheduler(
|
|
651
|
+
repository=repo,
|
|
652
|
+
enqueuer=enqueuer,
|
|
653
|
+
lock_provider=lock_provider
|
|
654
|
+
)
|
|
655
|
+
```
|
|
656
|
+
|
|
657
|
+
---
|
|
658
|
+
|
|
587
659
|
## Referencias
|
|
588
660
|
|
|
589
661
|
- [CONTRIBUTING.md](./CONTRIBUTING.md): Pautas de colaboración.
|
|
@@ -9,6 +9,7 @@ from .pipeline import MiddlewarePipeline
|
|
|
9
9
|
from .in_memory_buses import InMemoryCommandBus, InMemoryQueryBus, InMemoryEventBus
|
|
10
10
|
from .adapters import UseCaseCommandHandler
|
|
11
11
|
from .factory import CQRSFactory
|
|
12
|
+
from .scheduler import DynamicScheduler
|
|
12
13
|
|
|
13
14
|
__all__ = [
|
|
14
15
|
"HandlerRegistry",
|
|
@@ -21,4 +22,5 @@ __all__ = [
|
|
|
21
22
|
"InMemoryEventBus",
|
|
22
23
|
"UseCaseCommandHandler",
|
|
23
24
|
"CQRSFactory",
|
|
25
|
+
"DynamicScheduler",
|
|
24
26
|
]
|
|
@@ -55,13 +55,12 @@ class CQRSConfig(BaseModel):
|
|
|
55
55
|
"""
|
|
56
56
|
|
|
57
57
|
enabled: bool = True
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
)
|
|
58
|
+
# Sin middlewares por defecto (P0-6). `TransactionMiddleware` *era* el default,
|
|
59
|
+
# pero adivinaba la sesión con el session factory interno de HexCore en vez del
|
|
60
|
+
# engine de la aplicación, y comiteaba por segunda vez sobre los handlers que ya
|
|
61
|
+
# gestionan su transacción. Si lo querés, declaralo explícitamente con su
|
|
62
|
+
# `uow_factory`.
|
|
63
|
+
command_bus: BusConfig = Field(default_factory=BusConfig)
|
|
65
64
|
query_bus: BusConfig = Field(default_factory=BusConfig)
|
|
66
65
|
event_bus: BusConfig = Field(default_factory=BusConfig)
|
|
67
66
|
serializer: t.Optional[str] = None # None = PydanticSerializer por defecto
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Factory para construir buses CQRS a partir de la configuración.
|
|
3
|
+
Resuelve backends, serializers y middlewares por dotted path.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import importlib
|
|
8
|
+
import typing as t
|
|
9
|
+
|
|
10
|
+
from hexcore.domain.cqrs.buses import ICommandBus, IQueryBus, IEventBus
|
|
11
|
+
from hexcore.domain.cqrs.middleware import IMiddleware
|
|
12
|
+
from hexcore.domain.cqrs.serializer import ISerializer
|
|
13
|
+
from hexcore.domain.cqrs.task_queues import ITaskEnqueuer
|
|
14
|
+
|
|
15
|
+
from .config import CQRSConfig, BusConfig
|
|
16
|
+
from .registry import HandlerRegistry
|
|
17
|
+
from .pipeline import MiddlewarePipeline
|
|
18
|
+
from .in_memory_buses import InMemoryCommandBus, InMemoryQueryBus, InMemoryEventBus
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _import_class(dotted_path: str) -> type:
|
|
22
|
+
"""Importa una clase a partir de su dotted path."""
|
|
23
|
+
module_path, class_name = dotted_path.rsplit(".", 1)
|
|
24
|
+
module = importlib.import_module(module_path)
|
|
25
|
+
return getattr(module, class_name)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _build_middlewares(dotted_paths: list[str]) -> list[IMiddleware]:
|
|
29
|
+
"""
|
|
30
|
+
Instancia middlewares a partir de sus dotted paths.
|
|
31
|
+
|
|
32
|
+
Sólo sirve para middlewares construibles sin argumentos. Los que necesitan
|
|
33
|
+
configuración (p. ej. `TransactionMiddleware`, que requiere un `uow_factory`
|
|
34
|
+
atado al engine de la aplicación) hay que instanciarlos a mano y pasar el
|
|
35
|
+
`MiddlewarePipeline` al bus.
|
|
36
|
+
"""
|
|
37
|
+
middlewares: list[IMiddleware] = []
|
|
38
|
+
for path in dotted_paths:
|
|
39
|
+
cls = _import_class(path)
|
|
40
|
+
try:
|
|
41
|
+
middlewares.append(cls())
|
|
42
|
+
except TypeError as exc:
|
|
43
|
+
raise TypeError(
|
|
44
|
+
f"El middleware '{path}' no se puede construir sin argumentos: {exc}. "
|
|
45
|
+
"Declararlo por dotted path sólo funciona para middlewares sin "
|
|
46
|
+
"configuración; instancialo a mano y pasá el MiddlewarePipeline al bus."
|
|
47
|
+
) from exc
|
|
48
|
+
except ValueError as exc:
|
|
49
|
+
raise ValueError(
|
|
50
|
+
f"El middleware '{path}' rechazó su construcción por defecto: {exc} "
|
|
51
|
+
"Declararlo por dotted path sólo funciona para middlewares sin "
|
|
52
|
+
"configuración; instancialo a mano y pasá el MiddlewarePipeline al bus."
|
|
53
|
+
) from exc
|
|
54
|
+
return middlewares
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _build_pipeline(bus_config: BusConfig) -> MiddlewarePipeline:
|
|
58
|
+
"""Construye un MiddlewarePipeline desde la configuración de un bus."""
|
|
59
|
+
middlewares = _build_middlewares(bus_config.middlewares)
|
|
60
|
+
return MiddlewarePipeline(middlewares)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class CQRSFactory:
|
|
64
|
+
"""
|
|
65
|
+
Factory que construye las instancias de buses CQRS.
|
|
66
|
+
|
|
67
|
+
Usa CQRSConfig para determinar qué implementación de bus instanciar,
|
|
68
|
+
qué middlewares configurar y qué serializer utilizar.
|
|
69
|
+
|
|
70
|
+
Uso::
|
|
71
|
+
|
|
72
|
+
config = CQRSConfig(...)
|
|
73
|
+
registry = HandlerRegistry()
|
|
74
|
+
# ...registrar handlers...
|
|
75
|
+
|
|
76
|
+
factory = CQRSFactory(config, registry, enqueuer=ProcrastinateEnqueuer(app))
|
|
77
|
+
command_bus = factory.create_command_bus()
|
|
78
|
+
query_bus = factory.create_query_bus()
|
|
79
|
+
event_bus = factory.create_event_bus()
|
|
80
|
+
|
|
81
|
+
El `enqueuer` es lo que habilita el Smart Routing: sin él, los buses in-memory
|
|
82
|
+
no pueden enrutar un `@background_command` y la factory lo dice **al construir**,
|
|
83
|
+
no en el primer dispatch.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
def __init__(
|
|
87
|
+
self,
|
|
88
|
+
config: CQRSConfig,
|
|
89
|
+
registry: HandlerRegistry,
|
|
90
|
+
enqueuer: ITaskEnqueuer | None = None,
|
|
91
|
+
) -> None:
|
|
92
|
+
self._config = config
|
|
93
|
+
self._registry = registry
|
|
94
|
+
self._enqueuer = enqueuer
|
|
95
|
+
self._serializer: ISerializer | None = None
|
|
96
|
+
|
|
97
|
+
def create_serializer(self) -> ISerializer:
|
|
98
|
+
"""
|
|
99
|
+
Crea el serializer configurado (PydanticSerializer por defecto).
|
|
100
|
+
|
|
101
|
+
La instancia se cachea: los buses y el consumer tienen que compartir el
|
|
102
|
+
mismo serializer para que el round-trip por la cola sea coherente.
|
|
103
|
+
"""
|
|
104
|
+
if self._serializer is not None:
|
|
105
|
+
return self._serializer
|
|
106
|
+
|
|
107
|
+
if self._config.serializer:
|
|
108
|
+
cls = _import_class(self._config.serializer)
|
|
109
|
+
self._serializer = t.cast(ISerializer, cls())
|
|
110
|
+
else:
|
|
111
|
+
from hexcore.infrastructure.cqrs.pydantic_serializer import PydanticSerializer
|
|
112
|
+
|
|
113
|
+
self._serializer = PydanticSerializer()
|
|
114
|
+
return self._serializer
|
|
115
|
+
|
|
116
|
+
def create_command_bus(self, **extra_kwargs: t.Any) -> ICommandBus:
|
|
117
|
+
"""
|
|
118
|
+
Crea el CommandBus configurado.
|
|
119
|
+
Si no hay backend explícito, retorna InMemoryCommandBus con el enqueuer y el
|
|
120
|
+
serializer necesarios para el Smart Routing.
|
|
121
|
+
"""
|
|
122
|
+
bus_config = self._config.command_bus
|
|
123
|
+
pipeline = _build_pipeline(bus_config)
|
|
124
|
+
|
|
125
|
+
if bus_config.backend is None:
|
|
126
|
+
self._assert_enqueuer_for_background_commands()
|
|
127
|
+
return InMemoryCommandBus(
|
|
128
|
+
registry=self._registry,
|
|
129
|
+
pipeline=pipeline,
|
|
130
|
+
enqueuer=self._enqueuer,
|
|
131
|
+
serializer=self.create_serializer(),
|
|
132
|
+
**extra_kwargs,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
# Backend personalizado (ej. ProcrastinateCommandBus)
|
|
136
|
+
cls = _import_class(bus_config.backend)
|
|
137
|
+
return cls(
|
|
138
|
+
registry=self._registry,
|
|
139
|
+
serializer=self.create_serializer(),
|
|
140
|
+
pipeline=pipeline,
|
|
141
|
+
**bus_config.options,
|
|
142
|
+
**extra_kwargs,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
def create_query_bus(self) -> IQueryBus:
|
|
146
|
+
"""
|
|
147
|
+
Crea el QueryBus configurado.
|
|
148
|
+
Nota: Las queries siempre son síncronas en CQRS puro.
|
|
149
|
+
"""
|
|
150
|
+
bus_config = self._config.query_bus
|
|
151
|
+
pipeline = _build_pipeline(bus_config)
|
|
152
|
+
|
|
153
|
+
if bus_config.backend is None:
|
|
154
|
+
return InMemoryQueryBus(
|
|
155
|
+
registry=self._registry,
|
|
156
|
+
pipeline=pipeline,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
cls = _import_class(bus_config.backend)
|
|
160
|
+
return cls(
|
|
161
|
+
registry=self._registry,
|
|
162
|
+
pipeline=pipeline,
|
|
163
|
+
**bus_config.options,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
def create_event_bus(self, **extra_kwargs: t.Any) -> IEventBus:
|
|
167
|
+
"""
|
|
168
|
+
Crea el EventBus configurado.
|
|
169
|
+
|
|
170
|
+
Al bus in-memory se le pasan enqueuer y serializer para que los suscriptores
|
|
171
|
+
marcados con `@background_handler` se puedan enrutar.
|
|
172
|
+
"""
|
|
173
|
+
bus_config = self._config.event_bus
|
|
174
|
+
pipeline = _build_pipeline(bus_config)
|
|
175
|
+
|
|
176
|
+
if bus_config.backend is None:
|
|
177
|
+
return InMemoryEventBus(
|
|
178
|
+
pipeline=pipeline,
|
|
179
|
+
enqueuer=self._enqueuer,
|
|
180
|
+
serializer=self.create_serializer(),
|
|
181
|
+
**extra_kwargs,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
cls = _import_class(bus_config.backend)
|
|
185
|
+
return cls(
|
|
186
|
+
pipeline=pipeline,
|
|
187
|
+
**bus_config.options,
|
|
188
|
+
**extra_kwargs,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
# ── Validación ────────────────────────────────────────────────────────────
|
|
192
|
+
|
|
193
|
+
def _assert_enqueuer_for_background_commands(self) -> None:
|
|
194
|
+
"""
|
|
195
|
+
Falla al construir si hay commands de background registrados y no hay
|
|
196
|
+
enqueuer. El error en el primer dispatch llega demasiado tarde: para
|
|
197
|
+
entonces la petición del usuario ya está en vuelo.
|
|
198
|
+
"""
|
|
199
|
+
if self._enqueuer is not None:
|
|
200
|
+
return
|
|
201
|
+
|
|
202
|
+
background = [
|
|
203
|
+
command_type.__name__
|
|
204
|
+
for command_type in self._registry.registered_commands
|
|
205
|
+
if getattr(command_type, "__cqrs_background__", False)
|
|
206
|
+
]
|
|
207
|
+
if not background:
|
|
208
|
+
return
|
|
209
|
+
|
|
210
|
+
raise RuntimeError(
|
|
211
|
+
"CQRSFactory no tiene 'enqueuer', pero hay commands decorados con "
|
|
212
|
+
f"@background_command: {', '.join(sorted(background))}. "
|
|
213
|
+
"Pasá un ITaskEnqueuer al construir la factory, p. ej. "
|
|
214
|
+
"CQRSFactory(config, registry, enqueuer=ProcrastinateEnqueuer(app))."
|
|
215
|
+
)
|
|
@@ -8,6 +8,7 @@ import logging
|
|
|
8
8
|
|
|
9
9
|
from hexcore.domain.cqrs.buses import ICommandBus, IQueryBus, IEventBus
|
|
10
10
|
from hexcore.domain.cqrs.commands import Command
|
|
11
|
+
from hexcore.domain.cqrs.context import is_worker_execution, local_execution
|
|
11
12
|
from hexcore.domain.cqrs.queries import Query
|
|
12
13
|
from hexcore.domain.events import DomainEvent
|
|
13
14
|
from hexcore.domain.cqrs.task_queues import ITaskEnqueuer
|
|
@@ -28,6 +29,10 @@ class InMemoryCommandBus(ICommandBus):
|
|
|
28
29
|
Si el comando está decorado con `@background_command` y se provee un `enqueuer`,
|
|
29
30
|
el comando es automáticamente encolado para su ejecución en segundo plano
|
|
30
31
|
sin bloquear el proceso actual (retornando None).
|
|
32
|
+
|
|
33
|
+
Cuando el mensaje viene de un worker (``IN_WORKER`` activo, lo pone el
|
|
34
|
+
``CQRSConsumer``), el bus lo ejecuta **localmente** en vez de reencolarlo.
|
|
35
|
+
Esto permite usar el mismo bus en el proceso web y en el worker.
|
|
31
36
|
"""
|
|
32
37
|
|
|
33
38
|
def __init__(
|
|
@@ -44,17 +49,19 @@ class InMemoryCommandBus(ICommandBus):
|
|
|
44
49
|
|
|
45
50
|
async def dispatch(self, command: Command) -> t.Any:
|
|
46
51
|
cmd_type = type(command)
|
|
47
|
-
|
|
52
|
+
|
|
48
53
|
# 1. Smart Routing: ¿Debe irse a background?
|
|
54
|
+
# Si ya estamos dentro de un worker, el mensaje viene de la cola: hay que
|
|
55
|
+
# ejecutarlo, no volver a encolarlo.
|
|
49
56
|
is_background = getattr(cmd_type, "__cqrs_background__", False)
|
|
50
|
-
|
|
51
|
-
if is_background:
|
|
57
|
+
|
|
58
|
+
if is_background and not is_worker_execution():
|
|
52
59
|
if not self._enqueuer or not self._serializer:
|
|
53
60
|
raise RuntimeError(
|
|
54
61
|
f"El comando '{cmd_type.__name__}' requiere ejecución en background, "
|
|
55
62
|
"pero el InMemoryCommandBus no tiene configurado un 'enqueuer' o 'serializer'."
|
|
56
63
|
)
|
|
57
|
-
|
|
64
|
+
|
|
58
65
|
async def background_dispatcher(cmd: Command) -> None:
|
|
59
66
|
queue_name = getattr(cmd_type, "__cqrs_queue__", "default")
|
|
60
67
|
payload = self._serializer.serialize(cmd) # type: ignore
|
|
@@ -70,7 +77,10 @@ class InMemoryCommandBus(ICommandBus):
|
|
|
70
77
|
async def final_handler(cmd: t.Any) -> t.Any:
|
|
71
78
|
return await handler.handle(cmd)
|
|
72
79
|
|
|
73
|
-
|
|
80
|
+
# `local_execution` consume el flag de worker: si el handler despacha otro
|
|
81
|
+
# `@background_command`, ese sí debe encolarse.
|
|
82
|
+
with local_execution():
|
|
83
|
+
return await self._pipeline.execute(command, final_handler)
|
|
74
84
|
|
|
75
85
|
|
|
76
86
|
class InMemoryQueryBus(IQueryBus):
|
|
@@ -129,9 +139,15 @@ class InMemoryEventBus(IEventBus):
|
|
|
129
139
|
|
|
130
140
|
async def publish(self, event: DomainEvent) -> None:
|
|
131
141
|
handlers = self._handlers.get(type(event), [])
|
|
142
|
+
# Si el evento viene de un worker, sus handlers de background se ejecutan
|
|
143
|
+
# aquí; reencolarlos sería un bucle infinito.
|
|
144
|
+
in_worker = is_worker_execution()
|
|
132
145
|
|
|
133
146
|
for event_handler in handlers:
|
|
134
|
-
is_background =
|
|
147
|
+
is_background = (
|
|
148
|
+
getattr(event_handler, "__cqrs_background_handler__", False)
|
|
149
|
+
and not in_worker
|
|
150
|
+
)
|
|
135
151
|
|
|
136
152
|
if is_background:
|
|
137
153
|
# Enrutamiento hacia background
|
|
@@ -161,4 +177,5 @@ class InMemoryEventBus(IEventBus):
|
|
|
161
177
|
) -> None:
|
|
162
178
|
await _h(evt)
|
|
163
179
|
|
|
164
|
-
|
|
180
|
+
with local_execution():
|
|
181
|
+
await self._pipeline.execute(event, final_handler)
|