Source code for loom.core.engine.post_commit
"""Post-commit channel: actions that run once a transaction has committed.
The executor binds one :class:`PostCommitChannel` per execution that owns a
unit of work (or when no channel is bound) and drains it after the commit.
Actions enqueued while a channel is bound belong to that channel; a
failed transaction discards them. The owner unbinds the channel before
draining, so an execution started from an action opens its own lifecycle.
"""
from __future__ import annotations
import asyncio
import collections
import inspect
from collections.abc import Awaitable, Callable
from contextvars import ContextVar, Token
from loom.core.errors import LoomError
from loom.core.errors.codes import ErrorCode
PostCommitAction = Callable[[], Awaitable[None] | None]
"""Zero-argument callable: sync (returning ``None`` or an awaitable) or async."""
_channel: ContextVar[PostCommitChannel | None] = ContextVar(
"_loom_post_commit_channel", default=None
)
[docs]
class PostCommitError(LoomError):
"""Raised when one or more post-commit actions failed after a commit.
Every action runs even when an earlier one fails; the error carries all
failures in enqueue order.
Args:
committed: Whether the transaction had committed when the actions ran.
failures: Exceptions raised by the failed actions, in enqueue order.
"""
def __init__(self, *, committed: bool, failures: tuple[Exception, ...]) -> None:
self.committed = committed
self.failures = failures
super().__init__(
f"{len(failures)} post-commit action(s) failed (committed={committed})",
code=ErrorCode.POST_COMMIT_FAILURE,
)
[docs]
class PostCommitChannel:
"""Ordered queue of actions to run after the owning transaction commits.
Two priorities, each FIFO among itself: :meth:`enqueue_priority` runs an
action ahead of every plain :meth:`enqueue` action, regardless of the
order the two calls were made in. Two actions of the same priority keep
the order they were queued in. This only orders actions queued on *this*
channel: a caller with more than one path to the same kind of action —
:class:`~loom.core.cache.repository.CachedRepository` queues its own
write's bump through :meth:`enqueue_priority` but the ``@transactional``
hook walk queues the mixins' tagged bump through plain :meth:`enqueue` —
decides per call site which lane it belongs in; the channel does not
infer that from what the action does.
Example::
channel = PostCommitChannel()
channel.enqueue(lambda: broker.send(message))
await channel.drain(committed=True)
"""
__slots__ = ("_actions", "_priority_actions")
def __init__(self) -> None:
self._priority_actions: list[PostCommitAction] = []
self._actions: list[PostCommitAction] = []
[docs]
def enqueue(self, action: PostCommitAction) -> None:
"""Append an action; it runs after every :meth:`enqueue_priority` action.
Args:
action: Sync callable returning ``None`` or an awaitable, or an
async callable.
"""
self._actions.append(action)
[docs]
def enqueue_priority(self, action: PostCommitAction) -> None:
"""Queue an action ahead of every plain :meth:`enqueue` action.
Use this for an action other queued actions may depend on having
already run — a cache invalidation ahead of the job dispatches that
might read the cache it invalidates. Two actions queued this way
still run in the order they were queued.
Args:
action: Sync callable returning ``None`` or an awaitable, or an
async callable.
"""
self._priority_actions.append(action)
[docs]
def discard(self) -> None:
"""Drop every queued action without running it."""
self._priority_actions.clear()
self._actions.clear()
[docs]
async def drain(self, *, committed: bool) -> None:
"""Run every queued action: the priority lane shielded, then the plain lane.
The owner unbinds the channel before draining, so an execution
started from an action opens its own lifecycle. A failing action
does not stop the others, in either lane; their failures collect
into one :class:`PostCommitError`.
The two lanes carry different durability stories, so they are run
differently:
* A priority action (:meth:`enqueue_priority`) describes a write
that has *already committed* — that is the whole point of
deferring it here rather than running it inline. It runs under
``asyncio.shield``: a cancellation reaching this call cannot
interrupt it, so it always completes even if the caller gives up
on waiting. It is expected to be cheap (a handful of cache
``incr`` calls), which is what makes the shield safe to take
unconditionally.
* A plain action (:meth:`enqueue`) — a job dispatch — has its own
durability story and no such requirement, so it stays exactly as
interruptible as before the priority lane existed: a cancellation
arriving while the plain lane runs stops the drain at once, and
the plain actions not yet run stay queued so a later drain can run
them. In inline job mode a dispatched job's body is awaited here
as a full nested use-case execution
(:class:`~loom.core.job.service._PendingDispatch.run`), with no
framework-imposed timeout — shielding it, as the priority lane
is shielded, would make it uncancellable for as long as it ran;
this is why the two lanes are not shielded alike.
Args:
committed: Whether a transaction of this owner's had committed
when the actions ran. ``False`` when the owner held no unit
of work, so a caller may safely retry the whole operation.
Raises:
PostCommitError: If any action raised; carries every failure.
"""
priority_pending = collections.deque(self._priority_actions)
self._priority_actions = []
failures: list[Exception] = []
await asyncio.shield(_run_all(priority_pending, failures))
pending = collections.deque(self._actions)
self._actions = []
try:
await _run_all(pending, failures)
except BaseException:
# The priority lane cannot appear here: it already ran to
# completion, shielded, above. Only the plain lane's remainder
# needs to survive a cancellation for a later drain to run it.
self._actions = [*pending, *self._actions]
raise
if failures:
raise PostCommitError(committed=committed, failures=tuple(failures))
async def _run_all(pending: collections.deque[PostCommitAction], failures: list[Exception]) -> None:
"""Pop and run ``pending`` in order; an interrupted action is not retried."""
while pending:
action = pending.popleft()
try:
await _run_one(action)
except Exception as exc: # collected and re-raised together by drain
failures.append(exc)
async def _run_one(action: PostCommitAction) -> None:
result = action()
if inspect.isawaitable(result):
await result
def bind_channel(channel: PostCommitChannel) -> Token[PostCommitChannel | None]:
"""Make ``channel`` the active channel for the current context.
Args:
channel: Channel that receives subsequent enqueues.
Returns:
Token to pass to :func:`reset_channel`.
"""
return _channel.set(channel)
def reset_channel(token: Token[PostCommitChannel | None]) -> None:
"""Restore the channel that was active before :func:`bind_channel`.
Args:
token: Token returned by :func:`bind_channel`.
"""
_channel.reset(token)
def active_channel() -> PostCommitChannel | None:
"""Return the channel bound to the current context, if any."""
return _channel.get()