refactor: clean up sqlalchemy connection maker

This commit is contained in:
arabcoders 2026-01-18 19:18:00 +03:00
parent a85ca93d5a
commit f98acbaa42
6 changed files with 54 additions and 35 deletions

View file

@ -219,7 +219,7 @@ class EventBus(metaclass=Singleton):
""" """
def __init__(self): def __init__(self):
self._listeners: dict[str, list[str, EventListener]] = {} self._listeners: dict[str, list[tuple[str, EventListener]]] = {}
"The listeners for the events." "The listeners for the events."
self.debug: bool = False self.debug: bool = False
@ -275,9 +275,10 @@ class EventBus(metaclass=Singleton):
continue continue
if e not in self._listeners: if e not in self._listeners:
self._listeners[e] = {} self._listeners[e] = []
self._listeners[e][name] = EventListener(name, callback) self._listeners[e] = [(n, listener) for n, listener in self._listeners[e] if n != name]
self._listeners[e].append((name, EventListener(name, callback)))
LOG.debug(f"'{name}' subscribed to '{event}'.") LOG.debug(f"'{name}' subscribed to '{event}'.")
@ -300,9 +301,11 @@ class EventBus(metaclass=Singleton):
events = [] events = []
for e in event: for e in event:
if e in self._listeners and name in self._listeners[e]: if e in self._listeners:
events.append(e) original_len: int = len(self._listeners[e])
del self._listeners[e][name] self._listeners[e] = [(n, listener) for n, listener in self._listeners[e] if n != name]
if len(self._listeners[e]) < original_len:
events.append(e)
if len(events) > 0: if len(events) > 0:
LOG.debug(f"'{name}' unsubscribed from '{events}'.") LOG.debug(f"'{name}' unsubscribed from '{events}'.")
@ -337,22 +340,25 @@ class EventBus(metaclass=Singleton):
try: try:
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
for handler in self._listeners[event].values(): async def execute_handlers():
try: for _, handler in self._listeners[event]:
if handler.is_coroutine: try:
coro = handler.call_back(ev, handler.name, **kwargs) if handler.is_coroutine:
if asyncio.iscoroutine(coro): coro = handler.call_back(ev, handler.name, **kwargs)
loop.create_task(coro) if asyncio.iscoroutine(coro):
await coro
else:
LOG.warning(f"Expected coroutine from async handler '{handler.name}', got {type(coro)}")
else: else:
LOG.warning(f"Expected coroutine from async handler '{handler.name}', got {type(coro)}") await self._call(handler, ev, kwargs)
else: except Exception as e:
loop.create_task(self._call(handler, ev, kwargs), name=f"sync-handler-{handler.name}-{ev.id}") LOG.exception(e)
except Exception as e: LOG.error(f"Failed to emit event '{ev.event}' to '{handler.name}'. Error message '{e!s}'.")
LOG.exception(e)
LOG.error(f"Failed to emit event '{ev.event}' to '{handler.name}'. Error message '{e!s}'.") loop.create_task(execute_handlers())
except RuntimeError: except RuntimeError:
LOG.debug(f"No event loop detected - using BackgroundWorker for {len(self._listeners[event])} handlers") LOG.debug(f"No event loop detected - using BackgroundWorker for {len(self._listeners[event])} handlers")
for handler in self._listeners[event].values(): for _, handler in self._listeners[event]:
try: try:
if not self._offload: if not self._offload:
self._offload = BackgroundWorker.get_instance() self._offload = BackgroundWorker.get_instance()

View file

@ -1647,6 +1647,9 @@ def load_modules(root_path: Path, directory: Path):
import pkgutil import pkgutil
package_name: str = str(directory.relative_to(root_path).as_posix()).replace("/", ".") package_name: str = str(directory.relative_to(root_path).as_posix()).replace("/", ".")
# Ensure package name starts with 'app.' for proper module resolution
if not package_name.startswith("app."):
package_name = f"app.{package_name}"
for _, name, _ in pkgutil.iter_modules([directory]): for _, name, _ in pkgutil.iter_modules([directory]):
full_name: str = f"{package_name}.{name}" full_name: str = f"{package_name}.{name}"

View file

@ -13,6 +13,7 @@ from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.ext.asyncio.engine import AsyncConnection from sqlalchemy.ext.asyncio.engine import AsyncConnection
from .Events import EventBus, Events
from .ItemDTO import ItemDTO from .ItemDTO import ItemDTO
from .operations import Operation, matches_condition from .operations import Operation, matches_condition
from .Services import Services from .Services import Services
@ -50,6 +51,12 @@ class SqliteStore(metaclass=ThreadSafe):
def attach(self, app: web.Application): def attach(self, app: web.Application):
Services.get_instance().add("sqlite_store", self) Services.get_instance().add("sqlite_store", self)
async def handle_event(_, __):
await self.get_connection()
EventBus.get_instance().subscribe(Events.STARTED, handle_event, "SqliteStore.get_connection")
app.on_shutdown.append(self.on_shutdown) app.on_shutdown.append(self.on_shutdown)
async def on_shutdown(self, _: web.Application): async def on_shutdown(self, _: web.Application):
@ -60,13 +67,13 @@ class SqliteStore(metaclass=ThreadSafe):
def __init__(self, db_path: str, *, max_pending: int = 200, flush_interval: float = 0.05): def __init__(self, db_path: str, *, max_pending: int = 200, flush_interval: float = 0.05):
self._db_path: str = db_path self._db_path: str = db_path
self._engine: AsyncEngine | None = None self._engine: AsyncEngine | None = None
self._conn = None self._conn: AsyncConnection | None = None
self._sessionmaker: async_sessionmaker[AsyncSession] | None = None self._sessionmaker: async_sessionmaker[AsyncSession] | None = None
self._queue: asyncio.Queue[_Op] | None = None self._queue: asyncio.Queue[_Op] | None = None
self._task: asyncio.Task | None = None self._task: asyncio.Task | None = None
self._lock: asyncio.Lock = asyncio.Lock()
self._flush_interval: float = flush_interval self._flush_interval: float = flush_interval
self._max_pending: int = max_pending self._max_pending: int = max_pending
self._lock = asyncio.Lock()
async def __aenter__(self) -> "SqliteStore": async def __aenter__(self) -> "SqliteStore":
await self.get_connection() await self.get_connection()
@ -90,7 +97,7 @@ class SqliteStore(metaclass=ThreadSafe):
""" """
if not self._sessionmaker: if not self._sessionmaker:
msg = "Database connection not initialized. Call _ensure_conn() first or use within async context." msg = "Database connection not initialized. Call get_connection() first or use within async context."
raise RuntimeError(msg) raise RuntimeError(msg)
return self._sessionmaker return self._sessionmaker
@ -414,7 +421,8 @@ class SqliteStore(metaclass=ThreadSafe):
if self._engine: if self._engine:
await self._engine.dispose() await self._engine.dispose()
self._engine = None self._engine = None
self._sessionmaker = None
self._sessionmaker = None
async def _enqueue(self, op: _Op) -> None: async def _enqueue(self, op: _Op) -> None:
self._ensure_worker() self._ensure_worker()
@ -555,7 +563,7 @@ class SqliteStore(metaclass=ThreadSafe):
echo=False, echo=False,
connect_args={"check_same_thread": False, "uri": self._db_path.startswith(":memory")}, connect_args={"check_same_thread": False, "uri": self._db_path.startswith(":memory")},
) )
self._conn: AsyncConnection = await self._engine.connect() self._conn = await self._engine.connect()
if version := await migrate.get_version(self._conn): if version := await migrate.get_version(self._conn):
LOG.debug(f"DB Version: '{version}'.") LOG.debug(f"DB Version: '{version}'.")

View file

@ -683,8 +683,10 @@ class TestEventBus:
assert result is bus # Should return self for chaining assert result is bus # Should return self for chaining
assert Events.TEST in bus._listeners assert Events.TEST in bus._listeners
assert "test_subscriber" in bus._listeners[Events.TEST] assert any(name == "test_subscriber" for name, _ in bus._listeners[Events.TEST])
assert isinstance(bus._listeners[Events.TEST]["test_subscriber"], EventListener) listener = next((listener for name, listener in bus._listeners[Events.TEST] if name == "test_subscriber"), None)
assert listener is not None
assert isinstance(listener, EventListener)
@patch("app.library.config.Config") @patch("app.library.config.Config")
@patch("app.library.BackgroundWorker.BackgroundWorker") @patch("app.library.BackgroundWorker.BackgroundWorker")
@ -703,7 +705,7 @@ class TestEventBus:
for event in events: for event in events:
assert event in bus._listeners assert event in bus._listeners
assert "multi_subscriber" in bus._listeners[event] assert any(name == "multi_subscriber" for name, _ in bus._listeners[event])
@patch("app.library.config.Config") @patch("app.library.config.Config")
@patch("app.library.BackgroundWorker.BackgroundWorker") @patch("app.library.BackgroundWorker.BackgroundWorker")
@ -722,7 +724,7 @@ class TestEventBus:
all_events = Events.get_all() all_events = Events.get_all()
for event in all_events: for event in all_events:
assert event in bus._listeners assert event in bus._listeners
assert "wildcard_subscriber" in bus._listeners[event] assert any(name == "wildcard_subscriber" for name, _ in bus._listeners[event])
@patch("app.library.config.Config") @patch("app.library.config.Config")
@patch("app.library.BackgroundWorker.BackgroundWorker") @patch("app.library.BackgroundWorker.BackgroundWorker")
@ -741,7 +743,7 @@ class TestEventBus:
frontend_events = Events.frontend() frontend_events = Events.frontend()
for event in frontend_events: for event in frontend_events:
assert event in bus._listeners assert event in bus._listeners
assert "frontend_subscriber" in bus._listeners[event] assert any(name == "frontend_subscriber" for name, _ in bus._listeners[event])
@patch("app.library.config.Config") @patch("app.library.config.Config")
@patch("app.library.BackgroundWorker.BackgroundWorker") @patch("app.library.BackgroundWorker.BackgroundWorker")
@ -779,7 +781,7 @@ class TestEventBus:
assert len(bus._listeners[Events.TEST]) == 1 assert len(bus._listeners[Events.TEST]) == 1
# Name should be a UUID # Name should be a UUID
subscriber_name = next(iter(bus._listeners[Events.TEST].keys())) subscriber_name = bus._listeners[Events.TEST][0][0]
assert len(subscriber_name) == 36 # UUID string length assert len(subscriber_name) == 36 # UUID string length
@patch("app.library.config.Config") @patch("app.library.config.Config")
@ -796,13 +798,13 @@ class TestEventBus:
# First subscribe # First subscribe
bus.subscribe(Events.TEST, test_callback, "test_subscriber") bus.subscribe(Events.TEST, test_callback, "test_subscriber")
assert "test_subscriber" in bus._listeners[Events.TEST] assert any(name == "test_subscriber" for name, _ in bus._listeners[Events.TEST])
# Then unsubscribe # Then unsubscribe
result = bus.unsubscribe(Events.TEST, "test_subscriber") result = bus.unsubscribe(Events.TEST, "test_subscriber")
assert result is bus assert result is bus
assert "test_subscriber" not in bus._listeners[Events.TEST] assert not any(name == "test_subscriber" for name, _ in bus._listeners[Events.TEST])
@patch("app.library.config.Config") @patch("app.library.config.Config")
@patch("app.library.BackgroundWorker.BackgroundWorker") @patch("app.library.BackgroundWorker.BackgroundWorker")

View file

@ -351,9 +351,9 @@ class TestUpdateChecker:
checker.attach(app_mock) checker.attach(app_mock)
# Verify subscription was created # Verify subscription was created
subscriptions = notify._listeners.get("started", {}) subscriptions = notify._listeners.get("started", [])
assert len(subscriptions) > 0, "Should have subscribed to STARTED event" assert len(subscriptions) > 0, "Should have subscribed to STARTED event"
assert any("UpdateChecker.attach" in name for name in subscriptions.keys()), ( assert any("UpdateChecker.attach" in name for name, _ in subscriptions), (
"Should have UpdateChecker.attach subscription" "Should have UpdateChecker.attach subscription"
) )
finally: finally:

View file

@ -183,7 +183,7 @@ link-mode = "copy"
[tool.pytest.ini_options] [tool.pytest.ini_options]
pythonpath = ["."] pythonpath = ["."]
testpaths = ["app/tests"] testpaths = ["app/tests", "app/features"]
addopts = "-v --tb=short" addopts = "-v --tb=short"
filterwarnings = [ filterwarnings = [
"ignore:Parsing dates involving a day of month without a year:DeprecationWarning", "ignore:Parsing dates involving a day of month without a year:DeprecationWarning",