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.
Files changed (134) hide show
  1. {hexcore-2.4.0 → hexcore-3.0.0}/PKG-INFO +131 -14
  2. hexcore-2.4.0/hexcore.egg-info/PKG-INFO → hexcore-3.0.0/README.md +103 -31
  3. {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/application/cqrs/__init__.py +2 -0
  4. {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/application/cqrs/config.py +6 -7
  5. hexcore-3.0.0/hexcore/application/cqrs/factory.py +215 -0
  6. {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/application/cqrs/in_memory_buses.py +24 -7
  7. hexcore-3.0.0/hexcore/application/cqrs/registry.py +185 -0
  8. hexcore-3.0.0/hexcore/application/cqrs/scheduler.py +242 -0
  9. {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/domain/cqrs/__init__.py +14 -0
  10. hexcore-3.0.0/hexcore/domain/cqrs/context.py +61 -0
  11. hexcore-3.0.0/hexcore/domain/cqrs/cron.py +69 -0
  12. {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/domain/cqrs/decorators.py +27 -17
  13. hexcore-3.0.0/hexcore/domain/cqrs/resolution.py +87 -0
  14. {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/infrastructure/api/utils.py +21 -0
  15. {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/infrastructure/cqrs/middlewares.py +29 -20
  16. {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/infrastructure/cqrs/postgres_bus.py +11 -3
  17. hexcore-3.0.0/hexcore/infrastructure/cqrs/postgres_lock.py +190 -0
  18. {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/infrastructure/cqrs/pydantic_serializer.py +6 -6
  19. {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/infrastructure/cqrs/redis_bus.py +12 -4
  20. hexcore-3.0.0/hexcore/infrastructure/cqrs/redis_lock.py +110 -0
  21. {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/infrastructure/repositories/base.py +6 -3
  22. hexcore-3.0.0/hexcore/infrastructure/repositories/implementations.py +198 -0
  23. hexcore-3.0.0/hexcore/infrastructure/repositories/orms/sqlalchemy/session.py +208 -0
  24. {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/infrastructure/repositories/utils.py +15 -6
  25. {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/infrastructure/task_queues/celery_adapter.py +51 -6
  26. hexcore-3.0.0/hexcore/infrastructure/task_queues/procrastinate_adapter.py +120 -0
  27. hexcore-3.0.0/hexcore/infrastructure/uow/__init__.py +209 -0
  28. hexcore-3.0.0/hexcore/infrastructure/uow/scopes.py +93 -0
  29. {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/infrastructure/workers/consumer.py +67 -22
  30. {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/infrastructure/workers/rabbitmq_worker.py +10 -6
  31. {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/types.py +4 -1
  32. hexcore-2.4.0/README.md → hexcore-3.0.0/hexcore.egg-info/PKG-INFO +148 -3
  33. {hexcore-2.4.0 → hexcore-3.0.0}/hexcore.egg-info/SOURCES.txt +21 -1
  34. {hexcore-2.4.0 → hexcore-3.0.0}/hexcore.egg-info/requires.txt +30 -8
  35. {hexcore-2.4.0 → hexcore-3.0.0}/pyproject.toml +26 -10
  36. {hexcore-2.4.0 → hexcore-3.0.0}/tests/test_beanie_query_utils.py +2 -0
  37. hexcore-3.0.0/tests/test_consumer_optional_event_bus.py +68 -0
  38. hexcore-3.0.0/tests/test_cqrs_factory.py +141 -0
  39. hexcore-3.0.0/tests/test_cqrs_middlewares.py +61 -0
  40. hexcore-3.0.0/tests/test_dotted_resolution.py +138 -0
  41. hexcore-3.0.0/tests/test_handler_registry.py +199 -0
  42. {hexcore-2.4.0 → hexcore-3.0.0}/tests/test_infrastructure_query_path.py +3 -0
  43. hexcore-3.0.0/tests/test_lock_error_policy.py +132 -0
  44. hexcore-3.0.0/tests/test_optional_dependencies.py +50 -0
  45. hexcore-3.0.0/tests/test_postgres_lock.py +152 -0
  46. {hexcore-2.4.0 → hexcore-3.0.0}/tests/test_query_field_validation.py +2 -0
  47. hexcore-3.0.0/tests/test_redis_lock.py +69 -0
  48. {hexcore-2.4.0 → hexcore-3.0.0}/tests/test_repositories_utils.py +2 -0
  49. hexcore-3.0.0/tests/test_scheduler.py +117 -0
  50. hexcore-3.0.0/tests/test_scheduler_catchup.py +361 -0
  51. {hexcore-2.4.0 → hexcore-3.0.0}/tests/test_smart_routing.py +33 -23
  52. hexcore-3.0.0/tests/test_sql_session_layer.py +338 -0
  53. hexcore-3.0.0/tests/test_task_queues_adapters.py +162 -0
  54. {hexcore-2.4.0 → hexcore-3.0.0}/tests/test_uow_session_regression.py +2 -0
  55. {hexcore-2.4.0 → hexcore-3.0.0}/tests/test_use_cases_query.py +2 -0
  56. hexcore-3.0.0/tests/test_worker_bus_integration.py +271 -0
  57. hexcore-2.4.0/hexcore/application/cqrs/factory.py +0 -131
  58. hexcore-2.4.0/hexcore/application/cqrs/registry.py +0 -115
  59. hexcore-2.4.0/hexcore/infrastructure/repositories/implementations.py +0 -212
  60. hexcore-2.4.0/hexcore/infrastructure/repositories/orms/sqlalchemy/session.py +0 -66
  61. hexcore-2.4.0/hexcore/infrastructure/task_queues/procrastinate_adapter.py +0 -61
  62. hexcore-2.4.0/hexcore/infrastructure/uow/__init__.py +0 -181
  63. hexcore-2.4.0/tests/test_task_queues_adapters.py +0 -82
  64. {hexcore-2.4.0 → hexcore-3.0.0}/LICENSE +0 -0
  65. {hexcore-2.4.0 → hexcore-3.0.0}/MANIFEST.in +0 -0
  66. {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/__init__.py +0 -0
  67. {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/__main__.py +0 -0
  68. {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/application/__init__.py +0 -0
  69. {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/application/cqrs/adapters.py +0 -0
  70. {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/application/cqrs/pipeline.py +0 -0
  71. {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/application/dtos/__init__.py +0 -0
  72. {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/application/dtos/base.py +0 -0
  73. {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/application/dtos/query.py +0 -0
  74. {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/application/use_cases/__init__.py +0 -0
  75. {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/application/use_cases/base.py +0 -0
  76. {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/application/use_cases/query.py +0 -0
  77. {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/config.py +0 -0
  78. {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/domain/__init__.py +0 -0
  79. {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/domain/auth/__init__.py +0 -0
  80. {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/domain/auth/permissions.py +0 -0
  81. {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/domain/auth/value_objects.py +0 -0
  82. {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/domain/base.py +0 -0
  83. {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/domain/cqrs/buses.py +0 -0
  84. {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/domain/cqrs/commands.py +0 -0
  85. {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/domain/cqrs/exceptions.py +0 -0
  86. {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/domain/cqrs/handlers.py +0 -0
  87. {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/domain/cqrs/middleware.py +0 -0
  88. {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/domain/cqrs/queries.py +0 -0
  89. {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/domain/cqrs/serializer.py +0 -0
  90. {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/domain/cqrs/task_queues.py +0 -0
  91. {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/domain/events.py +0 -0
  92. {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/domain/exceptions.py +0 -0
  93. {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/domain/repositories.py +0 -0
  94. {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/domain/services.py +0 -0
  95. {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/domain/uow.py +0 -0
  96. {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/infrastructure/__init__.py +0 -0
  97. {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/infrastructure/api/__init__.py +0 -0
  98. {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/infrastructure/cache/__init__.py +0 -0
  99. {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/infrastructure/cache/cache_backends/__init__.py +0 -0
  100. {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/infrastructure/cache/cache_backends/memory.py +0 -0
  101. {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/infrastructure/cache/cache_backends/redis.py +0 -0
  102. {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/infrastructure/cli.py +0 -0
  103. {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/infrastructure/cqrs/__init__.py +0 -0
  104. {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/infrastructure/cqrs/procrastinate.py +0 -0
  105. {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/infrastructure/cqrs/rabbitmq.py +0 -0
  106. {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/infrastructure/events/__init__.py +0 -0
  107. {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/infrastructure/events/events_backends/__init__.py +0 -0
  108. {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/infrastructure/events/events_backends/memory.py +0 -0
  109. {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/infrastructure/repositories/__init__.py +0 -0
  110. {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/infrastructure/repositories/decorators.py +0 -0
  111. {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/infrastructure/repositories/orms/__init__.py +0 -0
  112. {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/infrastructure/repositories/orms/beanie/__init__.py +0 -0
  113. {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/infrastructure/repositories/orms/beanie/utils.py +0 -0
  114. {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/infrastructure/repositories/orms/sqlalchemy/__init__.py +0 -0
  115. {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/infrastructure/repositories/orms/sqlalchemy/utils.py +0 -0
  116. {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/infrastructure/task_queues/__init__.py +0 -0
  117. {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/infrastructure/uow/decorators.py +0 -0
  118. {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/infrastructure/uow/helpers.py +0 -0
  119. {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/infrastructure/workers/__init__.py +0 -0
  120. {hexcore-2.4.0 → hexcore-3.0.0}/hexcore/py.typed +0 -0
  121. {hexcore-2.4.0 → hexcore-3.0.0}/hexcore.egg-info/dependency_links.txt +0 -0
  122. {hexcore-2.4.0 → hexcore-3.0.0}/hexcore.egg-info/entry_points.txt +0 -0
  123. {hexcore-2.4.0 → hexcore-3.0.0}/hexcore.egg-info/top_level.txt +0 -0
  124. {hexcore-2.4.0 → hexcore-3.0.0}/scripts/__init__.py +0 -0
  125. {hexcore-2.4.0 → hexcore-3.0.0}/scripts/main.py +0 -0
  126. {hexcore-2.4.0 → hexcore-3.0.0}/setup.cfg +0 -0
  127. {hexcore-2.4.0 → hexcore-3.0.0}/tests/conftest.py +0 -0
  128. {hexcore-2.4.0 → hexcore-3.0.0}/tests/test_basic.py +0 -0
  129. {hexcore-2.4.0 → hexcore-3.0.0}/tests/test_config_loading.py +0 -0
  130. {hexcore-2.4.0 → hexcore-3.0.0}/tests/test_cqrs.py +0 -0
  131. {hexcore-2.4.0 → hexcore-3.0.0}/tests/test_domain_service_query.py +0 -0
  132. {hexcore-2.4.0 → hexcore-3.0.0}/tests/test_postgres_bus.py +0 -0
  133. {hexcore-2.4.0 → hexcore-3.0.0}/tests/test_rabbitmq_bus.py +0 -0
  134. {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: 2.4.0
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: 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
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: procrastinate
22
- Requires-Dist: procrastinate>=3.0.0; extra == "procrastinate"
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 [![PyPI Downloads](https://static.pepy.tech/personalized-badge/hexcore?period=total&units=INTERNATIONAL_SYSTEM&left_color=BLACK&right_color=GREEN&left_text=downloads)](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 y se ejecutan (por defecto) dentro de una transacción de base de datos (Unit of Work).
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
- # Por defecto incluye TransactionMiddleware
297
- middlewares=["hexcore.infrastructure.cqrs.middlewares.TransactionMiddleware"]
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 [![PyPI Downloads](https://static.pepy.tech/personalized-badge/hexcore?period=total&units=INTERNATIONAL_SYSTEM&left_color=BLACK&right_color=GREEN&left_text=downloads)](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 y se ejecutan (por defecto) dentro de una transacción de base de datos (Unit of Work).
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
- # Por defecto incluye TransactionMiddleware
297
- middlewares=["hexcore.infrastructure.cqrs.middlewares.TransactionMiddleware"]
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
- command_bus: BusConfig = Field(
59
- default_factory=lambda: BusConfig(
60
- middlewares=[
61
- "hexcore.infrastructure.cqrs.middlewares.TransactionMiddleware"
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
- return await self._pipeline.execute(command, final_handler)
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 = getattr(event_handler, "__cqrs_background_handler__", False)
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
- await self._pipeline.execute(event, final_handler)
180
+ with local_execution():
181
+ await self._pipeline.execute(event, final_handler)