Files
kanta/kanta/transaction.py
T
LeoVasanko a41f34d332 Route all diagnostic logging through the plain "kanta" logger.
Module loggers used __name__, splitting diagnostics across eight
module-named loggers and colliding kanta.transaction with the
transaction event channel.  Diagnostics (integrity errors, flush
failures, rotation notes) are few; they now all go through the
"kanta" logger, following the application's root logging
configuration like any ordinary library output.
2026-09-16 02:16:58 +00:00

149 lines
5.0 KiB
Python

"""Transaction context manager for Kanta."""
from __future__ import annotations
import logging
from contextlib import contextmanager
from datetime import datetime
from typing import Any
from kanta.diff import diff
from kanta.exceptions import DataIntegrityError
from kanta.callbacks import InjectionContext
from kanta.logging import _USER_PATH, LogEvent, emit_event, transaction_logger
from kanta.serialization import restore_data_in_place, struct_to_dict
_logger = logging.getLogger("kanta")
def _build_logfmt(impl, previous: dict, current: dict):
"""Build the logfmt chain for a state transition."""
return impl.callback_registry.build_logfmt(
InjectionContext(
previous_state=previous,
current_state=current,
kanta=impl._kanta,
)
)
def _resolve_user(logfmt, user: str | None) -> str | None:
"""Resolve *user* for display via the logfmt chain (raw as fallback)."""
if user is None:
return None
resolved = logfmt(user, _USER_PATH)
return resolved if resolved is not None else user
@contextmanager
def transaction(
impl,
action: str,
*,
user: str | None = None,
extra: Any = None,
mtime: bool | datetime = True,
log: bool | logging.Logger = True,
logdiff: bool = True,
):
"""Wrap writes in a transaction and yield the live db object."""
if impl.readonly:
raise DataIntegrityError(
"Cannot start transaction in read-only mode",
db_path=impl.filename,
action=action,
)
if impl.in_transaction:
raise RuntimeError(
"Nested or simultaneous transactions are not supported "
"(don't await inside transactions)."
)
current_dict = struct_to_dict(impl.data, serializer=impl.serializer)
if current_dict != impl.statedict:
is_bootstrap = action in {"bootstrap"}
if not (is_bootstrap and not impl.statedict):
delta = diff(impl.statedict, current_dict)
if delta:
_logger.critical(
"Database state modified outside of transaction! "
"This indicates a bug where changes occurred without a transaction wrapper.\n"
"Changes detected: %s",
delta,
)
raise DataIntegrityError(
"Database state modified outside of transaction",
db_path=impl.db_path,
action=action,
diff=delta,
)
impl.in_transaction = True
impl.transaction_snapshot = current_dict
try:
yield impl.data
new_dict = struct_to_dict(impl.data, serializer=impl.serializer)
delta = diff(impl.statedict, new_dict)
if delta:
if impl.callback_registry.has("validate"):
impl.callback_registry.invoke_sync(
"validate",
InjectionContext(data=impl.data, kanta=impl._kanta),
)
previous = impl.statedict
record = impl.queue_change(action, new_dict, user=user, mtime=mtime)
if record is not None:
if log is not False:
logfmt = _build_logfmt(impl, previous, new_dict)
logger = (
log if isinstance(log, logging.Logger) else transaction_logger
)
emit_event(
LogEvent(
kind="change",
logger=logger,
kanta=impl._kanta,
action=action,
user=_resolve_user(logfmt, user),
extra=extra,
diff=record.diff,
previous=previous,
current=new_dict,
logfmt=logfmt,
show_diff=logdiff,
),
impl.callback_registry.logemit_handlers,
)
except Exception as exc:
resolved_user = None
if user is not None:
logfmt = _build_logfmt(impl, impl.statedict, impl.statedict)
resolved_user = _resolve_user(logfmt, user)
emit_event(
LogEvent(
kind="aborted",
logger=transaction_logger,
level=logging.WARNING,
kanta=impl._kanta,
action=action,
user=resolved_user,
extra=extra,
error=exc,
),
impl.callback_registry.logemit_handlers,
)
if impl.transaction_snapshot is not None:
impl.data = restore_data_in_place(
impl.data,
impl.transaction_snapshot,
impl.data_type,
serializer=impl.serializer,
)
raise
finally:
impl.in_transaction = False
impl.transaction_snapshot = None