Outbox and relay¶
- class alchemiq.OutboxEvent(**kwargs)[source]¶
Bases:
ModelPersistent outbox row storing a domain event awaiting broker delivery.
Status lifecycle:
pending->published(success) orfailed(delivery error, retryable) ->dead(max_attemptsreached).See also
Relay- background worker that drains this table.
- class alchemiq.OutboxMessage(id, topic, payload, headers, aggregate_type, aggregate_id, event_type)[source]¶
Bases:
objectImmutable, broker-agnostic view of one outbox row handed to a
Publisher.Produced from an
OutboxEventrow by theRelaybefore each delivery attempt. Consumers should deduplicate onid.
- class alchemiq.Publisher(*args, **kwargs)[source]¶
Bases:
ProtocolStructural protocol for outbox delivery adapters.
Implementations must expose
publish(message). An optionalpublish_batch(messages)method is detected by duck-typing when present; it is not part of this protocol contract, so any object with justpublishsatisfiesisinstance(obj, Publisher).E.g.:
from alchemiq.outbox import Publisher, TransientPublishError from alchemiq.outbox.message import OutboxMessage class MyBrokerPublisher: async def publish(self, message: OutboxMessage) -> None: try: await broker.send(message.topic, message.payload) except BrokerConnectionError as e: raise TransientPublishError(str(e)) from e
See also
Relay- background loop that calls this protocol.- async publish(message)[source]¶
Deliver a single message to the broker.
Raise
TransientPublishErrorfor connection failures; the relay backs off without incrementingattempts. Raise any other exception to poison the row (incrementattempts; markfailedordead).- Parameters:
message (OutboxMessage) – the outbox row projected to a broker-agnostic value object.
- Raises:
TransientPublishError – for transient broker connection failures.
- Return type:
None
- class alchemiq.Relay(publisher, *, batch_size=100, poll_interval=1.0, max_attempts=5, error_backoff=5.0)[source]¶
Bases:
objectBackground relay that drains the outbox table and delivers rows to a broker.
Each cycle claims a batch with
FOR UPDATE SKIP LOCKED(safe for multiple concurrent relay workers), publishes each row, and records the outcome within the same transaction. Bothpendingandfailedrows are claimed and re-attempted every cycle until they reachdead.Delivery semantics: at-least-once - consumers must deduplicate on
OutboxMessage.id.Error taxonomy:
TransientPublishError-> whole-batch rollback;attemptsis not incremented; relay sleepserror_backoffseconds before the next cycle.Any other exception (per-message path) -> per-event poison:
attemptsincremented; status set tofailedordead(whenattempts >= max_attempts).Any other exception (
publish_batchpath) -> whole-batch poison: all rows in the claimed batch are poisoned together (not row-by-row).
E.g.:
import asyncio from alchemiq.outbox import Relay relay = Relay(my_publisher, batch_size=50, poll_interval=2.0, max_attempts=5) # run in a background task: task = asyncio.create_task(relay.run()) # ... on shutdown: relay.stop() await task
- Parameters:
publisher (Publisher) – delivery adapter satisfying the
Publisherprotocol.batch_size (int) – maximum rows claimed per cycle (default 100).
poll_interval (float) – seconds to wait between cycles when the batch was not full (default 1.0).
max_attempts (int) – delivery attempts before a row is marked
dead(default 5).error_backoff (float) – seconds to sleep after a transient broker error (default 5.0).
See also
Publisher- the delivery protocol.publish()- manual event emission outside of a model signal.- async run()[source]¶
Start the relay polling loop; blocks until
stop()is called or cancelled.Spawns no threads. Designed to run as an
asynciotask. Cancellation is handled gracefully: the stopping event is set andCancelledErroris re-raised.Note
Requires alchemiq to be configured (
configurecalled) beforerun()is awaited.- Return type:
None
- async alchemiq.publish(topic, payload, *, key=None, headers=None)[source]¶
Write an outbox event row in its own autocommit transaction.
Use this for manual event emission (e.g. from a background task or CLI) when no model signal is firing. To tie the event to a business transaction, open a
UnitOfWorkaround the call.E.g.:
from alchemiq import publish await publish("user.signed_up", {"id": 1, "email": "ada@x.io"}) # with a Pydantic payload: await publish("billing.upgraded", BillingUpgraded(plan="pro"), key="user-42")
- Parameters:
topic (str) – broker routing key (e.g.
"user.signed_up").payload (dict[str, Any] | BaseModel) – event data - a plain
dictor a PydanticBaseModel.key (str | None) – stored as
aggregate_id; typically used as the broker partition key.headers (dict[str, Any] | None) – optional broker-level headers dict.
- Return type:
None
See also
Relay- background worker that picks up and delivers these rows.
- exception alchemiq.PublishError[source]¶
Bases:
ExceptionBase exception for publisher delivery errors.
- exception alchemiq.TransientPublishError[source]¶
Bases:
PublishErrorBroker unreachable or connection lost.
When raised by a
Publisher, theRelayrolls back the whole batch without incrementingattemptsand sleepserror_backoffseconds before retrying. Use this for temporary infrastructure failures, not logical errors.