refactor: clean up sqlalchemy connection maker
This commit is contained in:
parent
a85ca93d5a
commit
f98acbaa42
6 changed files with 54 additions and 35 deletions
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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}"
|
||||||
|
|
|
||||||
|
|
@ -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}'.")
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue