hexcore 2.4.0__tar.gz → 2.5.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 (117) hide show
  1. {hexcore-2.4.0 → hexcore-2.5.0}/PKG-INFO +118 -11
  2. hexcore-2.4.0/hexcore.egg-info/PKG-INFO → hexcore-2.5.0/README.md +90 -28
  3. {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/application/cqrs/__init__.py +2 -0
  4. hexcore-2.5.0/hexcore/application/cqrs/scheduler.py +99 -0
  5. {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/domain/cqrs/__init__.py +5 -0
  6. hexcore-2.5.0/hexcore/domain/cqrs/cron.py +69 -0
  7. hexcore-2.5.0/hexcore/infrastructure/cqrs/postgres_lock.py +95 -0
  8. hexcore-2.5.0/hexcore/infrastructure/cqrs/redis_lock.py +62 -0
  9. {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/infrastructure/repositories/base.py +6 -3
  10. hexcore-2.5.0/hexcore/infrastructure/repositories/implementations.py +198 -0
  11. {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/infrastructure/repositories/utils.py +15 -6
  12. hexcore-2.5.0/hexcore/infrastructure/uow/__init__.py +189 -0
  13. {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/types.py +4 -1
  14. hexcore-2.4.0/README.md → hexcore-2.5.0/hexcore.egg-info/PKG-INFO +135 -0
  15. {hexcore-2.4.0 → hexcore-2.5.0}/hexcore.egg-info/SOURCES.txt +8 -0
  16. {hexcore-2.4.0 → hexcore-2.5.0}/hexcore.egg-info/requires.txt +30 -8
  17. {hexcore-2.4.0 → hexcore-2.5.0}/pyproject.toml +26 -10
  18. {hexcore-2.4.0 → hexcore-2.5.0}/tests/test_beanie_query_utils.py +2 -0
  19. {hexcore-2.4.0 → hexcore-2.5.0}/tests/test_infrastructure_query_path.py +3 -0
  20. hexcore-2.5.0/tests/test_optional_dependencies.py +50 -0
  21. hexcore-2.5.0/tests/test_postgres_lock.py +77 -0
  22. {hexcore-2.4.0 → hexcore-2.5.0}/tests/test_query_field_validation.py +2 -0
  23. hexcore-2.5.0/tests/test_redis_lock.py +69 -0
  24. {hexcore-2.4.0 → hexcore-2.5.0}/tests/test_repositories_utils.py +2 -0
  25. hexcore-2.5.0/tests/test_scheduler.py +115 -0
  26. {hexcore-2.4.0 → hexcore-2.5.0}/tests/test_uow_session_regression.py +2 -0
  27. {hexcore-2.4.0 → hexcore-2.5.0}/tests/test_use_cases_query.py +2 -0
  28. hexcore-2.4.0/hexcore/infrastructure/repositories/implementations.py +0 -212
  29. hexcore-2.4.0/hexcore/infrastructure/uow/__init__.py +0 -181
  30. {hexcore-2.4.0 → hexcore-2.5.0}/LICENSE +0 -0
  31. {hexcore-2.4.0 → hexcore-2.5.0}/MANIFEST.in +0 -0
  32. {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/__init__.py +0 -0
  33. {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/__main__.py +0 -0
  34. {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/application/__init__.py +0 -0
  35. {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/application/cqrs/adapters.py +0 -0
  36. {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/application/cqrs/config.py +0 -0
  37. {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/application/cqrs/factory.py +0 -0
  38. {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/application/cqrs/in_memory_buses.py +0 -0
  39. {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/application/cqrs/pipeline.py +0 -0
  40. {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/application/cqrs/registry.py +0 -0
  41. {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/application/dtos/__init__.py +0 -0
  42. {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/application/dtos/base.py +0 -0
  43. {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/application/dtos/query.py +0 -0
  44. {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/application/use_cases/__init__.py +0 -0
  45. {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/application/use_cases/base.py +0 -0
  46. {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/application/use_cases/query.py +0 -0
  47. {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/config.py +0 -0
  48. {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/domain/__init__.py +0 -0
  49. {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/domain/auth/__init__.py +0 -0
  50. {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/domain/auth/permissions.py +0 -0
  51. {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/domain/auth/value_objects.py +0 -0
  52. {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/domain/base.py +0 -0
  53. {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/domain/cqrs/buses.py +0 -0
  54. {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/domain/cqrs/commands.py +0 -0
  55. {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/domain/cqrs/decorators.py +0 -0
  56. {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/domain/cqrs/exceptions.py +0 -0
  57. {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/domain/cqrs/handlers.py +0 -0
  58. {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/domain/cqrs/middleware.py +0 -0
  59. {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/domain/cqrs/queries.py +0 -0
  60. {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/domain/cqrs/serializer.py +0 -0
  61. {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/domain/cqrs/task_queues.py +0 -0
  62. {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/domain/events.py +0 -0
  63. {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/domain/exceptions.py +0 -0
  64. {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/domain/repositories.py +0 -0
  65. {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/domain/services.py +0 -0
  66. {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/domain/uow.py +0 -0
  67. {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/infrastructure/__init__.py +0 -0
  68. {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/infrastructure/api/__init__.py +0 -0
  69. {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/infrastructure/api/utils.py +0 -0
  70. {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/infrastructure/cache/__init__.py +0 -0
  71. {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/infrastructure/cache/cache_backends/__init__.py +0 -0
  72. {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/infrastructure/cache/cache_backends/memory.py +0 -0
  73. {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/infrastructure/cache/cache_backends/redis.py +0 -0
  74. {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/infrastructure/cli.py +0 -0
  75. {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/infrastructure/cqrs/__init__.py +0 -0
  76. {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/infrastructure/cqrs/middlewares.py +0 -0
  77. {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/infrastructure/cqrs/postgres_bus.py +0 -0
  78. {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/infrastructure/cqrs/procrastinate.py +0 -0
  79. {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/infrastructure/cqrs/pydantic_serializer.py +0 -0
  80. {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/infrastructure/cqrs/rabbitmq.py +0 -0
  81. {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/infrastructure/cqrs/redis_bus.py +0 -0
  82. {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/infrastructure/events/__init__.py +0 -0
  83. {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/infrastructure/events/events_backends/__init__.py +0 -0
  84. {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/infrastructure/events/events_backends/memory.py +0 -0
  85. {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/infrastructure/repositories/__init__.py +0 -0
  86. {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/infrastructure/repositories/decorators.py +0 -0
  87. {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/infrastructure/repositories/orms/__init__.py +0 -0
  88. {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/infrastructure/repositories/orms/beanie/__init__.py +0 -0
  89. {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/infrastructure/repositories/orms/beanie/utils.py +0 -0
  90. {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/infrastructure/repositories/orms/sqlalchemy/__init__.py +0 -0
  91. {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/infrastructure/repositories/orms/sqlalchemy/session.py +0 -0
  92. {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/infrastructure/repositories/orms/sqlalchemy/utils.py +0 -0
  93. {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/infrastructure/task_queues/__init__.py +0 -0
  94. {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/infrastructure/task_queues/celery_adapter.py +0 -0
  95. {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/infrastructure/task_queues/procrastinate_adapter.py +0 -0
  96. {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/infrastructure/uow/decorators.py +0 -0
  97. {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/infrastructure/uow/helpers.py +0 -0
  98. {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/infrastructure/workers/__init__.py +0 -0
  99. {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/infrastructure/workers/consumer.py +0 -0
  100. {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/infrastructure/workers/rabbitmq_worker.py +0 -0
  101. {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/py.typed +0 -0
  102. {hexcore-2.4.0 → hexcore-2.5.0}/hexcore.egg-info/dependency_links.txt +0 -0
  103. {hexcore-2.4.0 → hexcore-2.5.0}/hexcore.egg-info/entry_points.txt +0 -0
  104. {hexcore-2.4.0 → hexcore-2.5.0}/hexcore.egg-info/top_level.txt +0 -0
  105. {hexcore-2.4.0 → hexcore-2.5.0}/scripts/__init__.py +0 -0
  106. {hexcore-2.4.0 → hexcore-2.5.0}/scripts/main.py +0 -0
  107. {hexcore-2.4.0 → hexcore-2.5.0}/setup.cfg +0 -0
  108. {hexcore-2.4.0 → hexcore-2.5.0}/tests/conftest.py +0 -0
  109. {hexcore-2.4.0 → hexcore-2.5.0}/tests/test_basic.py +0 -0
  110. {hexcore-2.4.0 → hexcore-2.5.0}/tests/test_config_loading.py +0 -0
  111. {hexcore-2.4.0 → hexcore-2.5.0}/tests/test_cqrs.py +0 -0
  112. {hexcore-2.4.0 → hexcore-2.5.0}/tests/test_domain_service_query.py +0 -0
  113. {hexcore-2.4.0 → hexcore-2.5.0}/tests/test_postgres_bus.py +0 -0
  114. {hexcore-2.4.0 → hexcore-2.5.0}/tests/test_rabbitmq_bus.py +0 -0
  115. {hexcore-2.4.0 → hexcore-2.5.0}/tests/test_redis_bus.py +0 -0
  116. {hexcore-2.4.0 → hexcore-2.5.0}/tests/test_smart_routing.py +0 -0
  117. {hexcore-2.4.0 → hexcore-2.5.0}/tests/test_task_queues_adapters.py +0 -0
@@ -1,29 +1,46 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hexcore
3
- Version: 2.4.0
3
+ Version: 2.5.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)
@@ -584,6 +601,96 @@ register_hexcore_celery_tasks(app, consumer)
584
601
 
585
602
  ---
586
603
 
604
+ ## Tareas Periódicas Dinámicas (Cronjobs en Caliente)
605
+
606
+ 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**.
607
+
608
+ ### 1. Implementa tu Repositorio
609
+ Implementa `ICronJobRepository` para decirle al Scheduler de dónde leer la configuración (ej. usando SQLAlchemy, MongoDB o Redis):
610
+
611
+ ```python
612
+ from hexcore.domain.cqrs.cron import ICronJobRepository, CronJobDefinition
613
+ from datetime import datetime
614
+
615
+ class MiCronRepository(ICronJobRepository):
616
+ async def get_active_jobs(self) -> list[CronJobDefinition]:
617
+ # SELECT * FROM cronjobs WHERE is_active = true
618
+ return [
619
+ CronJobDefinition(
620
+ job_id="1",
621
+ task_name="hexcore.process_task", # o cualquier @background_task
622
+ cron_expression="*/5 * * * *", # cada 5 minutos
623
+ payload={"task_name": "clean_db", "payload": {}}
624
+ )
625
+ ]
626
+
627
+ async def update_last_run(self, job_id: str, run_time: datetime) -> None:
628
+ # UPDATE cronjobs SET last_run = run_time WHERE id = job_id
629
+ pass
630
+ ```
631
+
632
+ ### 2. Levanta el Scheduler
633
+ En un proceso en background de tu API o en un microservicio separado, arranca el Scheduler inyectándole tu Enqueuer favorito (Celery, Procrastinate):
634
+
635
+ ```python
636
+ from hexcore.application.cqrs.scheduler import DynamicScheduler
637
+ import asyncio
638
+
639
+ async def run_scheduler():
640
+ repo = MiCronRepository()
641
+ scheduler = DynamicScheduler(
642
+ repository=repo,
643
+ enqueuer=enqueuer,
644
+ tick_interval_seconds=60
645
+ )
646
+
647
+ await scheduler.start() # Bucle infinito
648
+
649
+ # En FastAPI puedes usar lifespan para lanzarlo:
650
+ # asyncio.create_task(run_scheduler())
651
+ ```
652
+
653
+ 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!
654
+
655
+ ### 3. Distributed Locks (Evitar ejecuciones dobles)
656
+
657
+ 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**.
658
+
659
+ 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.
660
+
661
+ #### Usando Redis
662
+ ```python
663
+ from hexcore.infrastructure.cqrs.redis_lock import RedisLockProvider
664
+ import redis.asyncio as redis
665
+
666
+ redis_client = redis.from_url("redis://localhost:6379/0")
667
+ lock_provider = RedisLockProvider(redis_client)
668
+
669
+ scheduler = DynamicScheduler(
670
+ repository=repo,
671
+ enqueuer=enqueuer,
672
+ lock_provider=lock_provider
673
+ )
674
+ ```
675
+
676
+ #### Usando PostgreSQL (asyncpg)
677
+ Si usas Procrastinate o bases de datos SQL y no quieres levantar Redis:
678
+
679
+ ```python
680
+ from hexcore.infrastructure.cqrs.postgres_lock import PostgresLockProvider
681
+
682
+ lock_provider = PostgresLockProvider(my_asyncpg_pool)
683
+ await lock_provider.setup() # Crea la tabla de locks si no existe
684
+
685
+ scheduler = DynamicScheduler(
686
+ repository=repo,
687
+ enqueuer=enqueuer,
688
+ lock_provider=lock_provider
689
+ )
690
+ ```
691
+
692
+ ---
693
+
587
694
  ## Referencias
588
695
 
589
696
  - [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
 
@@ -584,6 +556,96 @@ register_hexcore_celery_tasks(app, consumer)
584
556
 
585
557
  ---
586
558
 
559
+ ## Tareas Periódicas Dinámicas (Cronjobs en Caliente)
560
+
561
+ 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**.
562
+
563
+ ### 1. Implementa tu Repositorio
564
+ Implementa `ICronJobRepository` para decirle al Scheduler de dónde leer la configuración (ej. usando SQLAlchemy, MongoDB o Redis):
565
+
566
+ ```python
567
+ from hexcore.domain.cqrs.cron import ICronJobRepository, CronJobDefinition
568
+ from datetime import datetime
569
+
570
+ class MiCronRepository(ICronJobRepository):
571
+ async def get_active_jobs(self) -> list[CronJobDefinition]:
572
+ # SELECT * FROM cronjobs WHERE is_active = true
573
+ return [
574
+ CronJobDefinition(
575
+ job_id="1",
576
+ task_name="hexcore.process_task", # o cualquier @background_task
577
+ cron_expression="*/5 * * * *", # cada 5 minutos
578
+ payload={"task_name": "clean_db", "payload": {}}
579
+ )
580
+ ]
581
+
582
+ async def update_last_run(self, job_id: str, run_time: datetime) -> None:
583
+ # UPDATE cronjobs SET last_run = run_time WHERE id = job_id
584
+ pass
585
+ ```
586
+
587
+ ### 2. Levanta el Scheduler
588
+ En un proceso en background de tu API o en un microservicio separado, arranca el Scheduler inyectándole tu Enqueuer favorito (Celery, Procrastinate):
589
+
590
+ ```python
591
+ from hexcore.application.cqrs.scheduler import DynamicScheduler
592
+ import asyncio
593
+
594
+ async def run_scheduler():
595
+ repo = MiCronRepository()
596
+ scheduler = DynamicScheduler(
597
+ repository=repo,
598
+ enqueuer=enqueuer,
599
+ tick_interval_seconds=60
600
+ )
601
+
602
+ await scheduler.start() # Bucle infinito
603
+
604
+ # En FastAPI puedes usar lifespan para lanzarlo:
605
+ # asyncio.create_task(run_scheduler())
606
+ ```
607
+
608
+ 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!
609
+
610
+ ### 3. Distributed Locks (Evitar ejecuciones dobles)
611
+
612
+ 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**.
613
+
614
+ 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.
615
+
616
+ #### Usando Redis
617
+ ```python
618
+ from hexcore.infrastructure.cqrs.redis_lock import RedisLockProvider
619
+ import redis.asyncio as redis
620
+
621
+ redis_client = redis.from_url("redis://localhost:6379/0")
622
+ lock_provider = RedisLockProvider(redis_client)
623
+
624
+ scheduler = DynamicScheduler(
625
+ repository=repo,
626
+ enqueuer=enqueuer,
627
+ lock_provider=lock_provider
628
+ )
629
+ ```
630
+
631
+ #### Usando PostgreSQL (asyncpg)
632
+ Si usas Procrastinate o bases de datos SQL y no quieres levantar Redis:
633
+
634
+ ```python
635
+ from hexcore.infrastructure.cqrs.postgres_lock import PostgresLockProvider
636
+
637
+ lock_provider = PostgresLockProvider(my_asyncpg_pool)
638
+ await lock_provider.setup() # Crea la tabla de locks si no existe
639
+
640
+ scheduler = DynamicScheduler(
641
+ repository=repo,
642
+ enqueuer=enqueuer,
643
+ lock_provider=lock_provider
644
+ )
645
+ ```
646
+
647
+ ---
648
+
587
649
  ## Referencias
588
650
 
589
651
  - [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
  ]
@@ -0,0 +1,99 @@
1
+ """
2
+ Servicio encargado de evaluar y encolar tareas periódicas.
3
+ """
4
+ from __future__ import annotations
5
+
6
+ import asyncio
7
+ import logging
8
+ from datetime import datetime, timezone
9
+
10
+ from hexcore.domain.cqrs.cron import ICronJobRepository, ILockProvider
11
+ from hexcore.domain.cqrs.task_queues import ITaskEnqueuer
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ class DynamicScheduler:
17
+ """
18
+ Evalúa constantemente un repositorio de tareas periódicas y delega la ejecución
19
+ al TaskEnqueuer (Celery/Procrastinate). Permite cambiar configuraciones cron en "caliente".
20
+ """
21
+
22
+ def __init__(
23
+ self,
24
+ repository: ICronJobRepository,
25
+ enqueuer: ITaskEnqueuer,
26
+ lock_provider: ILockProvider | None = None,
27
+ tick_interval_seconds: int = 30
28
+ ) -> None:
29
+ self.repository = repository
30
+ self.enqueuer = enqueuer
31
+ self.lock_provider = lock_provider
32
+ self.tick_interval_seconds = tick_interval_seconds
33
+
34
+ self._stop_event = asyncio.Event()
35
+
36
+ async def start(self) -> None:
37
+ """Inicia el bucle infinito del scheduler."""
38
+ import croniter
39
+
40
+ logger.info(f"[*] DynamicScheduler started (Tick interval: {self.tick_interval_seconds}s)")
41
+ self._stop_event.clear()
42
+
43
+ # El estado base es el momento en que se levantó el scheduler.
44
+ last_check_time = datetime.now(timezone.utc)
45
+
46
+ while not self._stop_event.is_set():
47
+ try:
48
+ # 1. Esperamos el tiempo del tick
49
+ await asyncio.sleep(self.tick_interval_seconds)
50
+
51
+ current_time = datetime.now(timezone.utc)
52
+
53
+ # 2. Obtenemos la configuración fresca de BD
54
+ active_jobs = await self.repository.get_active_jobs()
55
+
56
+ for job in active_jobs:
57
+ # Determinamos desde cuándo chequear. Si el job tiene `last_run_at`, lo usamos.
58
+ # Sino, usamos el `last_check_time` global del bucle.
59
+ base_time = job.last_run_at or last_check_time
60
+
61
+ try:
62
+ # 3. Comprobamos si la expresión cron cayó entre base_time y current_time
63
+ if croniter.croniter.match(job.cron_expression, current_time):
64
+ # Evitar múltiples encolados en el mismo tick (minuto)
65
+ # Intentar adquirir lock si hay proveedor (Distributed Locks)
66
+ if self.lock_provider:
67
+ # Usamos el minuto actual (truncado) para evitar colisiones distribuidas
68
+ minute_key = current_time.replace(second=0, microsecond=0).isoformat()
69
+ lock_key = f"hexcore:cron_lock:{job.job_id}:{minute_key}"
70
+
71
+ lock_acquired = await self.lock_provider.acquire_lock(lock_key, ttl_seconds=60)
72
+ if not lock_acquired:
73
+ logger.debug(f"CronJob '{job.job_id}' ignorado (lock adquirido por otra réplica).")
74
+ continue
75
+
76
+ logger.info(f"Encolando CronJob '{job.job_id}' -> Task: {job.task_name}")
77
+
78
+ await self.enqueuer.enqueue_task(
79
+ task_name=job.task_name,
80
+ payload=job.payload,
81
+ queue=job.queue
82
+ )
83
+
84
+ # Actualizamos el timestamp en el repositorio
85
+ await self.repository.update_last_run(job.job_id, current_time)
86
+
87
+ except Exception as e:
88
+ logger.error(f"Error procesando el cron job {job.job_id}: {e}")
89
+
90
+ last_check_time = current_time
91
+
92
+ except asyncio.CancelledError:
93
+ break
94
+ except Exception as e:
95
+ logger.error(f"Error crítico en el bucle del scheduler: {e}")
96
+
97
+ def stop(self) -> None:
98
+ """Detiene el bucle del scheduler de forma segura."""
99
+ self._stop_event.set()
@@ -21,6 +21,7 @@ from .handlers import ICommandHandler, IQueryHandler
21
21
  from .buses import ICommandBus, IQueryBus, IEventBus
22
22
  from .middleware import IMiddleware
23
23
  from .serializer import ISerializer
24
+ from .cron import CronJobDefinition, ICronJobRepository, ILockProvider
24
25
 
25
26
  __all__ = [
26
27
  # Nombres canónicos (Abstract*)
@@ -34,6 +35,10 @@ __all__ = [
34
35
  "AbstractMiddleware",
35
36
  "NextHandler",
36
37
  "AbstractSerializer",
38
+ # Cron
39
+ "CronJobDefinition",
40
+ "ICronJobRepository",
41
+ "ILockProvider",
37
42
  # Aliases (I*)
38
43
  "ICommandHandler",
39
44
  "IQueryHandler",
@@ -0,0 +1,69 @@
1
+ """
2
+ Definiciones de Dominio para el agendamiento dinámico de Tareas Periódicas (Cronjobs).
3
+ """
4
+ import abc
5
+ import typing as t
6
+ from dataclasses import dataclass, field
7
+ from datetime import datetime
8
+
9
+
10
+ @dataclass
11
+ class CronJobDefinition:
12
+ """
13
+ Representa la definición de una tarea periódica que debe ejecutarse.
14
+ Esta configuración normalmente se guarda en una BD para permitir
15
+ cambios "en caliente" (hot reload).
16
+ """
17
+ job_id: str
18
+ task_name: str
19
+ cron_expression: str
20
+ payload: dict[str, t.Any] = field(default_factory=dict)
21
+ queue: str = "default"
22
+ is_active: bool = True
23
+
24
+ # Para control de estado si se usa sin locks distribuidos
25
+ last_run_at: datetime | None = None
26
+
27
+
28
+ class ICronJobRepository(abc.ABC):
29
+ """
30
+ Repositorio que el usuario debe implementar (ej. usando SQLAlchemy, Beanie, etc.)
31
+ para proveer al DynamicScheduler la lista actualizada de tareas a ejecutar.
32
+ """
33
+
34
+ @abc.abstractmethod
35
+ async def get_active_jobs(self) -> list[CronJobDefinition]:
36
+ """
37
+ Retorna la lista de trabajos activos.
38
+ """
39
+ pass
40
+
41
+ @abc.abstractmethod
42
+ async def update_last_run(self, job_id: str, run_time: datetime) -> None:
43
+ """
44
+ Actualiza la fecha de última ejecución de un job para evitar
45
+ que se ejecute repetidamente en el mismo tick.
46
+ """
47
+ pass
48
+
49
+
50
+ class ILockProvider(abc.ABC):
51
+ """
52
+ Proveedor de Locks Distribuidos (ej. Redis, PostgreSQL Advisory Locks).
53
+ Utilizado por el DynamicScheduler para garantizar que múltiples réplicas
54
+ del scheduler no ejecuten el mismo cronjob de manera duplicada.
55
+ """
56
+
57
+ @abc.abstractmethod
58
+ async def acquire_lock(self, lock_key: str, ttl_seconds: int) -> bool:
59
+ """
60
+ Intenta adquirir un lock. Retorna True si lo consiguió, False si ya estaba tomado.
61
+ """
62
+ pass
63
+
64
+ @abc.abstractmethod
65
+ async def release_lock(self, lock_key: str) -> None:
66
+ """
67
+ Libera el lock explícitamente (opcional, ya que el TTL lo hace automáticamente).
68
+ """
69
+ pass
@@ -0,0 +1,95 @@
1
+ """
2
+ Proveedor de Locks Distribuidos utilizando PostgreSQL.
3
+ Ideal para garantizar ejecución única de CronJobs cuando usas bases de datos SQL
4
+ sin necesidad de añadir Redis a tu stack.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import logging
9
+ from datetime import datetime, timezone, timedelta
10
+ from typing import TYPE_CHECKING
11
+
12
+ from hexcore.domain.cqrs.cron import ILockProvider
13
+
14
+ if TYPE_CHECKING:
15
+ import asyncpg
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ class PostgresLockProvider(ILockProvider):
21
+ """
22
+ Implementación de ILockProvider basada en PostgreSQL (vía asyncpg).
23
+ Utiliza una tabla `hexcore_cron_locks` para gestionar la exclusión mutua
24
+ con soporte para TTL atómico.
25
+ """
26
+
27
+ def __init__(self, pool: asyncpg.Pool | asyncpg.Connection, table_name: str = "hexcore_cron_locks") -> None:
28
+ """
29
+ Args:
30
+ pool: Pool de conexiones o conexión simple de `asyncpg`.
31
+ table_name: Nombre de la tabla a utilizar para los locks.
32
+ """
33
+ self.pool = pool
34
+ self.table_name = table_name
35
+
36
+ async def setup(self) -> None:
37
+ """
38
+ Crea la tabla necesaria para los locks si no existe.
39
+ Debe llamarse al inicio de la aplicación.
40
+ """
41
+ query = f"""
42
+ CREATE TABLE IF NOT EXISTS {self.table_name} (
43
+ lock_key TEXT PRIMARY KEY,
44
+ expires_at TIMESTAMP WITH TIME ZONE NOT NULL
45
+ )
46
+ """
47
+ try:
48
+ if hasattr(self.pool, "execute"):
49
+ await self.pool.execute(query) # type: ignore
50
+ except Exception as e:
51
+ logger.error(f"Error creando tabla de locks en Postgres: {e}")
52
+ raise
53
+
54
+ async def acquire_lock(self, lock_key: str, ttl_seconds: int) -> bool:
55
+ """
56
+ Intenta adquirir un lock en Postgres.
57
+
58
+ Args:
59
+ lock_key: La clave única del lock.
60
+ ttl_seconds: Tiempo de vida del lock en segundos (evita deadlocks).
61
+
62
+ Returns:
63
+ True si el lock fue adquirido con éxito.
64
+ False si el lock ya estaba tomado y aún no ha expirado.
65
+ """
66
+ # Usamos UPSERT (ON CONFLICT) condicional
67
+ # Si no existe, lo inserta.
68
+ # Si existe pero expiró, lo actualiza y lo toma.
69
+ # Si existe y NO expiró, no hace nada (y no retorna fila).
70
+ query = f"""
71
+ INSERT INTO {self.table_name} (lock_key, expires_at)
72
+ VALUES ($1, NOW() + ($2 || ' seconds')::interval)
73
+ ON CONFLICT (lock_key) DO UPDATE
74
+ SET expires_at = NOW() + ($2 || ' seconds')::interval
75
+ WHERE {self.table_name}.expires_at < NOW()
76
+ RETURNING lock_key;
77
+ """
78
+
79
+ try:
80
+ # fetchrow returns a record if RETURNING gave something, else None
81
+ result = await self.pool.fetchrow(query, lock_key, str(ttl_seconds)) # type: ignore
82
+ return result is not None
83
+ except Exception as e:
84
+ logger.error(f"Error intentando adquirir lock de Postgres para {lock_key}: {e}")
85
+ return False
86
+
87
+ async def release_lock(self, lock_key: str) -> None:
88
+ """
89
+ Libera el lock explícitamente eliminándolo de la tabla.
90
+ """
91
+ query = f"DELETE FROM {self.table_name} WHERE lock_key = $1"
92
+ try:
93
+ await self.pool.execute(query, lock_key) # type: ignore
94
+ except Exception as e:
95
+ logger.error(f"Error intentando liberar lock de Postgres para {lock_key}: {e}")
@@ -0,0 +1,62 @@
1
+ """
2
+ Proveedor de Locks Distribuidos utilizando Redis.
3
+ Ideal para garantizar ejecución única de CronJobs en múltiples réplicas.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import logging
8
+ from typing import TYPE_CHECKING
9
+
10
+ from hexcore.domain.cqrs.cron import ILockProvider
11
+
12
+ if TYPE_CHECKING:
13
+ from redis.asyncio import Redis
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class RedisLockProvider(ILockProvider):
19
+ """
20
+ Implementación de ILockProvider basada en Redis.
21
+ Utiliza el comando SET NX EX para asegurar exclusión mutua de manera atómica
22
+ y evitar "deadlocks" mediante la expiración (TTL).
23
+ """
24
+
25
+ def __init__(self, redis_client: Redis) -> None:
26
+ """
27
+ Args:
28
+ redis_client: Instancia activa de `redis.asyncio.Redis`.
29
+ """
30
+ self.redis = redis_client
31
+
32
+ async def acquire_lock(self, lock_key: str, ttl_seconds: int) -> bool:
33
+ """
34
+ Intenta adquirir un lock en Redis.
35
+
36
+ Args:
37
+ lock_key: La clave única del lock (ej. "hexcore:cron_lock:job_1:2026-07-28T01:02:00").
38
+ ttl_seconds: Tiempo de vida del lock en segundos (evita deadlocks).
39
+
40
+ Returns:
41
+ True si el lock fue adquirido con éxito (nadie lo tenía).
42
+ False si el lock ya estaba tomado por otro proceso.
43
+ """
44
+ try:
45
+ # SET key "1" NX (solo si no existe) EX ttl_seconds (expira automáticamente)
46
+ # Retorna True (si lo seteó) o None/False (si ya existía)
47
+ result = await self.redis.set(lock_key, "locked", nx=True, ex=ttl_seconds)
48
+ return bool(result)
49
+ except Exception as e:
50
+ logger.error(f"Error intentando adquirir lock de Redis para {lock_key}: {e}")
51
+ # En caso de error de Redis, asumimos False para evitar ejecución duplicada por dudas,
52
+ # o podríamos dejar que explote. En un scheduler, es más seguro no correr.
53
+ return False
54
+
55
+ async def release_lock(self, lock_key: str) -> None:
56
+ """
57
+ Libera el lock explícitamente.
58
+ """
59
+ try:
60
+ await self.redis.delete(lock_key)
61
+ except Exception as e:
62
+ logger.error(f"Error intentando liberar lock de Redis para {lock_key}: {e}")
@@ -1,7 +1,10 @@
1
1
  from __future__ import annotations
2
2
  import abc
3
3
  import typing as t
4
- from sqlalchemy.ext.asyncio import AsyncSession
4
+ try:
5
+ from sqlalchemy.ext.asyncio import AsyncSession
6
+ except ImportError:
7
+ class AsyncSession: ... # type: ignore
5
8
 
6
9
  from hexcore.domain.base import BaseEntity
7
10
  from hexcore.domain.repositories import IBaseRepository
@@ -12,12 +15,12 @@ T = t.TypeVar("T", bound=BaseEntity)
12
15
 
13
16
  class BaseSQLAlchemyRepository(IBaseRepository[T], abc.ABC, t.Generic[T]):
14
17
  def __init__(self, uow: IUnitOfWork):
15
- self._session: t.Optional[AsyncSession] = getattr(uow, "session", None)
18
+ self._session: t.Optional["AsyncSession"] = getattr(uow, "session", None)
16
19
 
17
20
  super().__init__(uow)
18
21
 
19
22
  @property
20
- def session(self) -> AsyncSession:
23
+ def session(self) -> "AsyncSession":
21
24
  if self._session is None:
22
25
  raise ValueError("El repositorio no está asociado a una sesión de base de datos.")
23
26
  return self._session