Skip to main content

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.

HexCore ships four event bus implementations that all satisfy the IEventBus / AbstractEventBus contract. Every bus exposes the same two-method interface (subscribe + publish) and supports Smart Routing — if an event handler carries the __cqrs_background_handler__ attribute (set by @background_handler), the bus automatically delegates execution to the configured ITaskEnqueuer instead of running the handler inline.

Shared interface

class IEventBus(abc.ABC):
    def subscribe(self, event_type: type[DomainEvent], handler: Callable) -> None: ...
    async def publish(self, event: DomainEvent) -> None: ...
Distributed buses additionally expose:
async def start_consuming(self) -> None: ...
async def stop(self) -> None: ...

Implementations

hexcore.application.cqrs.in_memory_buses.InMemoryEventBusPure in-process event bus with Smart Routing support. Handlers execute in the same asyncio event loop. Background handlers are delegated to the configured ITaskEnqueuer. This bus is the default value of ServerConfig.event_bus.

Constructor

InMemoryEventBus(
    pipeline: MiddlewarePipeline | None = None,
    enqueuer: ITaskEnqueuer | None = None,
    serializer: ISerializer | None = None,
)
pipeline
MiddlewarePipeline | None
default:"None"
Optional middleware pipeline that wraps every handler invocation. Defaults to an empty MiddlewarePipeline() when None.
enqueuer
ITaskEnqueuer | None
default:"None"
Task enqueuer required when any subscribed handler is decorated with @background_handler. If None and a background handler is called, a RuntimeError is raised.
serializer
ISerializer | None
default:"None"
Serialiser required when enqueuer is provided. Used to convert domain events to a wire-safe dict before enqueuing.

Methods

subscribe(event_type, handler)
None
Appends handler to the list of callables subscribed to event_type. Multiple handlers for the same event type are all called in registration order.
publish(event: DomainEvent)
Awaitable[None]
Dispatches event to every registered handler. Handlers with __cqrs_background_handler__ = True are routed to enqueuer.enqueue_handler(handler_name, payload, queue). All others are awaited inline through the middleware pipeline.

Smart Routing logic

for event_handler in handlers:
    is_background = getattr(event_handler, "__cqrs_background_handler__", False)

    if is_background:
        handler_name = getattr(event_handler, "__cqrs_handler_name__")
        queue_name   = getattr(event_handler, "__cqrs_queue__", "default")
        payload      = self._serializer.serialize(event)
        await self._enqueuer.enqueue_handler(handler_name, payload, queue=queue_name)
    else:
        await event_handler(event)  # via pipeline

Example

from hexcore.application.cqrs.in_memory_buses import InMemoryEventBus
from hexcore.infrastructure.cqrs.pydantic_serializer import PydanticSerializer
from myapp.celery_app import celery_enqueuer

bus = InMemoryEventBus(
    enqueuer=celery_enqueuer,
    serializer=PydanticSerializer(),
)

bus.subscribe(UserCreatedEvent, on_user_created)

await bus.publish(UserCreatedEvent(user_id=user.id))

ILockProvider

hexcore.domain.cqrs.cron.ILockProvider Abstract distributed lock interface used by DynamicScheduler to prevent duplicate cron job execution across multiple replicas.
class ILockProvider(abc.ABC):
    @abc.abstractmethod
    async def acquire_lock(self, lock_key: str, ttl_seconds: int) -> bool: ...

    @abc.abstractmethod
    async def release_lock(self, lock_key: str) -> None: ...
acquire_lock(lock_key: str, ttl_seconds: int)
Awaitable[bool]
Attempts to acquire the lock identified by lock_key. Returns True if the lock was acquired (no other holder), False if already locked. The lock expires automatically after ttl_seconds to prevent deadlocks.
release_lock(lock_key: str)
Awaitable[None]
Explicitly releases the lock before its TTL expires. Optional if TTL-based expiry is acceptable.

RedisLockProvider

hexcore.infrastructure.cqrs.redis_lock.RedisLockProvider Implements ILockProvider using Redis SET key value NX EX ttl for atomic, TTL-enforced mutual exclusion.

Constructor

RedisLockProvider(redis_client: "redis.asyncio.Redis")
redis_client
redis.asyncio.Redis
required
An active redis.asyncio.Redis instance.
import redis.asyncio as aioredis
from hexcore.infrastructure.cqrs.redis_lock import RedisLockProvider

redis_client = aioredis.Redis.from_url("redis://localhost:6379/0")
lock_provider = RedisLockProvider(redis_client=redis_client)

acquired = await lock_provider.acquire_lock("cron:daily_report:2024-01-15T02:00:00", ttl_seconds=300)
if acquired:
    # run the job
    await lock_provider.release_lock("cron:daily_report:2024-01-15T02:00:00")

PostgresLockProvider

hexcore.infrastructure.cqrs.postgres_lock.PostgresLockProvider Implements ILockProvider using a dedicated PostgreSQL table (hexcore_cron_locks) with UPSERT-based conditional locking.

Constructor

PostgresLockProvider(
    pool: "asyncpg.Pool | asyncpg.Connection",
    table_name: str = "hexcore_cron_locks",
)
pool
asyncpg.Pool | asyncpg.Connection
required
An active asyncpg pool or single connection.
table_name
str
default:"\"hexcore_cron_locks\""
Name of the lock table. Customise when sharing a schema with strict naming conventions.

setup()

async def setup(self) -> None
Creates the hexcore_cron_locks table if it does not exist. Must be called once at application startup before any lock is acquired.
import asyncpg
from hexcore.infrastructure.cqrs.postgres_lock import PostgresLockProvider

pool = await asyncpg.create_pool("postgresql://user:pass@localhost/mydb")
lock_provider = PostgresLockProvider(pool=pool)
await lock_provider.setup()   # create table if needed

acquired = await lock_provider.acquire_lock("cron:cleanup:2024-01-15T03:00:00", ttl_seconds=120)
PostgresLockProvider uses an INSERT ... ON CONFLICT DO UPDATE ... WHERE expires_at < NOW() query. This means an expired lock is automatically taken over by the first new acquirer without requiring an explicit release_lock().

Build docs developers (and LLMs) love