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.
- {hexcore-2.4.0 → hexcore-2.5.0}/PKG-INFO +118 -11
- hexcore-2.4.0/hexcore.egg-info/PKG-INFO → hexcore-2.5.0/README.md +90 -28
- {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/application/cqrs/__init__.py +2 -0
- hexcore-2.5.0/hexcore/application/cqrs/scheduler.py +99 -0
- {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/domain/cqrs/__init__.py +5 -0
- hexcore-2.5.0/hexcore/domain/cqrs/cron.py +69 -0
- hexcore-2.5.0/hexcore/infrastructure/cqrs/postgres_lock.py +95 -0
- hexcore-2.5.0/hexcore/infrastructure/cqrs/redis_lock.py +62 -0
- {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/infrastructure/repositories/base.py +6 -3
- hexcore-2.5.0/hexcore/infrastructure/repositories/implementations.py +198 -0
- {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/infrastructure/repositories/utils.py +15 -6
- hexcore-2.5.0/hexcore/infrastructure/uow/__init__.py +189 -0
- {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/types.py +4 -1
- hexcore-2.4.0/README.md → hexcore-2.5.0/hexcore.egg-info/PKG-INFO +135 -0
- {hexcore-2.4.0 → hexcore-2.5.0}/hexcore.egg-info/SOURCES.txt +8 -0
- {hexcore-2.4.0 → hexcore-2.5.0}/hexcore.egg-info/requires.txt +30 -8
- {hexcore-2.4.0 → hexcore-2.5.0}/pyproject.toml +26 -10
- {hexcore-2.4.0 → hexcore-2.5.0}/tests/test_beanie_query_utils.py +2 -0
- {hexcore-2.4.0 → hexcore-2.5.0}/tests/test_infrastructure_query_path.py +3 -0
- hexcore-2.5.0/tests/test_optional_dependencies.py +50 -0
- hexcore-2.5.0/tests/test_postgres_lock.py +77 -0
- {hexcore-2.4.0 → hexcore-2.5.0}/tests/test_query_field_validation.py +2 -0
- hexcore-2.5.0/tests/test_redis_lock.py +69 -0
- {hexcore-2.4.0 → hexcore-2.5.0}/tests/test_repositories_utils.py +2 -0
- hexcore-2.5.0/tests/test_scheduler.py +115 -0
- {hexcore-2.4.0 → hexcore-2.5.0}/tests/test_uow_session_regression.py +2 -0
- {hexcore-2.4.0 → hexcore-2.5.0}/tests/test_use_cases_query.py +2 -0
- hexcore-2.4.0/hexcore/infrastructure/repositories/implementations.py +0 -212
- hexcore-2.4.0/hexcore/infrastructure/uow/__init__.py +0 -181
- {hexcore-2.4.0 → hexcore-2.5.0}/LICENSE +0 -0
- {hexcore-2.4.0 → hexcore-2.5.0}/MANIFEST.in +0 -0
- {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/__init__.py +0 -0
- {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/__main__.py +0 -0
- {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/application/__init__.py +0 -0
- {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/application/cqrs/adapters.py +0 -0
- {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/application/cqrs/config.py +0 -0
- {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/application/cqrs/factory.py +0 -0
- {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/application/cqrs/in_memory_buses.py +0 -0
- {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/application/cqrs/pipeline.py +0 -0
- {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/application/cqrs/registry.py +0 -0
- {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/application/dtos/__init__.py +0 -0
- {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/application/dtos/base.py +0 -0
- {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/application/dtos/query.py +0 -0
- {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/application/use_cases/__init__.py +0 -0
- {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/application/use_cases/base.py +0 -0
- {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/application/use_cases/query.py +0 -0
- {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/config.py +0 -0
- {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/domain/__init__.py +0 -0
- {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/domain/auth/__init__.py +0 -0
- {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/domain/auth/permissions.py +0 -0
- {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/domain/auth/value_objects.py +0 -0
- {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/domain/base.py +0 -0
- {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/domain/cqrs/buses.py +0 -0
- {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/domain/cqrs/commands.py +0 -0
- {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/domain/cqrs/decorators.py +0 -0
- {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/domain/cqrs/exceptions.py +0 -0
- {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/domain/cqrs/handlers.py +0 -0
- {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/domain/cqrs/middleware.py +0 -0
- {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/domain/cqrs/queries.py +0 -0
- {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/domain/cqrs/serializer.py +0 -0
- {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/domain/cqrs/task_queues.py +0 -0
- {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/domain/events.py +0 -0
- {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/domain/exceptions.py +0 -0
- {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/domain/repositories.py +0 -0
- {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/domain/services.py +0 -0
- {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/domain/uow.py +0 -0
- {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/infrastructure/__init__.py +0 -0
- {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/infrastructure/api/__init__.py +0 -0
- {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/infrastructure/api/utils.py +0 -0
- {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/infrastructure/cache/__init__.py +0 -0
- {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/infrastructure/cache/cache_backends/__init__.py +0 -0
- {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/infrastructure/cache/cache_backends/memory.py +0 -0
- {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/infrastructure/cache/cache_backends/redis.py +0 -0
- {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/infrastructure/cli.py +0 -0
- {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/infrastructure/cqrs/__init__.py +0 -0
- {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/infrastructure/cqrs/middlewares.py +0 -0
- {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/infrastructure/cqrs/postgres_bus.py +0 -0
- {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/infrastructure/cqrs/procrastinate.py +0 -0
- {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/infrastructure/cqrs/pydantic_serializer.py +0 -0
- {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/infrastructure/cqrs/rabbitmq.py +0 -0
- {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/infrastructure/cqrs/redis_bus.py +0 -0
- {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/infrastructure/events/__init__.py +0 -0
- {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/infrastructure/events/events_backends/__init__.py +0 -0
- {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/infrastructure/events/events_backends/memory.py +0 -0
- {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/infrastructure/repositories/__init__.py +0 -0
- {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/infrastructure/repositories/decorators.py +0 -0
- {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/infrastructure/repositories/orms/__init__.py +0 -0
- {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/infrastructure/repositories/orms/beanie/__init__.py +0 -0
- {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/infrastructure/repositories/orms/beanie/utils.py +0 -0
- {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/infrastructure/repositories/orms/sqlalchemy/__init__.py +0 -0
- {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/infrastructure/repositories/orms/sqlalchemy/session.py +0 -0
- {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/infrastructure/repositories/orms/sqlalchemy/utils.py +0 -0
- {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/infrastructure/task_queues/__init__.py +0 -0
- {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/infrastructure/task_queues/celery_adapter.py +0 -0
- {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/infrastructure/task_queues/procrastinate_adapter.py +0 -0
- {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/infrastructure/uow/decorators.py +0 -0
- {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/infrastructure/uow/helpers.py +0 -0
- {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/infrastructure/workers/__init__.py +0 -0
- {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/infrastructure/workers/consumer.py +0 -0
- {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/infrastructure/workers/rabbitmq_worker.py +0 -0
- {hexcore-2.4.0 → hexcore-2.5.0}/hexcore/py.typed +0 -0
- {hexcore-2.4.0 → hexcore-2.5.0}/hexcore.egg-info/dependency_links.txt +0 -0
- {hexcore-2.4.0 → hexcore-2.5.0}/hexcore.egg-info/entry_points.txt +0 -0
- {hexcore-2.4.0 → hexcore-2.5.0}/hexcore.egg-info/top_level.txt +0 -0
- {hexcore-2.4.0 → hexcore-2.5.0}/scripts/__init__.py +0 -0
- {hexcore-2.4.0 → hexcore-2.5.0}/scripts/main.py +0 -0
- {hexcore-2.4.0 → hexcore-2.5.0}/setup.cfg +0 -0
- {hexcore-2.4.0 → hexcore-2.5.0}/tests/conftest.py +0 -0
- {hexcore-2.4.0 → hexcore-2.5.0}/tests/test_basic.py +0 -0
- {hexcore-2.4.0 → hexcore-2.5.0}/tests/test_config_loading.py +0 -0
- {hexcore-2.4.0 → hexcore-2.5.0}/tests/test_cqrs.py +0 -0
- {hexcore-2.4.0 → hexcore-2.5.0}/tests/test_domain_service_query.py +0 -0
- {hexcore-2.4.0 → hexcore-2.5.0}/tests/test_postgres_bus.py +0 -0
- {hexcore-2.4.0 → hexcore-2.5.0}/tests/test_rabbitmq_bus.py +0 -0
- {hexcore-2.4.0 → hexcore-2.5.0}/tests/test_redis_bus.py +0 -0
- {hexcore-2.4.0 → hexcore-2.5.0}/tests/test_smart_routing.py +0 -0
- {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.
|
|
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:
|
|
11
|
-
Requires-Dist: alembic>=1.16.5
|
|
12
|
-
Requires-Dist: asyncpg>=0.30.0
|
|
13
|
-
Requires-Dist: beanie>=2.0.0
|
|
14
|
-
Requires-Dist: fastapi>=0.116.1
|
|
15
|
-
Requires-Dist: pika>=1.3.2
|
|
10
|
+
Requires-Dist: croniter>=2.0.0
|
|
16
11
|
Requires-Dist: pydantic>=2.11.7
|
|
17
|
-
Requires-Dist: redis>=6.4.0
|
|
18
|
-
Requires-Dist: sqlalchemy>=2.0.43
|
|
19
12
|
Requires-Dist: typer>=0.17.3
|
|
20
13
|
Requires-Dist: ruff>=0.12.11
|
|
21
|
-
Provides-Extra:
|
|
22
|
-
Requires-Dist:
|
|
14
|
+
Provides-Extra: api
|
|
15
|
+
Requires-Dist: fastapi>=0.116.1; extra == "api"
|
|
16
|
+
Provides-Extra: redis
|
|
17
|
+
Requires-Dist: redis>=6.4.0; extra == "redis"
|
|
18
|
+
Provides-Extra: mongo
|
|
19
|
+
Requires-Dist: beanie>=2.0.0; extra == "mongo"
|
|
20
|
+
Provides-Extra: sql
|
|
21
|
+
Requires-Dist: sqlalchemy>=2.0.43; extra == "sql"
|
|
22
|
+
Requires-Dist: alembic>=1.16.5; extra == "sql"
|
|
23
|
+
Requires-Dist: asyncpg>=0.30.0; extra == "sql"
|
|
24
|
+
Requires-Dist: aiosqlite>=0.21.0; extra == "sql"
|
|
23
25
|
Provides-Extra: rabbitmq
|
|
24
26
|
Requires-Dist: aio-pika>=9.4.0; extra == "rabbitmq"
|
|
27
|
+
Requires-Dist: pika>=1.3.2; extra == "rabbitmq"
|
|
28
|
+
Provides-Extra: procrastinate
|
|
29
|
+
Requires-Dist: procrastinate>=3.0.0; extra == "procrastinate"
|
|
25
30
|
Provides-Extra: celery
|
|
26
31
|
Requires-Dist: celery>=5.4.0; extra == "celery"
|
|
32
|
+
Provides-Extra: all
|
|
33
|
+
Requires-Dist: fastapi>=0.116.1; extra == "all"
|
|
34
|
+
Requires-Dist: redis>=6.4.0; extra == "all"
|
|
35
|
+
Requires-Dist: beanie>=2.0.0; extra == "all"
|
|
36
|
+
Requires-Dist: sqlalchemy>=2.0.43; extra == "all"
|
|
37
|
+
Requires-Dist: alembic>=1.16.5; extra == "all"
|
|
38
|
+
Requires-Dist: asyncpg>=0.30.0; extra == "all"
|
|
39
|
+
Requires-Dist: aiosqlite>=0.21.0; extra == "all"
|
|
40
|
+
Requires-Dist: aio-pika>=9.4.0; extra == "all"
|
|
41
|
+
Requires-Dist: pika>=1.3.2; extra == "all"
|
|
42
|
+
Requires-Dist: procrastinate>=3.0.0; extra == "all"
|
|
43
|
+
Requires-Dist: celery>=5.4.0; extra == "all"
|
|
27
44
|
Dynamic: license-file
|
|
28
45
|
|
|
29
46
|
# HexCore [](https://pepy.tech/projects/hexcore)
|
|
@@ -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 [](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
|
-
|
|
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
|