Refactored storage layer to use per-operation auto-commit with explicit transaction API.
CI / Formatting (push) Successful in 5s
CI / Linting (push) Successful in 5s
CI / Tests (Python 3.12) (push) Successful in 20s
CI / Tests (Python 3.13) (push) Successful in 19s
CI / Tests (Python 3.14) (push) Successful in 17s
CI / Type Checking (push) Successful in 9s
CI / Spelling (push) Successful in 6s

This commit is contained in:
2026-03-09 20:03:38 -04:00
parent 87b037065f
commit 3186e6e392
12 changed files with 280 additions and 244 deletions
+2 -2
View File
@@ -34,8 +34,8 @@ def on_setup(func: LifecycleHandler) -> LifecycleHandler:
"""Mark a function as a module setup hook.
The decorated function will be called during module loading with a
``ModuleContext``. Setup hooks run inside a storage transaction that
is committed on success and rolled back on failure.
``ModuleContext``. Each storage operation within a setup hook
auto-commits independently.
Applied without parentheses::
+33 -52
View File
@@ -41,15 +41,13 @@ class ModuleStorage:
concurrent readers and a single writer can operate without "database is
locked" errors.
Transactions are managed at the handler level. The bot commits after each
handler succeeds, and rolls back if the handler throws an exception. Module
developers don't need to think about commits for normal usage.
Each storage operation (execute, fetch_one, etc.) independently acquires a
connection from the pool, auto-commits on success, rolls back on failure,
and releases the connection immediately. No connection is held between
calls.
For finer control within a handler, use the transaction() context manager
to group multiple operations that should succeed or fail together.
Concurrency: The _checkout() context manager acquires a dedicated
connection from the pool for the current handler invocation.
For operations that must succeed or fail together, use the transaction()
context manager to group them into a single atomic unit.
"""
def __init__(self, storage_dir: Path, module_name: str, pool_size: int = 4):
@@ -90,10 +88,16 @@ class ModuleStorage:
@asynccontextmanager
async def transaction(self) -> AsyncIterator[ModuleStorage]:
"""Context manager for explicit transaction control within a handler.
"""Context manager for explicit transaction control.
Use this when you need multiple operations to succeed or fail together
within a single handler. Commits on success, rolls back on exception.
Use this when you need multiple operations to succeed or fail together.
Acquires a dedicated connection from the pool and shares it across all
operations within the block. Commits on success, rolls back on exception.
Nesting ``transaction()`` calls is not supported and raises
``RuntimeError``. SQLite only allows one writer at a time, so a
nested transaction would deadlock waiting for the outer connection's
write lock.
Example:
async with ctx.storage.transaction():
@@ -102,15 +106,27 @@ class ModuleStorage:
# Both committed together, or both rolled back on error
:return: This ModuleStorage instance.
:raises RuntimeError: If called while already inside a transaction.
"""
if self._txn_conn.get() is not None:
raise RuntimeError(
"transaction() cannot be nested. Already inside an active transaction."
)
conn = await self._acquire()
token = self._txn_conn.set(conn)
self._logger.debug("Explicit transaction started.")
try:
yield self
await self._commit()
await conn.commit()
self._logger.debug("Transaction committed.")
except BaseException:
await self._rollback()
await conn.rollback()
self._logger.debug("Transaction rolled back.")
raise
finally:
self._txn_conn.reset(token)
self._release(conn)
async def execute(
self,
@@ -255,10 +271,10 @@ class ModuleStorage:
async def _connection(self) -> AsyncIterator[aiosqlite.Connection]:
"""Async context manager that provides a connection.
If already inside a ``_checkout``, yields the checked-out connection
without releasing it. Otherwise acquires a standalone connection from
the pool that auto-commits on success and rolls back on failure
before being released.
If already inside a ``transaction()``, yields the shared connection
without committing (the transaction block handles that). Otherwise
acquires a standalone connection from the pool that auto-commits on
success and rolls back on failure before being released.
"""
existing = self._txn_conn.get()
if existing is not None:
@@ -275,41 +291,6 @@ class ModuleStorage:
finally:
self._release(conn)
@asynccontextmanager
async def _checkout(self) -> AsyncIterator[None]:
"""Check out a connection from the pool for the duration of a handler.
Sets a ContextVar so that all storage operations within the handler
reuse the same connection.
"""
conn = await self._acquire()
token = self._txn_conn.set(conn)
try:
yield
finally:
self._txn_conn.reset(token)
self._release(conn)
async def _commit(self) -> None:
"""Commit the current transaction (internal use by bot).
Called automatically after each handler completes successfully.
"""
conn = self._txn_conn.get()
if conn is not None:
await conn.commit()
self._logger.debug("Transaction committed.")
async def _rollback(self) -> None:
"""Rollback the current transaction (internal use by bot).
Called automatically if a handler throws an exception.
"""
conn = self._txn_conn.get()
if conn is not None:
await conn.rollback()
self._logger.debug("Transaction rolled back.")
async def _close(self) -> None:
"""Close all pool connections (internal use by bot)."""
self._closed = True
+7 -13
View File
@@ -426,9 +426,9 @@ class ModuleLoader:
async def _run_module_setup(self, module_name: str) -> None:
"""Run a module's ``@on_setup`` hooks if any are defined.
All setup handlers run inside a single storage transaction that is
committed on success. If any handler fails, the transaction is
rolled back, storage is closed, and the module is fully cleaned up.
Each storage operation within a setup handler auto-commits
independently. If any handler fails, storage is closed and the
module is fully cleaned up.
:param module_name: Name of the module whose setup to run.
:raises ModuleLoadError: If any @on_setup handler raises an exception.
@@ -443,16 +443,10 @@ class ModuleLoader:
module_ctx = self._module_contexts[module_name]
try:
async with module_ctx.storage._checkout():
try:
for setup_func in setup_funcs:
logger.debug(f"Running @on_setup for module: {module_name}")
await setup_func(module_ctx)
await module_ctx.storage._commit()
logger.debug(f"Setup completed for module: {module_name}")
except Exception:
await module_ctx.storage._rollback()
raise
for setup_func in setup_funcs:
logger.debug(f"Running @on_setup for module: {module_name}")
await setup_func(module_ctx)
logger.debug(f"Setup completed for module: {module_name}")
except Exception as e:
await module_ctx.storage._close()
self._cleanup_module(module_name)
+20 -32
View File
@@ -528,39 +528,27 @@ class CommandDispatcher:
module=module_ctx,
)
# The checkout acquires a pooled connection for the
# duration of this command invocation.
async with module_ctx.storage._checkout():
try:
start = time.perf_counter()
await asyncio.wait_for(
command_info.handler(cmd_ctx), timeout=self._handler_timeout
)
elapsed = (time.perf_counter() - start) * 1000
try:
start = time.perf_counter()
await asyncio.wait_for(
command_info.handler(cmd_ctx), timeout=self._handler_timeout
)
elapsed = (time.perf_counter() - start) * 1000
# Command succeeded, commit any database changes.
await module_ctx.storage._commit()
logger.debug(
f"Command '{command_info.name}' completed in {elapsed:.1f}ms."
)
except TimeoutError:
# Command timed out. Rollback any partial changes.
await module_ctx.storage._rollback()
logger.warning(
f"Command handler '{command_info.name}' "
f"from module '{command_info.module_name}' "
f"cancelled after "
f"{self._handler_timeout}s timeout."
)
except Exception as e:
# Command raised an exception. Rollback any partial changes.
await module_ctx.storage._rollback()
logger.exception(
f"Command handler '{command_info.name}' "
f"from module '{command_info.module_name}' "
f"raised exception: {e}"
)
logger.debug(f"Command '{command_info.name}' completed in {elapsed:.1f}ms.")
except TimeoutError:
logger.warning(
f"Command handler '{command_info.name}' "
f"from module '{command_info.module_name}' "
f"cancelled after "
f"{self._handler_timeout}s timeout."
)
except Exception as e:
logger.exception(
f"Command handler '{command_info.name}' "
f"from module '{command_info.module_name}' "
f"raised exception: {e}"
)
def _register_builtin_commands(self) -> None:
"""Register all built-in commands."""
+16 -28
View File
@@ -374,7 +374,7 @@ class EventDispatcher:
module_name: str,
propagation: PropagationState,
) -> None:
"""Call a single handler with timeout enforcement and transaction management.
"""Call a single handler with timeout enforcement.
:param handler: The handler function to call.
:param event: The event to pass to the handler.
@@ -395,34 +395,22 @@ class EventDispatcher:
_propagation=propagation,
)
# Call the handler with timeout enforcement to prevent
# runaway handlers from blocking everything.
# The checkout acquires a pooled connection for the
# duration of this handler invocation.
async with module_ctx.storage._checkout():
try:
start = time.perf_counter()
await asyncio.wait_for(handler(ctx), timeout=self._handler_timeout)
elapsed = (time.perf_counter() - start) * 1000
try:
start = time.perf_counter()
await asyncio.wait_for(handler(ctx), timeout=self._handler_timeout)
elapsed = (time.perf_counter() - start) * 1000
# Handler succeeded, commit any database changes.
await module_ctx.storage._commit()
logger.debug(f"Handler '{handler_name}' completed in {elapsed:.1f}ms.")
except TimeoutError:
# Handler took too long. Rollback any partial changes.
await module_ctx.storage._rollback()
logger.warning(
f"Handler '{handler_name}' from module '{module_name}' "
f"cancelled after {self._handler_timeout}s timeout."
)
except Exception as e:
# Handler raised an exception. Rollback any partial changes.
await module_ctx.storage._rollback()
logger.exception(
f"Handler '{handler_name}' from module "
f"'{module_name}' raised exception: {e}"
)
logger.debug(f"Handler '{handler_name}' completed in {elapsed:.1f}ms.")
except TimeoutError:
logger.warning(
f"Handler '{handler_name}' from module '{module_name}' "
f"cancelled after {self._handler_timeout}s timeout."
)
except Exception as e:
logger.exception(
f"Handler '{handler_name}' from module "
f"'{module_name}' raised exception: {e}"
)
class ModuleEvents:
+33 -42
View File
@@ -356,7 +356,7 @@ class RouteDispatcher:
"""Dispatches HTTP requests to registered module route handlers.
Looks up routes in the RouteRegistry, validates methods, creates
RouteContext, and calls the handler with timeout and transaction management.
RouteContext, and calls the handler with timeout enforcement.
"""
def __init__(
@@ -532,50 +532,41 @@ class RouteDispatcher:
f"Calling route handler: {route_info.full_path} from module: {module_name}"
)
# The checkout acquires a pooled connection for the
# duration of this route invocation.
async with module_ctx.storage._checkout():
try:
start = time.perf_counter()
result = await asyncio.wait_for(
route_info.handler(ctx), timeout=self._handler_timeout
)
elapsed = (time.perf_counter() - start) * 1000
try:
start = time.perf_counter()
result = await asyncio.wait_for(
route_info.handler(ctx), timeout=self._handler_timeout
)
elapsed = (time.perf_counter() - start) * 1000
# Handler succeeded, commit any database changes.
await module_ctx.storage._commit()
mod_logger.debug(
f"Route handler '{route_info.full_path}' completed in {elapsed:.1f}ms."
)
mod_logger.debug(
f"Route handler '{route_info.full_path}' "
f"completed in {elapsed:.1f}ms."
)
if result is None:
return web.Response(status=204) # No Content.
if isinstance(result, web.StreamResponse):
return result
if isinstance(result, dict):
return web.json_response(result)
# pragma: no branch — defensive against untyped handlers
mod_logger.error( # type: ignore[unreachable]
f"Route handler '{route_info.full_path}' returned "
f"unsupported type: {type(result).__name__}"
)
return web.Response(status=500)
if result is None:
return web.Response(status=204) # No Content.
if isinstance(result, web.StreamResponse):
return result
if isinstance(result, dict):
return web.json_response(result)
# pragma: no branch — defensive against untyped handlers
mod_logger.error( # type: ignore[unreachable]
f"Route handler '{route_info.full_path}' returned "
f"unsupported type: {type(result).__name__}"
)
return web.Response(status=500)
except TimeoutError:
await module_ctx.storage._rollback()
mod_logger.warning(
f"Route handler '{route_info.full_path}' timed out "
f"after {self._handler_timeout}s."
)
return web.Response(status=500)
except Exception as e:
await module_ctx.storage._rollback()
mod_logger.exception(
f"Route handler '{route_info.full_path}' raised exception: {e}"
)
return web.Response(status=500)
except TimeoutError:
mod_logger.warning(
f"Route handler '{route_info.full_path}' timed out "
f"after {self._handler_timeout}s."
)
return web.Response(status=500)
except Exception as e:
mod_logger.exception(
f"Route handler '{route_info.full_path}' raised exception: {e}"
)
return web.Response(status=500)
class ModuleRoutes: