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
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:
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user