sandloom.sdk.effects¶
Effects of a unit: audit, commit hooks, the durable inbox, operation keys, sensitive effects.
Public API 1.0 (spec §2.2, Appendix C).
from sandloom.sdk.effects import <name>. Signatures in a terminal: sandloom docs <name>. Beginner names are the ones generated code and the examples use; any other name is advanced (mark its import line # sandloom: advanced <reason> in generated code and examples).
Start with (beginner)¶
| Name | Kind | Summary |
|---|---|---|
on_commit |
async function | Run callback only after the current Unit of Work COMMITS. |
record_audit |
function | Append an entry to the audit log within the current Unit of Work. |
Advanced¶
| Name | Kind | Summary |
|---|---|---|
before_commit |
async function | Run callback right BEFORE the current Unit of Work commits. |
durable_inbox |
function | Return a DurableConsumerInbox for exactly-once consumer effects. |
mark_reauthenticated |
function | Record a successful re-authentication in this request (the caller just verified the current password, or a just-completed sign-in): it sa... |
operation_key |
function | A derived operation key: ":".join(parts) with checked parts. |
run_async |
function | Run coro to completion on this process's persistent event loop. |
sensitive_effect |
function | Declare the risk of an effect service, once, at its source (ADR-0009 items 7-8). Stack it UNDER @classmethod / @staticmethod. |
Beginner names¶
on_commit¶
async def on_commit(callback: OnCommitCallback, *, database: str | None = None) -> None
Run callback only after the current Unit of Work COMMITS.
For side effects that live OUTSIDE the transaction (a cache write such as
revoke_user_tokens, a notification) and must not happen if the business
change rolls back. callback takes no arguments and may be sync or async
(use functools.partial / a closure to bind arguments).
- Inside a UoW (request
DbSession,db_session(), a contract call): queued on that UoW's session and run right after its commit — never on rollback, and never when it was registered inside abegin_nested()savepoint that rolled back.database=disambiguates when several databases are active (same rule aspublish_event). - Outside any UoW (a script, a test that owns its own transaction): there is no commit to wait for, so it runs immediately.
Callbacks run after the outbox's post-commit dispatch was handed off (on the request path the events may still be on their way to a networked broker — never order a callback against event delivery), in registration order. A failing callback is logged and does NOT fail the already-committed request (the change is durable; make the effect retryable if it matters). A callback runs under the tenant that was bound when it was queued (the UoW's / request's tenant scope has already closed by then).
record_audit¶
def record_audit(
action: str,
*,
actor_id: _uuid.UUID | str | None = None,
actor_type: str = 'user',
resource_type: str | None = None,
resource_id: str | None = None,
data: dict[str, Any] | None = None,
required: bool | None = None,
database: str | None = None,
) -> None
Append an entry to the audit log within the current Unit of Work.
Writes into the active request/task transaction, so the audit row commits iff the audited change commits (correct for auditing SUCCESSFUL actions). To audit a FAILED attempt that must survive a rollback, write it in a separate transaction opened after the request.
Modules call this by importing from sandloom.core.di — no ORM import needed.
Like publish_event, when an endpoint holds more than one database
session at once pass database= to choose which database's audit_log
the record lands in (so a billing action is audited in the billing database,
not whichever session happened to be innermost). With a single active
database it may be omitted; with several active and no choice the call
raises rather than auditing in the wrong database.
required defaults to True (since 0.7): no UoW scope, a backend
without an audit store, or a non-UUID actor_id string raise
AuditUnavailableError instead of dropping the record. Best-effort
is explicit — required=False skips (logged) in those cases; business
modules need an # sandloom: best-effort <reason> marker on such a call
(sandloom check rule best-effort-effects).
Advanced names¶
before_commit¶
async def before_commit(
callback: OnCommitCallback,
*,
database: str | None = None,
) -> None
Run callback right BEFORE the current Unit of Work commits.
A last-moment check whose failure must abort the transaction: the callback
(no arguments, sync or async) runs after the handler finished and before
COMMIT; if it raises, the UoW ROLLS BACK and the exception propagates
(a request answers with it — e.g. an HTTPException(401)). Used to
re-validate an authorization input that lives in ANOTHER service, where no
row lock can make the check atomic with the commit (ADR-0003 G2):
the commit-time re-check (spec §4.5) re-reads the caller's subject-state
record here, after the queued callbacks. It runs BEFORE the commit, not
atomically with it:
a change landing between the check and COMMIT is not seen (the check
narrows a window to that gap; it does not close it).
- Inside a UoW (request
DbSession,db_session(), a contract command): queued on its session, run in registration order before the commit; skipped when registered inside abegin_nested()savepoint that rolled back (likeon_commit).database=disambiguates several active databases. - Outside any UoW there is no commit to precede: it runs immediately.
Keep callbacks short and side-effect free (a read): they run while the transaction still holds its locks.
durable_inbox¶
def durable_inbox(consumer: str) -> Any
Return a DurableConsumerInbox for exactly-once consumer effects.
For money / orders / provisioning handlers: claim the event in the handler's own UoW session before applying the side effect:
inbox = durable_inbox("billing.credits@orders.paid.v1")
if not await inbox.claim(session, envelope["id"]):
return # duplicate delivery — effect already applied
Non-critical / idempotent handlers can keep using the cache-backed at-least-once dedupe instead.
mark_reauthenticated¶
def mark_reauthenticated() -> None
Record a successful re-authentication in this request (the caller just verified the current password, or a just-completed sign-in): it satisfies every step-up of the request (ADR-0009 item 8).
operation_key¶
def operation_key(*parts: object) -> str
A derived operation key: ":".join(parts) with checked parts.
The one rule for keys derived from business ids (ADR-0008 §3): every part
is non-empty and contains neither : nor whitespace, so two different
tuples can never produce the same key. Use it for any command a flow
issues more than once for the same business fact:
operation_key("enroll", enrollment_id, "charge") # "enroll:<id>:charge"
run_async¶
def run_async(coro: Coroutine[Any, Any, T]) -> T
Run coro to completion on this process's persistent event loop.
Use from synchronous code that has no running loop — a Celery task body,
a script. Inside async code await the coroutine instead.
sensitive_effect¶
def sensitive_effect(risk: Risk, *, step_up: int | None = None) -> Callable[[F], F]
Declare the risk of an effect service, once, at its source (ADR-0009
items 7-8). Stack it UNDER @classmethod / @staticmethod.
step_up (seconds) defaults to 300 for security and to none for
admin / money. See the module docstring for what the risk triggers.