Documentation Index
Fetch the complete documentation index at: https://mintlify.com/Indroic/HexCore/llms.txt
Use this file to discover all available pages before exploring further.
DynamicScheduler evaluates a database-driven list of cron jobs on every tick and enqueues due tasks through ITaskEnqueuer. Because the schedule is stored in a database rather than hardcoded in configuration, you can add, modify, enable, or disable cron jobs at runtime — with zero application restarts. An optional ILockProvider (backed by Redis or PostgreSQL) prevents duplicate execution across multiple replicas.
Domain contracts
CronJobDefinition
A dataclass that describes a single scheduled task, read from your database by ICronJobRepository:
Unique identifier for the job. Used as part of the distributed lock key.
Fully-qualified function name of the task to enqueue (e.g.
"app.tasks.generate_report"). The worker resolves this via importlib at execution time.Standard five-field cron expression (e.g.
"0 * * * *" for hourly). Evaluated using the croniter library.Arbitrary keyword arguments forwarded to the task function when it is invoked.
Name of the task queue to route this job to.
When
False, the scheduler skips this job entirely on the next tick.Tracks the last execution time. The scheduler uses this to avoid re-enqueueing within the same tick. Updated by
ICronJobRepository.update_last_run() after successful enqueue.ICronJobRepository
Your application must implement this interface to feed jobs to the scheduler. Any ORM (SQLAlchemy, Beanie, Tortoise) works.
ILockProvider
DynamicScheduler
Your implementation that reads active jobs from the database. Called on every tick so the schedule is always fresh.
Task enqueuer used to dispatch due jobs. The job’s
task_name and payload are forwarded directly via enqueuer.enqueue_task().Optional distributed lock provider. When set, the scheduler acquires a per-job-per-minute lock before enqueueing. If the lock is already held (by another replica), the job is skipped silently for that tick.
How often (in seconds) the scheduler wakes up to evaluate jobs. Set to
60 to match the smallest meaningful cron granularity.How the tick loop works
On each tick the scheduler:- Reads all active jobs via
repository.get_active_jobs()— always fresh from the database. - For each job, checks if
croniter.croniter.match(cron_expression, current_time)returnsTrue. - If a lock provider is configured, attempts to acquire
hexcore:cron_lock:{job_id}:{minute_iso}. Skips the job if the lock is already held. - Calls
enqueuer.enqueue_task(task_name, payload, queue). - Calls
repository.update_last_run(job_id, current_time)to record execution.
croniter must be installed separately: pip install croniter. It is listed as an optional dependency of HexCore.Implementing ICronJobRepository
- SQLAlchemy
- Beanie (MongoDB)
Distributed lock providers
When you run multiple replicas of your application (e.g. Kubernetes pods), multipleDynamicScheduler instances may wake up at the same second. Without a lock, the same job gets enqueued multiple times. Inject a ILockProvider to prevent this.
- RedisLockProvider
- PostgresLockProvider
Uses Redis The lock key format is
SET NX EX — atomic, TTL-based, no deadlocks.An active async Redis client instance.
hexcore:cron_lock:{job_id}:{minute_iso}, where minute_iso is the current UTC time truncated to the minute. The TTL is 60 seconds, guaranteeing the lock expires even if the scheduler crashes mid-tick.Full wiring example with FastAPI lifespan
# app/tasks.py
from hexcore.domain.cqrs.decorators import background_task
@background_task(queue="reports")
async def generate_daily_summary(region: str) -> None:
# This function runs inside the Celery worker
await build_and_email_summary(region)
# app/scheduler/setup.py
from hexcore.application.cqrs.scheduler import DynamicScheduler
from hexcore.infrastructure.cqrs.redis_lock import RedisLockProvider
from app.scheduler.repository import SqlCronJobRepository
from app.celery_app import celery_enqueuer
import redis.asyncio as aioredis
from sqlalchemy.ext.asyncio import AsyncSession
def create_scheduler(session: AsyncSession) -> DynamicScheduler:
redis_client = aioredis.from_url("redis://localhost:6379")
return DynamicScheduler(
repository=SqlCronJobRepository(session),
enqueuer=celery_enqueuer,
lock_provider=RedisLockProvider(redis_client),
tick_interval_seconds=60,
)
# app/main.py
import asyncio
from contextlib import asynccontextmanager
from fastapi import FastAPI
from app.db import get_session
from app.scheduler.setup import create_scheduler
@asynccontextmanager
async def lifespan(app: FastAPI):
# Start scheduler in background task
async with get_session() as session:
scheduler = create_scheduler(session)
scheduler_task = asyncio.create_task(scheduler.start())
yield # Application is running
# Graceful shutdown
scheduler.stop()
scheduler_task.cancel()
try:
await scheduler_task
except asyncio.CancelledError:
pass
app = FastAPI(lifespan=lifespan)
INSERT INTO cron_jobs (id, task_name, cron_expression, payload, queue, is_active)
VALUES
('daily-summary-eu', 'app.tasks.generate_daily_summary', '0 6 * * *', '{"region": "EU"}', 'reports', true),
('daily-summary-us', 'app.tasks.generate_daily_summary', '0 14 * * *', '{"region": "US"}', 'reports', true);