hexcore 3.0.0__tar.gz → 5.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 (166) hide show
  1. {hexcore-3.0.0/hexcore.egg-info → hexcore-5.0.0}/PKG-INFO +155 -64
  2. {hexcore-3.0.0 → hexcore-5.0.0}/README.md +154 -63
  3. {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/application/cqrs/__init__.py +3 -3
  4. {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/application/cqrs/config.py +13 -12
  5. {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/application/cqrs/in_memory_buses.py +7 -3
  6. hexcore-5.0.0/hexcore/application/dtos/cursor.py +110 -0
  7. hexcore-5.0.0/hexcore/application/dtos/errors.py +30 -0
  8. {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/config.py +5 -0
  9. hexcore-5.0.0/hexcore/cqrs.py +144 -0
  10. {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/domain/services.py +12 -2
  11. hexcore-5.0.0/hexcore/fastapi.py +122 -0
  12. hexcore-5.0.0/hexcore/infrastructure/api/__init__.py +76 -0
  13. hexcore-5.0.0/hexcore/infrastructure/api/app.py +131 -0
  14. hexcore-5.0.0/hexcore/infrastructure/api/cqrs.py +195 -0
  15. hexcore-5.0.0/hexcore/infrastructure/api/exception_handlers.py +131 -0
  16. hexcore-5.0.0/hexcore/infrastructure/api/health.py +255 -0
  17. hexcore-5.0.0/hexcore/infrastructure/api/lifespan.py +340 -0
  18. hexcore-5.0.0/hexcore/infrastructure/api/middlewares.py +142 -0
  19. hexcore-5.0.0/hexcore/infrastructure/api/rate_limit.py +165 -0
  20. hexcore-5.0.0/hexcore/infrastructure/api/routing.py +87 -0
  21. hexcore-5.0.0/hexcore/infrastructure/api/streaming.py +293 -0
  22. hexcore-5.0.0/hexcore/infrastructure/api/utils.py +346 -0
  23. {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/infrastructure/cli.py +15 -15
  24. hexcore-5.0.0/hexcore/infrastructure/cqrs/__init__.py +29 -0
  25. hexcore-5.0.0/hexcore/infrastructure/cqrs/cron_sql.py +340 -0
  26. {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/infrastructure/cqrs/middlewares.py +44 -1
  27. {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/infrastructure/repositories/implementations.py +24 -0
  28. {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/infrastructure/repositories/orms/beanie/utils.py +3 -1
  29. {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/infrastructure/repositories/orms/sqlalchemy/utils.py +140 -1
  30. {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/infrastructure/task_queues/celery_adapter.py +103 -5
  31. hexcore-5.0.0/hexcore/infrastructure/workers/__init__.py +19 -0
  32. hexcore-5.0.0/hexcore/infrastructure/workers/runner.py +349 -0
  33. hexcore-5.0.0/hexcore/sql.py +95 -0
  34. hexcore-5.0.0/hexcore/testing/__init__.py +27 -0
  35. hexcore-5.0.0/hexcore/testing/fakes.py +175 -0
  36. hexcore-5.0.0/hexcore/testing/fixtures.py +105 -0
  37. hexcore-5.0.0/hexcore/testing/helpers.py +147 -0
  38. {hexcore-3.0.0 → hexcore-5.0.0/hexcore.egg-info}/PKG-INFO +155 -64
  39. {hexcore-3.0.0 → hexcore-5.0.0}/hexcore.egg-info/SOURCES.txt +35 -1
  40. {hexcore-3.0.0 → hexcore-5.0.0}/pyproject.toml +4 -1
  41. hexcore-5.0.0/tests/test_api_app_and_lifespan.py +428 -0
  42. hexcore-5.0.0/tests/test_api_cqrs_providers.py +197 -0
  43. hexcore-5.0.0/tests/test_api_exception_handlers.py +151 -0
  44. hexcore-5.0.0/tests/test_api_health.py +220 -0
  45. hexcore-5.0.0/tests/test_api_middlewares_and_routing.py +308 -0
  46. hexcore-5.0.0/tests/test_api_rate_limit.py +228 -0
  47. hexcore-5.0.0/tests/test_api_streaming.py +367 -0
  48. hexcore-5.0.0/tests/test_celery_event_loop.py +215 -0
  49. hexcore-5.0.0/tests/test_cqrs_middlewares.py +149 -0
  50. hexcore-5.0.0/tests/test_cron_sql.py +351 -0
  51. hexcore-5.0.0/tests/test_documentation_examples.py +398 -0
  52. hexcore-5.0.0/tests/test_facades.py +180 -0
  53. {hexcore-3.0.0 → hexcore-5.0.0}/tests/test_optional_dependencies.py +7 -0
  54. hexcore-5.0.0/tests/test_query_cursor_and_endpoint.py +504 -0
  55. hexcore-5.0.0/tests/test_testing_utilities.py +330 -0
  56. {hexcore-3.0.0 → hexcore-5.0.0}/tests/test_use_cases_query.py +3 -1
  57. {hexcore-3.0.0 → hexcore-5.0.0}/tests/test_worker_bus_integration.py +38 -0
  58. hexcore-5.0.0/tests/test_worker_runner.py +363 -0
  59. hexcore-3.0.0/hexcore/infrastructure/api/__init__.py +0 -3
  60. hexcore-3.0.0/hexcore/infrastructure/api/utils.py +0 -195
  61. hexcore-3.0.0/hexcore/infrastructure/cqrs/__init__.py +0 -24
  62. hexcore-3.0.0/hexcore/infrastructure/workers/__init__.py +0 -1
  63. hexcore-3.0.0/tests/test_cqrs_middlewares.py +0 -61
  64. {hexcore-3.0.0 → hexcore-5.0.0}/LICENSE +0 -0
  65. {hexcore-3.0.0 → hexcore-5.0.0}/MANIFEST.in +0 -0
  66. {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/__init__.py +0 -0
  67. {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/__main__.py +0 -0
  68. {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/application/__init__.py +0 -0
  69. {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/application/cqrs/adapters.py +0 -0
  70. {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/application/cqrs/factory.py +0 -0
  71. {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/application/cqrs/pipeline.py +0 -0
  72. {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/application/cqrs/registry.py +0 -0
  73. {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/application/cqrs/scheduler.py +0 -0
  74. {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/application/dtos/__init__.py +0 -0
  75. {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/application/dtos/base.py +0 -0
  76. {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/application/dtos/query.py +0 -0
  77. {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/application/use_cases/__init__.py +0 -0
  78. {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/application/use_cases/base.py +0 -0
  79. {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/application/use_cases/query.py +0 -0
  80. {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/domain/__init__.py +0 -0
  81. {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/domain/auth/__init__.py +0 -0
  82. {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/domain/auth/permissions.py +0 -0
  83. {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/domain/auth/value_objects.py +0 -0
  84. {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/domain/base.py +0 -0
  85. {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/domain/cqrs/__init__.py +0 -0
  86. {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/domain/cqrs/buses.py +0 -0
  87. {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/domain/cqrs/commands.py +0 -0
  88. {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/domain/cqrs/context.py +0 -0
  89. {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/domain/cqrs/cron.py +0 -0
  90. {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/domain/cqrs/decorators.py +0 -0
  91. {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/domain/cqrs/exceptions.py +0 -0
  92. {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/domain/cqrs/handlers.py +0 -0
  93. {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/domain/cqrs/middleware.py +0 -0
  94. {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/domain/cqrs/queries.py +0 -0
  95. {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/domain/cqrs/resolution.py +0 -0
  96. {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/domain/cqrs/serializer.py +0 -0
  97. {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/domain/cqrs/task_queues.py +0 -0
  98. {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/domain/events.py +0 -0
  99. {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/domain/exceptions.py +0 -0
  100. {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/domain/repositories.py +0 -0
  101. {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/domain/uow.py +0 -0
  102. {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/infrastructure/__init__.py +0 -0
  103. {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/infrastructure/cache/__init__.py +0 -0
  104. {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/infrastructure/cache/cache_backends/__init__.py +0 -0
  105. {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/infrastructure/cache/cache_backends/memory.py +0 -0
  106. {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/infrastructure/cache/cache_backends/redis.py +0 -0
  107. {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/infrastructure/cqrs/postgres_bus.py +0 -0
  108. {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/infrastructure/cqrs/postgres_lock.py +0 -0
  109. {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/infrastructure/cqrs/procrastinate.py +0 -0
  110. {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/infrastructure/cqrs/pydantic_serializer.py +0 -0
  111. {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/infrastructure/cqrs/rabbitmq.py +0 -0
  112. {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/infrastructure/cqrs/redis_bus.py +0 -0
  113. {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/infrastructure/cqrs/redis_lock.py +0 -0
  114. {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/infrastructure/events/__init__.py +0 -0
  115. {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/infrastructure/events/events_backends/__init__.py +0 -0
  116. {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/infrastructure/events/events_backends/memory.py +0 -0
  117. {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/infrastructure/repositories/__init__.py +0 -0
  118. {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/infrastructure/repositories/base.py +0 -0
  119. {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/infrastructure/repositories/decorators.py +0 -0
  120. {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/infrastructure/repositories/orms/__init__.py +0 -0
  121. {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/infrastructure/repositories/orms/beanie/__init__.py +0 -0
  122. {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/infrastructure/repositories/orms/sqlalchemy/__init__.py +0 -0
  123. {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/infrastructure/repositories/orms/sqlalchemy/session.py +0 -0
  124. {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/infrastructure/repositories/utils.py +0 -0
  125. {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/infrastructure/task_queues/__init__.py +0 -0
  126. {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/infrastructure/task_queues/procrastinate_adapter.py +0 -0
  127. {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/infrastructure/uow/__init__.py +0 -0
  128. {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/infrastructure/uow/decorators.py +0 -0
  129. {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/infrastructure/uow/helpers.py +0 -0
  130. {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/infrastructure/uow/scopes.py +0 -0
  131. {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/infrastructure/workers/consumer.py +0 -0
  132. {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/infrastructure/workers/rabbitmq_worker.py +0 -0
  133. {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/py.typed +0 -0
  134. {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/types.py +0 -0
  135. {hexcore-3.0.0 → hexcore-5.0.0}/hexcore.egg-info/dependency_links.txt +0 -0
  136. {hexcore-3.0.0 → hexcore-5.0.0}/hexcore.egg-info/entry_points.txt +0 -0
  137. {hexcore-3.0.0 → hexcore-5.0.0}/hexcore.egg-info/requires.txt +0 -0
  138. {hexcore-3.0.0 → hexcore-5.0.0}/hexcore.egg-info/top_level.txt +0 -0
  139. {hexcore-3.0.0 → hexcore-5.0.0}/scripts/__init__.py +0 -0
  140. {hexcore-3.0.0 → hexcore-5.0.0}/scripts/main.py +0 -0
  141. {hexcore-3.0.0 → hexcore-5.0.0}/setup.cfg +0 -0
  142. {hexcore-3.0.0 → hexcore-5.0.0}/tests/conftest.py +0 -0
  143. {hexcore-3.0.0 → hexcore-5.0.0}/tests/test_basic.py +0 -0
  144. {hexcore-3.0.0 → hexcore-5.0.0}/tests/test_beanie_query_utils.py +0 -0
  145. {hexcore-3.0.0 → hexcore-5.0.0}/tests/test_config_loading.py +0 -0
  146. {hexcore-3.0.0 → hexcore-5.0.0}/tests/test_consumer_optional_event_bus.py +0 -0
  147. {hexcore-3.0.0 → hexcore-5.0.0}/tests/test_cqrs.py +0 -0
  148. {hexcore-3.0.0 → hexcore-5.0.0}/tests/test_cqrs_factory.py +0 -0
  149. {hexcore-3.0.0 → hexcore-5.0.0}/tests/test_domain_service_query.py +0 -0
  150. {hexcore-3.0.0 → hexcore-5.0.0}/tests/test_dotted_resolution.py +0 -0
  151. {hexcore-3.0.0 → hexcore-5.0.0}/tests/test_handler_registry.py +0 -0
  152. {hexcore-3.0.0 → hexcore-5.0.0}/tests/test_infrastructure_query_path.py +0 -0
  153. {hexcore-3.0.0 → hexcore-5.0.0}/tests/test_lock_error_policy.py +0 -0
  154. {hexcore-3.0.0 → hexcore-5.0.0}/tests/test_postgres_bus.py +0 -0
  155. {hexcore-3.0.0 → hexcore-5.0.0}/tests/test_postgres_lock.py +0 -0
  156. {hexcore-3.0.0 → hexcore-5.0.0}/tests/test_query_field_validation.py +0 -0
  157. {hexcore-3.0.0 → hexcore-5.0.0}/tests/test_rabbitmq_bus.py +0 -0
  158. {hexcore-3.0.0 → hexcore-5.0.0}/tests/test_redis_bus.py +0 -0
  159. {hexcore-3.0.0 → hexcore-5.0.0}/tests/test_redis_lock.py +0 -0
  160. {hexcore-3.0.0 → hexcore-5.0.0}/tests/test_repositories_utils.py +0 -0
  161. {hexcore-3.0.0 → hexcore-5.0.0}/tests/test_scheduler.py +0 -0
  162. {hexcore-3.0.0 → hexcore-5.0.0}/tests/test_scheduler_catchup.py +0 -0
  163. {hexcore-3.0.0 → hexcore-5.0.0}/tests/test_smart_routing.py +0 -0
  164. {hexcore-3.0.0 → hexcore-5.0.0}/tests/test_sql_session_layer.py +0 -0
  165. {hexcore-3.0.0 → hexcore-5.0.0}/tests/test_task_queues_adapters.py +0 -0
  166. {hexcore-3.0.0 → hexcore-5.0.0}/tests/test_uow_session_regression.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hexcore
3
- Version: 3.0.0
3
+ Version: 5.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
@@ -340,23 +340,36 @@ Si ya tienes una aplicación escrita con la abstracción `UseCase` de HexCore, p
340
340
  En lugar de instanciar un UseCase directamente en tu endpoint, envuélvelo en un comando:
341
341
 
342
342
  ```python
343
- from hexcore.application.cqrs.adapters import UseCaseCommandHandler
344
- from hexcore.application.cqrs.registry import HandlerRegistry
343
+ import hexcore.cqrs as cqrs
345
344
 
346
- # 1. Tienes tu UseCase legado
345
+ # 1. Tienes tu UseCase legado, con sus dependencias
347
346
  class CreateUserUseCase(UseCase[CreateUserCommand, UserDTO]):
347
+ def __init__(self, uow: IUnitOfWork) -> None:
348
+ self.uow = uow
349
+
348
350
  async def execute(self, request: CreateUserCommand) -> UserDTO:
349
351
  # logica legacy
350
- pass
352
+ ...
351
353
 
352
354
  # 2. Lo registras en el registry de CQRS utilizando el adaptador
353
- registry = HandlerRegistry()
354
- registry.register_command(
355
- CreateUserCommand,
356
- UseCaseCommandHandler(CreateUserUseCase())
355
+ registry = cqrs.HandlerRegistry()
356
+ registry.register_command_handler(
357
+ CreateUserCommand,
358
+ cqrs.UseCaseCommandHandler(CreateUserUseCase(uow)),
359
+ )
360
+
361
+ # O con un factory, si quieres resolver las dependencias en el momento del dispatch:
362
+ registry.register_command_handler(
363
+ CreateUserCommand,
364
+ cqrs.HandlerRegistry.factory(
365
+ lambda: cqrs.UseCaseCommandHandler(CreateUserUseCase(build_uow()))
366
+ ),
357
367
  )
358
368
  ```
359
369
 
370
+ > El método es `register_command_handler` (y `register_query_handler`), no
371
+ > `register_command`.
372
+
360
373
  #### Paso 2: Consumirlo desde el endpoint usando el CommandBus
361
374
 
362
375
  ```python
@@ -524,28 +537,30 @@ async def clean_old_records_task(days_retention: int):
524
537
  pass
525
538
  ```
526
539
 
527
- #### 2. Crear el Enqueuer (Adaptador)
540
+ #### 2. Usar un Enqueuer (Adaptador)
528
541
 
529
- Implementa la interfaz genérica `ITaskEnqueuer` usando la SDK de tu Task Queue favorito:
542
+ No hace falta escribirlo: HexCore trae `ProcrastinateEnqueuer` y `CeleryEnqueuer` listos, y
543
+ registran las tareas del consumidor con los nombres `hexcore.process_command`,
544
+ `hexcore.process_handler` y `hexcore.process_task`.
530
545
 
531
546
  ```python
532
- from hexcore.domain.cqrs.task_queues import ITaskEnqueuer
547
+ from hexcore.infrastructure.task_queues.procrastinate_adapter import ProcrastinateEnqueuer
533
548
 
534
- class ProcrastinateEnqueuer(ITaskEnqueuer):
535
- async def enqueue_command(self, command_name: str, payload: dict, queue: str) -> None:
536
- await process_cqrs_command.defer_async(payload=payload)
537
-
538
- async def enqueue_handler(self, handler_name: str, payload: dict, queue: str) -> None:
539
- await process_cqrs_handler.defer_async(handler_name=handler_name, payload=payload)
540
-
541
- async def enqueue_event(self, event_name: str, payload: dict, queue: str) -> None:
542
- pass # Usualmente no se usa directamente si utilizas @background_handler
543
-
544
- async def enqueue_task(self, task_name: str, payload: dict, queue: str) -> None:
545
- await process_generic_task.defer_async(task_name=task_name, payload=payload)
549
+ enqueuer = ProcrastinateEnqueuer(procrastinate_app)
546
550
  ```
547
551
 
548
- #### 2. Configurar tus Buses con un Adaptador Oficial
552
+ Si necesitas otro broker, implementa `ITaskEnqueuer` (4 métodos). Dos advertencias:
553
+
554
+ - **`enqueue_event` no es un `pass`.** Una cola de tareas no puede hacer fan-out a "todos
555
+ los suscriptores", así que los adaptadores oficiales lanzan `NotImplementedError` en vez
556
+ de perder el evento en silencio. Para ejecutar un suscriptor concreto en background usa
557
+ `@background_handler` (el EventBus llamará a `enqueue_handler`); para fan-out real usa
558
+ `RedisEventBus` o `PostgresEventBus`.
559
+ - Si envuelves corutinas en un worker síncrono (Celery), no uses `asyncio.run()` por tarea:
560
+ cierra el event loop y deja el pool del `AsyncEngine` atado a un loop muerto. HexCore usa
561
+ un loop persistente por proceso, expuesto como `run_in_worker_loop(coro)`.
562
+
563
+ #### 3. Configurar tus Buses con un Adaptador Oficial
549
564
 
550
565
  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.
551
566
 
@@ -593,74 +608,133 @@ await enqueuer.enqueue_task(
593
608
 
594
609
  #### 5. Levantar el Worker (Consumidor Universal)
595
610
 
596
- En el entrypoint de tu worker, usa la función utilitaria `register_hexcore_celery_tasks` para autoconfigurar las rutas en una sola línea:
611
+ En el entrypoint de tu worker, `register_hexcore_*_tasks` autoconfigura las rutas
612
+ `hexcore.process_command`, `hexcore.process_handler` y `hexcore.process_task`:
597
613
 
598
614
  ```python
599
- from hexcore.infrastructure.workers.consumer import CQRSConsumer
615
+ import hexcore.cqrs as cqrs
600
616
  from hexcore.infrastructure.task_queues.celery_adapter import register_hexcore_celery_tasks
601
617
 
602
- consumer = CQRSConsumer(
603
- command_bus=command_bus, # Tu CommandBus configurado
604
- event_bus=event_bus,
605
- serializer=serializer
606
- )
618
+ # Le pasas el MISMO bus que usa el proceso web: el consumer marca el mensaje como
619
+ # "viene del worker", así que el bus lo ejecuta en vez de reencolarlo.
620
+ consumer = cqrs.CQRSConsumer(command_bus, event_bus)
621
+
622
+ register_hexcore_celery_tasks(app, consumer) # idempotente: llamarla dos veces no revienta
623
+ ```
624
+
625
+ Un worker que sólo procesa comandos puede omitir el event bus: `cqrs.CQRSConsumer(command_bus)`.
626
+
627
+ Y el entrypoint completo del worker (con scheduler, muerte mutua y SIGTERM) es una llamada:
607
628
 
608
- # ¡Magia! Registra las tareas 'hexcore.process_command', 'hexcore.process_handler', etc.
609
- register_hexcore_celery_tasks(app, consumer)
629
+ ```python
630
+ await cqrs.run_procrastinate_worker(
631
+ procrastinate_app,
632
+ queues=["default", "reactive"],
633
+ scheduler=cqrs.DynamicScheduler(repo, enqueuer, lock_provider=lock),
634
+ on_startup=[lambda: cqrs.seed_cron_jobs(CRON_JOBS)],
635
+ )
610
636
  ```
611
637
 
638
+ #### 6. Ejecutar un comando "aquí y ahora"
639
+
640
+ No hay una API separada para esto, y es a propósito: el contrato es que **el bus decide
641
+ por contexto**.
642
+
643
+ - Fuera de un worker, un `@background_command` se encola.
644
+ - Dentro de un worker (es decir, cuando el mensaje viene del `CQRSConsumer`) el **mismo**
645
+ bus lo ejecuta localmente. Por eso puedes —y debes— compartir un único bus entre la app
646
+ web y el worker.
647
+ - Si un handler despacha a propósito otro `@background_command`, ese sí se encola: el
648
+ contexto de worker se consume en el primer dispatch.
649
+
650
+ Si necesitas comprobarlo desde tu código, `cqrs.is_worker_execution()` responde si el
651
+ mensaje en curso viene de una cola.
652
+
612
653
  ---
613
654
 
614
655
  ## Tareas Periódicas Dinámicas (Cronjobs en Caliente)
615
656
 
616
657
  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
658
 
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):
659
+ ### 1. Usa el repositorio SQL de serie (o implementa el tuyo)
660
+
661
+ Si tu cron vive en SQL —el caso normal— no escribas nada: HexCore trae la tabla, el
662
+ repositorio y el seed (extra `[sql]`).
663
+
664
+ ```python
665
+ import hexcore.cqrs as cqrs
666
+
667
+ CRON_JOBS = [
668
+ cqrs.cron_job(clean_old_records_task, "*/5 * * * *", payload={"days_retention": 30}),
669
+ cqrs.cron_job(cerrar_caja, "0 3 * * *"),
670
+ ]
671
+
672
+ await cqrs.create_cron_tables() # o una migración de Alembic
673
+ await cqrs.seed_cron_jobs(CRON_JOBS) # idempotente, y NO pisa lo editado en BD
674
+
675
+ repo = cqrs.SqlAlchemyCronJobRepository()
676
+ ```
677
+
678
+ `cron_job()` deriva el `task_name` de `__cqrs_task_name__`: escribirlo a mano es cómo se
679
+ acaba con un cron que encola una tarea ya renombrada, y el fallo aparece en el worker.
680
+
681
+ Si tu configuración vive en otro sitio (Mongo, Redis, un YAML), implementa
682
+ `ICronJobRepository`:
620
683
 
621
684
  ```python
622
- from hexcore.domain.cqrs.cron import ICronJobRepository, CronJobDefinition
623
685
  from datetime import datetime
624
686
 
625
- class MiCronRepository(ICronJobRepository):
626
- async def get_active_jobs(self) -> list[CronJobDefinition]:
627
- # SELECT * FROM cronjobs WHERE is_active = true
687
+ import hexcore.cqrs as cqrs
688
+
689
+
690
+ class MiCronRepository(cqrs.ICronJobRepository):
691
+ async def get_active_jobs(self) -> list[cqrs.CronJobDefinition]:
628
692
  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": {}}
693
+ cqrs.CronJobDefinition(
694
+ job_id="clean-db",
695
+ task_name="mi_app.tasks.clean_old_records_task", # un @background_task
696
+ cron_expression="*/5 * * * *",
697
+ payload={"days_retention": 30},
698
+ queue="maintenance",
634
699
  )
635
700
  ]
636
-
701
+
637
702
  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
703
+ # Importa implementarlo: es lo que deduplica el encolado entre ticks.
704
+ ...
640
705
  ```
641
706
 
642
707
  ### 2. Levanta el Scheduler
643
708
  En un proceso en background de tu API o en un microservicio separado, arranca el Scheduler inyectándole tu Enqueuer favorito (Celery, Procrastinate):
644
709
 
645
710
  ```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
711
+ import hexcore.cqrs as cqrs
658
712
 
659
- # En FastAPI puedes usar lifespan para lanzarlo:
660
- # asyncio.create_task(run_scheduler())
713
+ scheduler = cqrs.DynamicScheduler(
714
+ repository=repo,
715
+ enqueuer=enqueuer,
716
+ tick_interval_seconds=60,
717
+ )
718
+
719
+ # Lo normal es dejar que el runner lo supervise junto al worker: si uno de los dos
720
+ # muere, se cancela el otro y el proceso sale para que el orquestador lo reinicie.
721
+ await cqrs.run_procrastinate_worker(procrastinate_app, scheduler=scheduler)
661
722
  ```
662
723
 
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!
724
+ El Scheduler evalúa las expresiones con `croniter` y delega la carga pesada al enqueuer. Tu
725
+ Worker no necesita saber de horarios, sólo ejecuta las tareas cuando le llegan.
726
+
727
+ **Cómo decide si toca ejecutar.** No compara contra el minuto actual, sino que busca si hubo
728
+ alguna ocurrencia entre la última ejecución (`last_run_at`) y ahora. Dos consecuencias que
729
+ importan:
730
+
731
+ - Un minuto saltado por drift del tick **no** pierde la ejecución.
732
+ - `update_last_run` deduplica de verdad, así que un `tick_interval_seconds < 60` no duplica
733
+ el encolado dentro del mismo proceso. Entre réplicas sí hace falta lock, y el scheduler
734
+ emite un `RuntimeWarning` si detecta tick sub-minuto sin `lock_provider`.
735
+
736
+ `catch_up_window_seconds` (1 hora por defecto) acota el catch-up: un scheduler que estuvo
737
+ caído una semana no dispara ocurrencias antiguas.
664
738
 
665
739
  ### 3. Distributed Locks (Evitar ejecuciones dobles)
666
740
 
@@ -690,7 +764,7 @@ Si usas Procrastinate o bases de datos SQL y no quieres levantar Redis:
690
764
  from hexcore.infrastructure.cqrs.postgres_lock import PostgresLockProvider
691
765
 
692
766
  lock_provider = PostgresLockProvider(my_asyncpg_pool)
693
- await lock_provider.setup() # Crea la tabla de locks si no existe
767
+ await lock_provider.setup() # Crea la tabla y el índice, y purga lo expirado
694
768
 
695
769
  scheduler = DynamicScheduler(
696
770
  repository=repo,
@@ -699,6 +773,23 @@ scheduler = DynamicScheduler(
699
773
  )
700
774
  ```
701
775
 
776
+ > El provider purga las filas expiradas solo (en `setup()` y cada 100 adquisiciones), así
777
+ > que la tabla de locks no crece sin límite. `purge_expired()` es pública si prefieres
778
+ > purgar desde un job propio, y `purge_every=0` desactiva la purga automática.
779
+
780
+ #### Qué pasa si el lock no responde
781
+
782
+ Si Redis (o Postgres) se cae, `acquire_lock` no puede decidir, y las dos respuestas
783
+ posibles son malas de formas distintas. La decisión es tuya y explícita:
784
+
785
+ ```python
786
+ RedisLockProvider(redis_client, on_error="skip") # default: no correr. El cron se detiene.
787
+ RedisLockProvider(redis_client, on_error="raise") # propagar, para que el supervisor lo vea.
788
+ ```
789
+
790
+ En los logs, "no pude decidir" es `critical` y "el lock estaba tomado por otra réplica"
791
+ —el caso normal— es `debug`.
792
+
702
793
  ---
703
794
 
704
795
  ## Referencias
@@ -295,23 +295,36 @@ Si ya tienes una aplicación escrita con la abstracción `UseCase` de HexCore, p
295
295
  En lugar de instanciar un UseCase directamente en tu endpoint, envuélvelo en un comando:
296
296
 
297
297
  ```python
298
- from hexcore.application.cqrs.adapters import UseCaseCommandHandler
299
- from hexcore.application.cqrs.registry import HandlerRegistry
298
+ import hexcore.cqrs as cqrs
300
299
 
301
- # 1. Tienes tu UseCase legado
300
+ # 1. Tienes tu UseCase legado, con sus dependencias
302
301
  class CreateUserUseCase(UseCase[CreateUserCommand, UserDTO]):
302
+ def __init__(self, uow: IUnitOfWork) -> None:
303
+ self.uow = uow
304
+
303
305
  async def execute(self, request: CreateUserCommand) -> UserDTO:
304
306
  # logica legacy
305
- pass
307
+ ...
306
308
 
307
309
  # 2. Lo registras en el registry de CQRS utilizando el adaptador
308
- registry = HandlerRegistry()
309
- registry.register_command(
310
- CreateUserCommand,
311
- UseCaseCommandHandler(CreateUserUseCase())
310
+ registry = cqrs.HandlerRegistry()
311
+ registry.register_command_handler(
312
+ CreateUserCommand,
313
+ cqrs.UseCaseCommandHandler(CreateUserUseCase(uow)),
314
+ )
315
+
316
+ # O con un factory, si quieres resolver las dependencias en el momento del dispatch:
317
+ registry.register_command_handler(
318
+ CreateUserCommand,
319
+ cqrs.HandlerRegistry.factory(
320
+ lambda: cqrs.UseCaseCommandHandler(CreateUserUseCase(build_uow()))
321
+ ),
312
322
  )
313
323
  ```
314
324
 
325
+ > El método es `register_command_handler` (y `register_query_handler`), no
326
+ > `register_command`.
327
+
315
328
  #### Paso 2: Consumirlo desde el endpoint usando el CommandBus
316
329
 
317
330
  ```python
@@ -479,28 +492,30 @@ async def clean_old_records_task(days_retention: int):
479
492
  pass
480
493
  ```
481
494
 
482
- #### 2. Crear el Enqueuer (Adaptador)
495
+ #### 2. Usar un Enqueuer (Adaptador)
483
496
 
484
- Implementa la interfaz genérica `ITaskEnqueuer` usando la SDK de tu Task Queue favorito:
497
+ No hace falta escribirlo: HexCore trae `ProcrastinateEnqueuer` y `CeleryEnqueuer` listos, y
498
+ registran las tareas del consumidor con los nombres `hexcore.process_command`,
499
+ `hexcore.process_handler` y `hexcore.process_task`.
485
500
 
486
501
  ```python
487
- from hexcore.domain.cqrs.task_queues import ITaskEnqueuer
502
+ from hexcore.infrastructure.task_queues.procrastinate_adapter import ProcrastinateEnqueuer
488
503
 
489
- class ProcrastinateEnqueuer(ITaskEnqueuer):
490
- async def enqueue_command(self, command_name: str, payload: dict, queue: str) -> None:
491
- await process_cqrs_command.defer_async(payload=payload)
492
-
493
- async def enqueue_handler(self, handler_name: str, payload: dict, queue: str) -> None:
494
- await process_cqrs_handler.defer_async(handler_name=handler_name, payload=payload)
495
-
496
- async def enqueue_event(self, event_name: str, payload: dict, queue: str) -> None:
497
- pass # Usualmente no se usa directamente si utilizas @background_handler
498
-
499
- async def enqueue_task(self, task_name: str, payload: dict, queue: str) -> None:
500
- await process_generic_task.defer_async(task_name=task_name, payload=payload)
504
+ enqueuer = ProcrastinateEnqueuer(procrastinate_app)
501
505
  ```
502
506
 
503
- #### 2. Configurar tus Buses con un Adaptador Oficial
507
+ Si necesitas otro broker, implementa `ITaskEnqueuer` (4 métodos). Dos advertencias:
508
+
509
+ - **`enqueue_event` no es un `pass`.** Una cola de tareas no puede hacer fan-out a "todos
510
+ los suscriptores", así que los adaptadores oficiales lanzan `NotImplementedError` en vez
511
+ de perder el evento en silencio. Para ejecutar un suscriptor concreto en background usa
512
+ `@background_handler` (el EventBus llamará a `enqueue_handler`); para fan-out real usa
513
+ `RedisEventBus` o `PostgresEventBus`.
514
+ - Si envuelves corutinas en un worker síncrono (Celery), no uses `asyncio.run()` por tarea:
515
+ cierra el event loop y deja el pool del `AsyncEngine` atado a un loop muerto. HexCore usa
516
+ un loop persistente por proceso, expuesto como `run_in_worker_loop(coro)`.
517
+
518
+ #### 3. Configurar tus Buses con un Adaptador Oficial
504
519
 
505
520
  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.
506
521
 
@@ -548,74 +563,133 @@ await enqueuer.enqueue_task(
548
563
 
549
564
  #### 5. Levantar el Worker (Consumidor Universal)
550
565
 
551
- En el entrypoint de tu worker, usa la función utilitaria `register_hexcore_celery_tasks` para autoconfigurar las rutas en una sola línea:
566
+ En el entrypoint de tu worker, `register_hexcore_*_tasks` autoconfigura las rutas
567
+ `hexcore.process_command`, `hexcore.process_handler` y `hexcore.process_task`:
552
568
 
553
569
  ```python
554
- from hexcore.infrastructure.workers.consumer import CQRSConsumer
570
+ import hexcore.cqrs as cqrs
555
571
  from hexcore.infrastructure.task_queues.celery_adapter import register_hexcore_celery_tasks
556
572
 
557
- consumer = CQRSConsumer(
558
- command_bus=command_bus, # Tu CommandBus configurado
559
- event_bus=event_bus,
560
- serializer=serializer
561
- )
573
+ # Le pasas el MISMO bus que usa el proceso web: el consumer marca el mensaje como
574
+ # "viene del worker", así que el bus lo ejecuta en vez de reencolarlo.
575
+ consumer = cqrs.CQRSConsumer(command_bus, event_bus)
576
+
577
+ register_hexcore_celery_tasks(app, consumer) # idempotente: llamarla dos veces no revienta
578
+ ```
579
+
580
+ Un worker que sólo procesa comandos puede omitir el event bus: `cqrs.CQRSConsumer(command_bus)`.
581
+
582
+ Y el entrypoint completo del worker (con scheduler, muerte mutua y SIGTERM) es una llamada:
562
583
 
563
- # ¡Magia! Registra las tareas 'hexcore.process_command', 'hexcore.process_handler', etc.
564
- register_hexcore_celery_tasks(app, consumer)
584
+ ```python
585
+ await cqrs.run_procrastinate_worker(
586
+ procrastinate_app,
587
+ queues=["default", "reactive"],
588
+ scheduler=cqrs.DynamicScheduler(repo, enqueuer, lock_provider=lock),
589
+ on_startup=[lambda: cqrs.seed_cron_jobs(CRON_JOBS)],
590
+ )
565
591
  ```
566
592
 
593
+ #### 6. Ejecutar un comando "aquí y ahora"
594
+
595
+ No hay una API separada para esto, y es a propósito: el contrato es que **el bus decide
596
+ por contexto**.
597
+
598
+ - Fuera de un worker, un `@background_command` se encola.
599
+ - Dentro de un worker (es decir, cuando el mensaje viene del `CQRSConsumer`) el **mismo**
600
+ bus lo ejecuta localmente. Por eso puedes —y debes— compartir un único bus entre la app
601
+ web y el worker.
602
+ - Si un handler despacha a propósito otro `@background_command`, ese sí se encola: el
603
+ contexto de worker se consume en el primer dispatch.
604
+
605
+ Si necesitas comprobarlo desde tu código, `cqrs.is_worker_execution()` responde si el
606
+ mensaje en curso viene de una cola.
607
+
567
608
  ---
568
609
 
569
610
  ## Tareas Periódicas Dinámicas (Cronjobs en Caliente)
570
611
 
571
612
  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
613
 
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):
614
+ ### 1. Usa el repositorio SQL de serie (o implementa el tuyo)
615
+
616
+ Si tu cron vive en SQL —el caso normal— no escribas nada: HexCore trae la tabla, el
617
+ repositorio y el seed (extra `[sql]`).
618
+
619
+ ```python
620
+ import hexcore.cqrs as cqrs
621
+
622
+ CRON_JOBS = [
623
+ cqrs.cron_job(clean_old_records_task, "*/5 * * * *", payload={"days_retention": 30}),
624
+ cqrs.cron_job(cerrar_caja, "0 3 * * *"),
625
+ ]
626
+
627
+ await cqrs.create_cron_tables() # o una migración de Alembic
628
+ await cqrs.seed_cron_jobs(CRON_JOBS) # idempotente, y NO pisa lo editado en BD
629
+
630
+ repo = cqrs.SqlAlchemyCronJobRepository()
631
+ ```
632
+
633
+ `cron_job()` deriva el `task_name` de `__cqrs_task_name__`: escribirlo a mano es cómo se
634
+ acaba con un cron que encola una tarea ya renombrada, y el fallo aparece en el worker.
635
+
636
+ Si tu configuración vive en otro sitio (Mongo, Redis, un YAML), implementa
637
+ `ICronJobRepository`:
575
638
 
576
639
  ```python
577
- from hexcore.domain.cqrs.cron import ICronJobRepository, CronJobDefinition
578
640
  from datetime import datetime
579
641
 
580
- class MiCronRepository(ICronJobRepository):
581
- async def get_active_jobs(self) -> list[CronJobDefinition]:
582
- # SELECT * FROM cronjobs WHERE is_active = true
642
+ import hexcore.cqrs as cqrs
643
+
644
+
645
+ class MiCronRepository(cqrs.ICronJobRepository):
646
+ async def get_active_jobs(self) -> list[cqrs.CronJobDefinition]:
583
647
  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": {}}
648
+ cqrs.CronJobDefinition(
649
+ job_id="clean-db",
650
+ task_name="mi_app.tasks.clean_old_records_task", # un @background_task
651
+ cron_expression="*/5 * * * *",
652
+ payload={"days_retention": 30},
653
+ queue="maintenance",
589
654
  )
590
655
  ]
591
-
656
+
592
657
  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
658
+ # Importa implementarlo: es lo que deduplica el encolado entre ticks.
659
+ ...
595
660
  ```
596
661
 
597
662
  ### 2. Levanta el Scheduler
598
663
  En un proceso en background de tu API o en un microservicio separado, arranca el Scheduler inyectándole tu Enqueuer favorito (Celery, Procrastinate):
599
664
 
600
665
  ```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
666
+ import hexcore.cqrs as cqrs
613
667
 
614
- # En FastAPI puedes usar lifespan para lanzarlo:
615
- # asyncio.create_task(run_scheduler())
668
+ scheduler = cqrs.DynamicScheduler(
669
+ repository=repo,
670
+ enqueuer=enqueuer,
671
+ tick_interval_seconds=60,
672
+ )
673
+
674
+ # Lo normal es dejar que el runner lo supervise junto al worker: si uno de los dos
675
+ # muere, se cancela el otro y el proceso sale para que el orquestador lo reinicie.
676
+ await cqrs.run_procrastinate_worker(procrastinate_app, scheduler=scheduler)
616
677
  ```
617
678
 
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!
679
+ El Scheduler evalúa las expresiones con `croniter` y delega la carga pesada al enqueuer. Tu
680
+ Worker no necesita saber de horarios, sólo ejecuta las tareas cuando le llegan.
681
+
682
+ **Cómo decide si toca ejecutar.** No compara contra el minuto actual, sino que busca si hubo
683
+ alguna ocurrencia entre la última ejecución (`last_run_at`) y ahora. Dos consecuencias que
684
+ importan:
685
+
686
+ - Un minuto saltado por drift del tick **no** pierde la ejecución.
687
+ - `update_last_run` deduplica de verdad, así que un `tick_interval_seconds < 60` no duplica
688
+ el encolado dentro del mismo proceso. Entre réplicas sí hace falta lock, y el scheduler
689
+ emite un `RuntimeWarning` si detecta tick sub-minuto sin `lock_provider`.
690
+
691
+ `catch_up_window_seconds` (1 hora por defecto) acota el catch-up: un scheduler que estuvo
692
+ caído una semana no dispara ocurrencias antiguas.
619
693
 
620
694
  ### 3. Distributed Locks (Evitar ejecuciones dobles)
621
695
 
@@ -645,7 +719,7 @@ Si usas Procrastinate o bases de datos SQL y no quieres levantar Redis:
645
719
  from hexcore.infrastructure.cqrs.postgres_lock import PostgresLockProvider
646
720
 
647
721
  lock_provider = PostgresLockProvider(my_asyncpg_pool)
648
- await lock_provider.setup() # Crea la tabla de locks si no existe
722
+ await lock_provider.setup() # Crea la tabla y el índice, y purga lo expirado
649
723
 
650
724
  scheduler = DynamicScheduler(
651
725
  repository=repo,
@@ -654,6 +728,23 @@ scheduler = DynamicScheduler(
654
728
  )
655
729
  ```
656
730
 
731
+ > El provider purga las filas expiradas solo (en `setup()` y cada 100 adquisiciones), así
732
+ > que la tabla de locks no crece sin límite. `purge_expired()` es pública si prefieres
733
+ > purgar desde un job propio, y `purge_every=0` desactiva la purga automática.
734
+
735
+ #### Qué pasa si el lock no responde
736
+
737
+ Si Redis (o Postgres) se cae, `acquire_lock` no puede decidir, y las dos respuestas
738
+ posibles son malas de formas distintas. La decisión es tuya y explícita:
739
+
740
+ ```python
741
+ RedisLockProvider(redis_client, on_error="skip") # default: no correr. El cron se detiene.
742
+ RedisLockProvider(redis_client, on_error="raise") # propagar, para que el supervisor lo vea.
743
+ ```
744
+
745
+ En los logs, "no pude decidir" es `critical` y "el lock estaba tomado por otra réplica"
746
+ —el caso normal— es `debug`.
747
+
657
748
  ---
658
749
 
659
750
  ## Referencias
@@ -3,8 +3,8 @@ hexcore.application.cqrs — Capa de aplicación CQRS.
3
3
  Registry, Pipeline, Buses In-Memory, Adaptadores y Factory.
4
4
  """
5
5
 
6
- from .registry import HandlerRegistry
7
- from .config import CQRSConfig, BusConfig, MiddlewareConfig
6
+ from .registry import HandlerRegistry, HandlerFactory
7
+ from .config import CQRSConfig, BusConfig
8
8
  from .pipeline import MiddlewarePipeline
9
9
  from .in_memory_buses import InMemoryCommandBus, InMemoryQueryBus, InMemoryEventBus
10
10
  from .adapters import UseCaseCommandHandler
@@ -13,9 +13,9 @@ from .scheduler import DynamicScheduler
13
13
 
14
14
  __all__ = [
15
15
  "HandlerRegistry",
16
+ "HandlerFactory",
16
17
  "CQRSConfig",
17
18
  "BusConfig",
18
- "MiddlewareConfig",
19
19
  "MiddlewarePipeline",
20
20
  "InMemoryCommandBus",
21
21
  "InMemoryQueryBus",
@@ -8,18 +8,17 @@ import typing as t
8
8
  from pydantic import BaseModel, ConfigDict, Field
9
9
 
10
10
 
11
- class MiddlewareConfig(BaseModel):
12
- """Configuración de un middleware individual."""
13
-
14
- enabled: bool = True
15
- order: int = 0 # Menor = se ejecuta primero
16
- options: dict[str, t.Any] = Field(default_factory=dict)
17
-
18
- model_config = ConfigDict(frozen=True)
11
+ class BusConfig(BaseModel):
12
+ """
13
+ Configuración de un bus individual (command, query o event).
19
14
 
15
+ `middlewares` sólo admite middlewares construibles sin argumentos: se instancian con
16
+ `cls()`. Los que necesitan configuración —`TransactionMiddleware` y su `uow_factory`,
17
+ `RetryMiddleware` y su política— hay que instanciarlos a mano y pasar el
18
+ `MiddlewarePipeline` al bus.
20
19
 
21
- class BusConfig(BaseModel):
22
- """Configuración de un bus individual (command, query o event)."""
20
+ `options` se reenvía al **bus**, no a los middlewares.
21
+ """
23
22
 
24
23
  # Clase del bus a instanciar (dotted path o referencia directa)
25
24
  # Si es None, usa el bus in-memory por defecto
@@ -46,12 +45,14 @@ class CQRSConfig(BaseModel):
46
45
  backend="hexcore.infrastructure.cqrs.procrastinate.ProcrastinateCommandBus",
47
46
  middlewares=[
48
47
  "hexcore.infrastructure.cqrs.middlewares.LoggingMiddleware",
49
- "hexcore.infrastructure.cqrs.middlewares.RetryMiddleware",
50
48
  ],
51
- options={"max_retries": 3},
49
+ options={"queue_name": "hexcore_commands"},
52
50
  ),
53
51
  ),
54
52
  )
53
+
54
+ Nota: `options` se le pasa al **bus**, no a los middlewares. Un middleware que
55
+ necesite configuración se instancia a mano y se le pasa el pipeline al bus.
55
56
  """
56
57
 
57
58
  enabled: bool = True