Files
kanta/kanta/kantaimpl.py
T

203 lines
6.9 KiB
Python

"""Internal implementation for Kanta."""
from __future__ import annotations
import asyncio
import copy
import importlib
import logging
from datetime import UTC, datetime
from typing import Any, Generic, TypeVar
from kanta.callbacks import CallbackRegistry, InjectionContext
from kanta.exceptions import DatabaseError, DataIntegrityError, ReplayError
from kanta.migrate import MigrationRegistry
from kanta.persistence import PersistenceMixin
from kanta.serialization import restore_data_in_place, struct_to_dict
from kanta.serialization.base import replay
_logger = logging.getLogger(__name__)
T = TypeVar("T")
class KantaImpl(PersistenceMixin, Generic[T]):
"""Internal state and logic for Kanta."""
def __init__(self, **kwargs: Any):
self.data_type = kwargs.pop("type")
self.data: T = kwargs.pop("data")
self.migrations = kwargs.pop("migrations", None)
self.migration_ctx = kwargs.pop("migration_ctx", None)
self._kanta = kwargs.pop("kanta", None)
super().__init__(**kwargs)
self.migration_registry: MigrationRegistry | None = None
if self.migrations is not None:
module = (
importlib.import_module(self.migrations)
if isinstance(self.migrations, str)
else self.migrations
)
self.migration_registry = MigrationRegistry.from_module(module)
self.in_transaction = False
self.transaction_snapshot: dict[str, Any] | None = None
self.opened = False
self.bootstrap_action = "bootstrap"
self.bootstrap_user: str | None = None
self.bootstrap_mtime: bool | datetime = True
self.callback_registry = CallbackRegistry(
kanta_class=type(self._kanta) if self._kanta is not None else None,
data_type=self.data_type,
)
self.statedict = struct_to_dict(self.data, serializer=self.serializer)
self.version = (
self.migration_registry.dbver if self.migration_registry is not None else 0
)
def add_bootstrap(
self,
*,
callback,
action: str,
user: str | None,
mtime: bool | datetime,
) -> None:
"""Add bootstrap callback and update bootstrap metadata."""
self.callback_registry.register("bootstrap", callback)
self.bootstrap_action = action
self.bootstrap_user = user
self.bootstrap_mtime = mtime
def add_logfmt(self, callback, *, path: str | None = None) -> None:
"""Register one transaction logfmt callback."""
self.callback_registry.register("logfmt", callback, path=path)
async def open(self, *, create: bool = True) -> None:
"""Open the database: load from disk, apply migrations, start background task."""
if self.opened:
raise DataIntegrityError(
"Kanta instance is already open",
db_path=self.filename,
action="open",
)
existed_before_open = self.filename.exists()
content = await asyncio.to_thread(
self.file.open_and_read,
self.filename,
create=create,
)
if not create and (not existed_before_open or not content):
self.file.close()
reason = (
"database file did not exist"
if not existed_before_open
else "database file is empty"
)
raise DataIntegrityError(
f"Cannot open database: {reason}",
db_path=self.filename,
action="open",
)
if content:
try:
rr = replay(
content,
framer=self.framer,
decode=self.serializer.decode,
)
except ReplayError as e:
raise DatabaseError(
f"{e}",
db_path=self.filename,
line_number=e.line_number,
byte_pos=e.byte_pos,
cause_type=type(e).__name__,
) from e
except (OSError, ValueError, DatabaseError) as e:
raise DatabaseError(
f"{e}",
db_path=self.filename,
cause_type=type(e).__name__,
) from e
except Exception as e:
_logger.exception("Unexpected error loading database")
raise DatabaseError(
f"{e}",
db_path=self.filename,
cause_type=type(e).__name__,
) from e
if self.migration_registry is not None:
rr.version = self.migration_registry.apply(
rr.state, rr.version, self.migration_ctx
)
self.statedict = copy.deepcopy(rr.state)
self.data = restore_data_in_place(
self.data,
rr.state,
self.data_type,
serializer=self.serializer,
)
self.version = rr.version
self.mtime = rr.m
normalized = struct_to_dict(self.data, serializer=self.serializer)
self.queue_change("migrate:msgspec", normalized, mtime=False)
self.snapshot.ts = (
datetime.fromtimestamp(rr.last_snapshot_mtime, UTC)
if rr.last_snapshot_mtime is not None
else None
)
elif self.callback_registry.has("bootstrap"):
try:
await self.callback_registry.invoke(
"bootstrap",
InjectionContext(data=self.data, kanta=self._kanta),
)
current = struct_to_dict(self.data, serializer=self.serializer)
self.queue_change(
self.bootstrap_action,
current,
user=self.bootstrap_user,
mtime=self.bootstrap_mtime,
)
except Exception:
self.file.close()
try:
await asyncio.to_thread(self.filename.unlink, missing_ok=True)
except FileNotFoundError:
pass
raise
self.opened = True
self.background_task = asyncio.create_task(self._background_loop())
async def close(self) -> None:
"""Stop the background task, flush pending changes, and release the file lock."""
if not self.opened:
return
if self.background_task is not None:
self.background_task.cancel()
try:
await self.background_task
except asyncio.CancelledError:
pass
self.background_task = None
# Always run a final flush in case the background task never reached
# its cancellation handler.
await self.flush()
self.file.close()
self.opened = False