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.
- {hexcore-3.0.0/hexcore.egg-info → hexcore-5.0.0}/PKG-INFO +155 -64
- {hexcore-3.0.0 → hexcore-5.0.0}/README.md +154 -63
- {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/application/cqrs/__init__.py +3 -3
- {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/application/cqrs/config.py +13 -12
- {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/application/cqrs/in_memory_buses.py +7 -3
- hexcore-5.0.0/hexcore/application/dtos/cursor.py +110 -0
- hexcore-5.0.0/hexcore/application/dtos/errors.py +30 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/config.py +5 -0
- hexcore-5.0.0/hexcore/cqrs.py +144 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/domain/services.py +12 -2
- hexcore-5.0.0/hexcore/fastapi.py +122 -0
- hexcore-5.0.0/hexcore/infrastructure/api/__init__.py +76 -0
- hexcore-5.0.0/hexcore/infrastructure/api/app.py +131 -0
- hexcore-5.0.0/hexcore/infrastructure/api/cqrs.py +195 -0
- hexcore-5.0.0/hexcore/infrastructure/api/exception_handlers.py +131 -0
- hexcore-5.0.0/hexcore/infrastructure/api/health.py +255 -0
- hexcore-5.0.0/hexcore/infrastructure/api/lifespan.py +340 -0
- hexcore-5.0.0/hexcore/infrastructure/api/middlewares.py +142 -0
- hexcore-5.0.0/hexcore/infrastructure/api/rate_limit.py +165 -0
- hexcore-5.0.0/hexcore/infrastructure/api/routing.py +87 -0
- hexcore-5.0.0/hexcore/infrastructure/api/streaming.py +293 -0
- hexcore-5.0.0/hexcore/infrastructure/api/utils.py +346 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/infrastructure/cli.py +15 -15
- hexcore-5.0.0/hexcore/infrastructure/cqrs/__init__.py +29 -0
- hexcore-5.0.0/hexcore/infrastructure/cqrs/cron_sql.py +340 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/infrastructure/cqrs/middlewares.py +44 -1
- {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/infrastructure/repositories/implementations.py +24 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/infrastructure/repositories/orms/beanie/utils.py +3 -1
- {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/infrastructure/repositories/orms/sqlalchemy/utils.py +140 -1
- {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/infrastructure/task_queues/celery_adapter.py +103 -5
- hexcore-5.0.0/hexcore/infrastructure/workers/__init__.py +19 -0
- hexcore-5.0.0/hexcore/infrastructure/workers/runner.py +349 -0
- hexcore-5.0.0/hexcore/sql.py +95 -0
- hexcore-5.0.0/hexcore/testing/__init__.py +27 -0
- hexcore-5.0.0/hexcore/testing/fakes.py +175 -0
- hexcore-5.0.0/hexcore/testing/fixtures.py +105 -0
- hexcore-5.0.0/hexcore/testing/helpers.py +147 -0
- {hexcore-3.0.0 → hexcore-5.0.0/hexcore.egg-info}/PKG-INFO +155 -64
- {hexcore-3.0.0 → hexcore-5.0.0}/hexcore.egg-info/SOURCES.txt +35 -1
- {hexcore-3.0.0 → hexcore-5.0.0}/pyproject.toml +4 -1
- hexcore-5.0.0/tests/test_api_app_and_lifespan.py +428 -0
- hexcore-5.0.0/tests/test_api_cqrs_providers.py +197 -0
- hexcore-5.0.0/tests/test_api_exception_handlers.py +151 -0
- hexcore-5.0.0/tests/test_api_health.py +220 -0
- hexcore-5.0.0/tests/test_api_middlewares_and_routing.py +308 -0
- hexcore-5.0.0/tests/test_api_rate_limit.py +228 -0
- hexcore-5.0.0/tests/test_api_streaming.py +367 -0
- hexcore-5.0.0/tests/test_celery_event_loop.py +215 -0
- hexcore-5.0.0/tests/test_cqrs_middlewares.py +149 -0
- hexcore-5.0.0/tests/test_cron_sql.py +351 -0
- hexcore-5.0.0/tests/test_documentation_examples.py +398 -0
- hexcore-5.0.0/tests/test_facades.py +180 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/tests/test_optional_dependencies.py +7 -0
- hexcore-5.0.0/tests/test_query_cursor_and_endpoint.py +504 -0
- hexcore-5.0.0/tests/test_testing_utilities.py +330 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/tests/test_use_cases_query.py +3 -1
- {hexcore-3.0.0 → hexcore-5.0.0}/tests/test_worker_bus_integration.py +38 -0
- hexcore-5.0.0/tests/test_worker_runner.py +363 -0
- hexcore-3.0.0/hexcore/infrastructure/api/__init__.py +0 -3
- hexcore-3.0.0/hexcore/infrastructure/api/utils.py +0 -195
- hexcore-3.0.0/hexcore/infrastructure/cqrs/__init__.py +0 -24
- hexcore-3.0.0/hexcore/infrastructure/workers/__init__.py +0 -1
- hexcore-3.0.0/tests/test_cqrs_middlewares.py +0 -61
- {hexcore-3.0.0 → hexcore-5.0.0}/LICENSE +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/MANIFEST.in +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/__init__.py +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/__main__.py +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/application/__init__.py +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/application/cqrs/adapters.py +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/application/cqrs/factory.py +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/application/cqrs/pipeline.py +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/application/cqrs/registry.py +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/application/cqrs/scheduler.py +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/application/dtos/__init__.py +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/application/dtos/base.py +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/application/dtos/query.py +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/application/use_cases/__init__.py +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/application/use_cases/base.py +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/application/use_cases/query.py +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/domain/__init__.py +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/domain/auth/__init__.py +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/domain/auth/permissions.py +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/domain/auth/value_objects.py +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/domain/base.py +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/domain/cqrs/__init__.py +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/domain/cqrs/buses.py +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/domain/cqrs/commands.py +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/domain/cqrs/context.py +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/domain/cqrs/cron.py +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/domain/cqrs/decorators.py +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/domain/cqrs/exceptions.py +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/domain/cqrs/handlers.py +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/domain/cqrs/middleware.py +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/domain/cqrs/queries.py +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/domain/cqrs/resolution.py +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/domain/cqrs/serializer.py +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/domain/cqrs/task_queues.py +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/domain/events.py +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/domain/exceptions.py +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/domain/repositories.py +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/domain/uow.py +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/infrastructure/__init__.py +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/infrastructure/cache/__init__.py +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/infrastructure/cache/cache_backends/__init__.py +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/infrastructure/cache/cache_backends/memory.py +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/infrastructure/cache/cache_backends/redis.py +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/infrastructure/cqrs/postgres_bus.py +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/infrastructure/cqrs/postgres_lock.py +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/infrastructure/cqrs/procrastinate.py +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/infrastructure/cqrs/pydantic_serializer.py +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/infrastructure/cqrs/rabbitmq.py +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/infrastructure/cqrs/redis_bus.py +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/infrastructure/cqrs/redis_lock.py +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/infrastructure/events/__init__.py +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/infrastructure/events/events_backends/__init__.py +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/infrastructure/events/events_backends/memory.py +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/infrastructure/repositories/__init__.py +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/infrastructure/repositories/base.py +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/infrastructure/repositories/decorators.py +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/infrastructure/repositories/orms/__init__.py +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/infrastructure/repositories/orms/beanie/__init__.py +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/infrastructure/repositories/orms/sqlalchemy/__init__.py +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/infrastructure/repositories/orms/sqlalchemy/session.py +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/infrastructure/repositories/utils.py +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/infrastructure/task_queues/__init__.py +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/infrastructure/task_queues/procrastinate_adapter.py +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/infrastructure/uow/__init__.py +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/infrastructure/uow/decorators.py +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/infrastructure/uow/helpers.py +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/infrastructure/uow/scopes.py +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/infrastructure/workers/consumer.py +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/infrastructure/workers/rabbitmq_worker.py +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/py.typed +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/hexcore/types.py +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/hexcore.egg-info/dependency_links.txt +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/hexcore.egg-info/entry_points.txt +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/hexcore.egg-info/requires.txt +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/hexcore.egg-info/top_level.txt +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/scripts/__init__.py +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/scripts/main.py +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/setup.cfg +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/tests/conftest.py +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/tests/test_basic.py +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/tests/test_beanie_query_utils.py +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/tests/test_config_loading.py +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/tests/test_consumer_optional_event_bus.py +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/tests/test_cqrs.py +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/tests/test_cqrs_factory.py +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/tests/test_domain_service_query.py +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/tests/test_dotted_resolution.py +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/tests/test_handler_registry.py +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/tests/test_infrastructure_query_path.py +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/tests/test_lock_error_policy.py +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/tests/test_postgres_bus.py +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/tests/test_postgres_lock.py +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/tests/test_query_field_validation.py +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/tests/test_rabbitmq_bus.py +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/tests/test_redis_bus.py +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/tests/test_redis_lock.py +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/tests/test_repositories_utils.py +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/tests/test_scheduler.py +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/tests/test_scheduler_catchup.py +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/tests/test_smart_routing.py +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/tests/test_sql_session_layer.py +0 -0
- {hexcore-3.0.0 → hexcore-5.0.0}/tests/test_task_queues_adapters.py +0 -0
- {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
|
+
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
|
-
|
|
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
|
-
|
|
352
|
+
...
|
|
351
353
|
|
|
352
354
|
# 2. Lo registras en el registry de CQRS utilizando el adaptador
|
|
353
|
-
registry = HandlerRegistry()
|
|
354
|
-
registry.
|
|
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.
|
|
540
|
+
#### 2. Usar un Enqueuer (Adaptador)
|
|
528
541
|
|
|
529
|
-
|
|
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.
|
|
547
|
+
from hexcore.infrastructure.task_queues.procrastinate_adapter import ProcrastinateEnqueuer
|
|
533
548
|
|
|
534
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
615
|
+
import hexcore.cqrs as cqrs
|
|
600
616
|
from hexcore.infrastructure.task_queues.celery_adapter import register_hexcore_celery_tasks
|
|
601
617
|
|
|
602
|
-
consumer
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
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
|
-
|
|
609
|
-
|
|
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.
|
|
619
|
-
|
|
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
|
-
|
|
626
|
-
|
|
627
|
-
|
|
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="
|
|
631
|
-
task_name="
|
|
632
|
-
cron_expression="*/5 * * * *",
|
|
633
|
-
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
|
-
#
|
|
639
|
-
|
|
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
|
-
|
|
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
|
-
|
|
660
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
307
|
+
...
|
|
306
308
|
|
|
307
309
|
# 2. Lo registras en el registry de CQRS utilizando el adaptador
|
|
308
|
-
registry = HandlerRegistry()
|
|
309
|
-
registry.
|
|
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.
|
|
495
|
+
#### 2. Usar un Enqueuer (Adaptador)
|
|
483
496
|
|
|
484
|
-
|
|
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.
|
|
502
|
+
from hexcore.infrastructure.task_queues.procrastinate_adapter import ProcrastinateEnqueuer
|
|
488
503
|
|
|
489
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
570
|
+
import hexcore.cqrs as cqrs
|
|
555
571
|
from hexcore.infrastructure.task_queues.celery_adapter import register_hexcore_celery_tasks
|
|
556
572
|
|
|
557
|
-
consumer
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
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
|
-
|
|
564
|
-
|
|
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.
|
|
574
|
-
|
|
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
|
-
|
|
581
|
-
|
|
582
|
-
|
|
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="
|
|
586
|
-
task_name="
|
|
587
|
-
cron_expression="*/5 * * * *",
|
|
588
|
-
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
|
-
#
|
|
594
|
-
|
|
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
|
-
|
|
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
|
-
|
|
615
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
12
|
-
"""
|
|
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
|
-
|
|
22
|
-
"""
|
|
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={"
|
|
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
|