fix: make db wrapper executor-safe, serialized, and leak-proof
This commit is contained in:
@@ -50,11 +50,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
await listener_hass.config_entries.async_reload(listener_entry.entry_id)
|
await listener_hass.config_entries.async_reload(listener_entry.entry_id)
|
||||||
|
|
||||||
database = await NestQuestDatabase(hass).open(await async_get_db_path(hass))
|
database = await NestQuestDatabase(hass).open(await async_get_db_path(hass))
|
||||||
|
try:
|
||||||
|
remove_update_listener = entry.add_update_listener(_async_update_listener)
|
||||||
|
except BaseException:
|
||||||
|
await database.close()
|
||||||
|
raise
|
||||||
runtime_data = NestQuestRuntimeData(
|
runtime_data = NestQuestRuntimeData(
|
||||||
entry_id=entry.entry_id,
|
entry_id=entry.entry_id,
|
||||||
options=dict(entry.options),
|
options=dict(entry.options),
|
||||||
database=database,
|
database=database,
|
||||||
remove_update_listener=entry.add_update_listener(_async_update_listener),
|
remove_update_listener=remove_update_listener,
|
||||||
)
|
)
|
||||||
entry.runtime_data = runtime_data
|
entry.runtime_data = runtime_data
|
||||||
hass.data[DOMAIN][entry.entry_id] = runtime_data
|
hass.data[DOMAIN][entry.entry_id] = runtime_data
|
||||||
@@ -71,13 +76,22 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
runtime_data = getattr(entry, "runtime_data", None)
|
runtime_data = getattr(entry, "runtime_data", None)
|
||||||
if runtime_data is not None:
|
if runtime_data is not None:
|
||||||
remove_update_listener = getattr(runtime_data, "remove_update_listener", None)
|
remove_update_listener = getattr(runtime_data, "remove_update_listener", None)
|
||||||
if remove_update_listener is not None:
|
unload_error: BaseException | None = None
|
||||||
result = remove_update_listener()
|
try:
|
||||||
if inspect.isawaitable(result):
|
if remove_update_listener is not None:
|
||||||
await result
|
result = remove_update_listener()
|
||||||
database = getattr(runtime_data, "database", None)
|
if inspect.isawaitable(result):
|
||||||
if database is not None:
|
await result
|
||||||
await database.close()
|
except BaseException as err:
|
||||||
|
unload_error = err
|
||||||
|
finally:
|
||||||
|
database = getattr(runtime_data, "database", None)
|
||||||
|
if database is not None:
|
||||||
|
await database.close()
|
||||||
|
if unload_error is not None:
|
||||||
|
if getattr(entry, "runtime_data", None) is not None:
|
||||||
|
entry.runtime_data = None
|
||||||
|
raise unload_error
|
||||||
if getattr(entry, "runtime_data", None) is not None:
|
if getattr(entry, "runtime_data", None) is not None:
|
||||||
entry.runtime_data = None
|
entry.runtime_data = None
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -1,27 +1,72 @@
|
|||||||
"""Async SQLite connection wrapper for the NestQuest integration."""
|
"""Async SQLite connection wrapper for the NestQuest integration."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from collections.abc import AsyncIterator, Sequence
|
from collections.abc import AsyncIterator, Sequence
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ExecutionResult:
|
||||||
|
"""Plain-data outcome of one statement.
|
||||||
|
|
||||||
|
``rowcount`` and ``lastrowid`` mirror the corresponding
|
||||||
|
:class:`sqlite3.Cursor` attributes; both are captured on the executor
|
||||||
|
thread so no sqlite3 object ever crosses back to the event loop.
|
||||||
|
"""
|
||||||
|
|
||||||
|
rowcount: int
|
||||||
|
lastrowid: int | None
|
||||||
|
|
||||||
|
|
||||||
class NestQuestDatabase:
|
class NestQuestDatabase:
|
||||||
"""Async wrapper around a single SQLite connection.
|
"""Async wrapper around a single SQLite connection.
|
||||||
|
|
||||||
Every sqlite3 call is delegated to the Home Assistant executor through
|
Every sqlite3 call is delegated to the Home Assistant executor through
|
||||||
``hass.async_add_executor_job`` so the event loop is never blocked.
|
``hass.async_add_executor_job`` so the event loop is never blocked, and
|
||||||
|
only plain Python data (row tuples and :class:`ExecutionResult`) is
|
||||||
|
returned to the event loop.
|
||||||
|
|
||||||
|
Every operation is serialized through one asyncio.Lock per wrapper. A
|
||||||
|
``transaction()`` holds that lock for its whole BEGIN..COMMIT span, so
|
||||||
|
interleaved coroutines cannot corrupt the transaction and concurrent
|
||||||
|
BEGINs are impossible; statements issued while a transaction is open
|
||||||
|
queue until it completes. Concurrent transactions on one wrapper are
|
||||||
|
not supported: a second ``transaction()`` entered while one is active
|
||||||
|
raises :class:`RuntimeError`. ``close()`` serializes on the same lock
|
||||||
|
and is idempotent.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, hass: HomeAssistant) -> None:
|
def __init__(self, hass: HomeAssistant) -> None:
|
||||||
"""Initialize the wrapper around a Home Assistant instance."""
|
"""Initialize the wrapper around a Home Assistant instance."""
|
||||||
self._hass = hass
|
self._hass = hass
|
||||||
self._conn: sqlite3.Connection | None = None
|
self._conn: sqlite3.Connection | None = None
|
||||||
self._cursor: sqlite3.Cursor | None = None
|
self._rowcount: int = -1
|
||||||
|
self._lastrowid: int | None = None
|
||||||
|
self._lock: asyncio.Lock | None = None
|
||||||
|
self._lock_loop: asyncio.AbstractEventLoop | None = None
|
||||||
|
self._in_transaction = False
|
||||||
|
self._tx_task: asyncio.Task | None = None
|
||||||
|
|
||||||
|
def _get_lock(self) -> asyncio.Lock:
|
||||||
|
"""Return the per-wrapper lock, (re)bound to the running loop.
|
||||||
|
|
||||||
|
HA drives every wrapper call from one event loop, so the lock is
|
||||||
|
created once and reused; re-binding only when the running loop
|
||||||
|
differs keeps the wrapper usable from plain synchronous drivers
|
||||||
|
that run each call on a fresh loop.
|
||||||
|
"""
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
if self._lock is None or self._lock_loop is not loop:
|
||||||
|
self._lock = asyncio.Lock()
|
||||||
|
self._lock_loop = loop
|
||||||
|
return self._lock
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def connected(self) -> bool:
|
def connected(self) -> bool:
|
||||||
@@ -31,109 +76,189 @@ class NestQuestDatabase:
|
|||||||
@property
|
@property
|
||||||
def rowcount(self) -> int:
|
def rowcount(self) -> int:
|
||||||
"""Row count of the most recent statement, or -1 when unset."""
|
"""Row count of the most recent statement, or -1 when unset."""
|
||||||
return self._cursor.rowcount if self._cursor is not None else -1
|
return self._rowcount
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def lastrowid(self) -> int | None:
|
def lastrowid(self) -> int | None:
|
||||||
"""Row id of the most recent insert, or None when unset."""
|
"""Row id of the most recent insert, or None when unset."""
|
||||||
return self._cursor.lastrowid if self._cursor is not None else None
|
return self._lastrowid
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def _serialized(self) -> AsyncIterator[None]:
|
||||||
|
"""Hold the wrapper lock unless the caller owns the active transaction.
|
||||||
|
|
||||||
|
A transaction holds the lock for its whole BEGIN..COMMIT span;
|
||||||
|
statements issued by the transaction's own task are already
|
||||||
|
serialized by it and must not re-acquire the non-reentrant lock.
|
||||||
|
Any other task queues on the lock until the transaction ends.
|
||||||
|
"""
|
||||||
|
if (
|
||||||
|
self._in_transaction
|
||||||
|
and self._tx_task is not None
|
||||||
|
and asyncio.current_task() is self._tx_task
|
||||||
|
):
|
||||||
|
yield
|
||||||
|
return
|
||||||
|
async with self._get_lock():
|
||||||
|
yield
|
||||||
|
|
||||||
async def open(self, path: Path | str) -> NestQuestDatabase:
|
async def open(self, path: Path | str) -> NestQuestDatabase:
|
||||||
"""Open the SQLite file with WAL journal mode and foreign keys on."""
|
"""Open the SQLite file with WAL journal mode and foreign keys on."""
|
||||||
if self._conn is not None:
|
if self._conn is not None:
|
||||||
return self
|
return self
|
||||||
|
async with self._get_lock():
|
||||||
|
if self._conn is not None:
|
||||||
|
return self
|
||||||
|
|
||||||
def _open() -> sqlite3.Connection:
|
def _open() -> sqlite3.Connection:
|
||||||
conn = sqlite3.connect(
|
conn = sqlite3.connect(
|
||||||
path, check_same_thread=False, isolation_level=None
|
path, check_same_thread=False, isolation_level=None
|
||||||
)
|
)
|
||||||
conn.execute("PRAGMA journal_mode=WAL")
|
try:
|
||||||
conn.execute("PRAGMA foreign_keys=ON")
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
return conn
|
conn.execute("PRAGMA foreign_keys=ON")
|
||||||
|
except BaseException:
|
||||||
|
conn.close()
|
||||||
|
raise
|
||||||
|
return conn
|
||||||
|
|
||||||
self._conn = await self._hass.async_add_executor_job(_open)
|
self._conn = await self._hass.async_add_executor_job(_open)
|
||||||
return self
|
return self
|
||||||
|
|
||||||
async def execute(
|
async def execute(
|
||||||
self, sql: str, parameters: Sequence[Any] = ()
|
self, sql: str, parameters: Sequence[Any] = ()
|
||||||
) -> sqlite3.Cursor:
|
) -> ExecutionResult:
|
||||||
"""Run one statement in the executor and return its cursor."""
|
"""Run one statement in the executor and return its plain result."""
|
||||||
conn = self._require_conn()
|
async with self._serialized():
|
||||||
|
conn = self._require_conn()
|
||||||
|
|
||||||
def _execute() -> sqlite3.Cursor:
|
def _execute() -> ExecutionResult:
|
||||||
return conn.execute(sql, parameters)
|
cursor = conn.execute(sql, parameters)
|
||||||
|
return ExecutionResult(cursor.rowcount, cursor.lastrowid)
|
||||||
|
|
||||||
self._cursor = await self._hass.async_add_executor_job(_execute)
|
result = await self._hass.async_add_executor_job(_execute)
|
||||||
return self._cursor
|
self._rowcount = result.rowcount
|
||||||
|
self._lastrowid = result.lastrowid
|
||||||
|
return result
|
||||||
|
|
||||||
async def execute_many(
|
async def execute_many(
|
||||||
self, sql: str, parameters: Sequence[Sequence[Any]]
|
self, sql: str, parameters: Sequence[Sequence[Any]]
|
||||||
) -> sqlite3.Cursor:
|
) -> ExecutionResult:
|
||||||
"""Run executemany in the executor and return its cursor."""
|
"""Run executemany in the executor and return its plain result."""
|
||||||
conn = self._require_conn()
|
async with self._serialized():
|
||||||
|
conn = self._require_conn()
|
||||||
|
|
||||||
def _execute_many() -> sqlite3.Cursor:
|
def _execute_many() -> ExecutionResult:
|
||||||
return conn.executemany(sql, parameters)
|
cursor = conn.executemany(sql, parameters)
|
||||||
|
return ExecutionResult(cursor.rowcount, cursor.lastrowid)
|
||||||
|
|
||||||
self._cursor = await self._hass.async_add_executor_job(_execute_many)
|
result = await self._hass.async_add_executor_job(_execute_many)
|
||||||
return self._cursor
|
self._rowcount = result.rowcount
|
||||||
|
self._lastrowid = result.lastrowid
|
||||||
|
return result
|
||||||
|
|
||||||
async def fetch_one(
|
async def fetch_one(
|
||||||
self, sql: str, parameters: Sequence[Any] = ()
|
self, sql: str, parameters: Sequence[Any] = ()
|
||||||
) -> tuple | None:
|
) -> tuple | None:
|
||||||
"""Run a query in the executor and return its first row, or None."""
|
"""Run a query in the executor and return its first row, or None."""
|
||||||
cursor = await self.execute(sql, parameters)
|
async with self._serialized():
|
||||||
|
conn = self._require_conn()
|
||||||
|
|
||||||
def _fetch_one() -> tuple | None:
|
def _fetch_one() -> tuple[tuple | None, int, int | None]:
|
||||||
return cursor.fetchone()
|
cursor = conn.execute(sql, parameters)
|
||||||
|
return cursor.fetchone(), cursor.rowcount, cursor.lastrowid
|
||||||
|
|
||||||
return await self._hass.async_add_executor_job(_fetch_one)
|
row, rowcount, lastrowid = await self._hass.async_add_executor_job(
|
||||||
|
_fetch_one
|
||||||
|
)
|
||||||
|
self._rowcount = rowcount
|
||||||
|
self._lastrowid = lastrowid
|
||||||
|
return row
|
||||||
|
|
||||||
async def fetch_all(
|
async def fetch_all(
|
||||||
self, sql: str, parameters: Sequence[Any] = ()
|
self, sql: str, parameters: Sequence[Any] = ()
|
||||||
) -> list[tuple]:
|
) -> list[tuple]:
|
||||||
"""Run a query in the executor and return all of its rows."""
|
"""Run a query in the executor and return all of its rows."""
|
||||||
cursor = await self.execute(sql, parameters)
|
async with self._serialized():
|
||||||
|
conn = self._require_conn()
|
||||||
|
|
||||||
def _fetch_all() -> list[tuple]:
|
def _fetch_all() -> tuple[list[tuple], int, int | None]:
|
||||||
return cursor.fetchall()
|
cursor = conn.execute(sql, parameters)
|
||||||
|
return cursor.fetchall(), cursor.rowcount, cursor.lastrowid
|
||||||
|
|
||||||
return await self._hass.async_add_executor_job(_fetch_all)
|
rows, rowcount, lastrowid = await self._hass.async_add_executor_job(
|
||||||
|
_fetch_all
|
||||||
|
)
|
||||||
|
self._rowcount = rowcount
|
||||||
|
self._lastrowid = lastrowid
|
||||||
|
return rows
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def transaction(self) -> AsyncIterator[None]:
|
async def transaction(self) -> AsyncIterator[None]:
|
||||||
"""Run a block inside BEGIN/COMMIT, rolling back on any exception."""
|
"""Run a block inside BEGIN/COMMIT, rolling back on any exception.
|
||||||
conn = self._require_conn()
|
|
||||||
|
|
||||||
def _begin() -> None:
|
The wrapper lock is held for the entire BEGIN..COMMIT span, so no
|
||||||
conn.execute("BEGIN")
|
other statement can interleave inside the transaction. Entering a
|
||||||
|
second transaction while one is active raises RuntimeError.
|
||||||
|
"""
|
||||||
|
if self._in_transaction:
|
||||||
|
raise RuntimeError(
|
||||||
|
"A NestQuest transaction is already active on this "
|
||||||
|
"connection; concurrent transactions are not supported"
|
||||||
|
)
|
||||||
|
async with self._get_lock():
|
||||||
|
conn = self._require_conn()
|
||||||
|
self._in_transaction = True
|
||||||
|
self._tx_task = asyncio.current_task()
|
||||||
|
try:
|
||||||
|
|
||||||
await self._hass.async_add_executor_job(_begin)
|
def _begin() -> None:
|
||||||
try:
|
conn.execute("BEGIN")
|
||||||
yield
|
|
||||||
except BaseException:
|
|
||||||
|
|
||||||
def _rollback() -> None:
|
await self._hass.async_add_executor_job(_begin)
|
||||||
conn.execute("ROLLBACK")
|
try:
|
||||||
|
yield
|
||||||
|
except BaseException:
|
||||||
|
|
||||||
await self._hass.async_add_executor_job(_rollback)
|
def _rollback() -> None:
|
||||||
raise
|
conn.execute("ROLLBACK")
|
||||||
|
|
||||||
def _commit() -> None:
|
await self._hass.async_add_executor_job(_rollback)
|
||||||
conn.execute("COMMIT")
|
raise
|
||||||
|
|
||||||
await self._hass.async_add_executor_job(_commit)
|
def _commit() -> None:
|
||||||
|
conn.execute("COMMIT")
|
||||||
|
|
||||||
|
await self._hass.async_add_executor_job(_commit)
|
||||||
|
finally:
|
||||||
|
self._in_transaction = False
|
||||||
|
self._tx_task = None
|
||||||
|
|
||||||
async def close(self) -> None:
|
async def close(self) -> None:
|
||||||
"""Close the connection in the executor; safe to call repeatedly."""
|
"""Close the connection in the executor; safe to call repeatedly.
|
||||||
conn, self._conn, self._cursor = self._conn, None, None
|
|
||||||
if conn is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
def _close() -> None:
|
Serialized on the wrapper lock, so an in-flight operation or open
|
||||||
conn.close()
|
transaction always completes before the connection is closed.
|
||||||
|
"""
|
||||||
|
if (
|
||||||
|
self._in_transaction
|
||||||
|
and self._tx_task is not None
|
||||||
|
and asyncio.current_task() is self._tx_task
|
||||||
|
):
|
||||||
|
raise RuntimeError(
|
||||||
|
"Cannot close the NestQuest database from inside its own "
|
||||||
|
"transaction; exit the transaction first"
|
||||||
|
)
|
||||||
|
async with self._get_lock():
|
||||||
|
conn, self._conn = self._conn, None
|
||||||
|
self._rowcount, self._lastrowid = -1, None
|
||||||
|
if conn is None:
|
||||||
|
return
|
||||||
|
|
||||||
await self._hass.async_add_executor_job(_close)
|
def _close() -> None:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
await self._hass.async_add_executor_job(_close)
|
||||||
|
|
||||||
def _require_conn(self) -> sqlite3.Connection:
|
def _require_conn(self) -> sqlite3.Connection:
|
||||||
if self._conn is None:
|
if self._conn is None:
|
||||||
|
|||||||
+281
-2
@@ -3,7 +3,10 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import ast
|
import ast
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import sqlite3
|
||||||
|
import threading
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -101,7 +104,7 @@ def test_every_method_routes_sqlite3_through_executor(tmp_path) -> None:
|
|||||||
|
|
||||||
assert row == ("a",)
|
assert row == ("a",)
|
||||||
assert rows == [("a",), ("b",), ("c",)]
|
assert rows == [("a",), ("b",), ("c",)]
|
||||||
assert hass.async_add_executor_job.await_count == calls_before + 7
|
assert hass.async_add_executor_job.await_count == calls_before + 5
|
||||||
_run(database.close())
|
_run(database.close())
|
||||||
|
|
||||||
|
|
||||||
@@ -118,11 +121,38 @@ async def test_every_wrapper_call_awaits_executor_job(tmp_path) -> None:
|
|||||||
async with database.transaction():
|
async with database.transaction():
|
||||||
pass
|
pass
|
||||||
|
|
||||||
assert hass.async_add_executor_job.await_count == 9
|
assert hass.async_add_executor_job.await_count == 7
|
||||||
assert all(callable(c.args[0]) for c in hass.async_add_executor_job.call_args_list)
|
assert all(callable(c.args[0]) for c in hass.async_add_executor_job.call_args_list)
|
||||||
await database.close()
|
await database.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_public_methods_return_only_plain_data(tmp_path) -> None:
|
||||||
|
"""execute/execute_many return plain dataclasses, never sqlite3 objects."""
|
||||||
|
hass = _make_hass_mock()
|
||||||
|
database = NestQuestDatabase(hass)
|
||||||
|
await database.open(tmp_path / "plain.db")
|
||||||
|
result = await database.execute(
|
||||||
|
"CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)"
|
||||||
|
)
|
||||||
|
assert not isinstance(result, (sqlite3.Connection, sqlite3.Cursor))
|
||||||
|
insert = await database.execute("INSERT INTO t (name) VALUES (?)", ("a",))
|
||||||
|
assert not isinstance(insert, (sqlite3.Connection, sqlite3.Cursor))
|
||||||
|
assert insert.rowcount == 1
|
||||||
|
assert insert.lastrowid == 1
|
||||||
|
many = await database.execute_many(
|
||||||
|
"INSERT INTO t (name) VALUES (?)", [("b",), ("c",)]
|
||||||
|
)
|
||||||
|
assert not isinstance(many, (sqlite3.Connection, sqlite3.Cursor))
|
||||||
|
assert many.rowcount == 2
|
||||||
|
row = await database.fetch_one("SELECT name FROM t WHERE id = 1")
|
||||||
|
assert row == ("a",)
|
||||||
|
rows = await database.fetch_all("SELECT name FROM t ORDER BY id")
|
||||||
|
assert rows == [("a",), ("b",), ("c",)]
|
||||||
|
for value in (*rows, row):
|
||||||
|
assert not isinstance(value, sqlite3.Cursor)
|
||||||
|
await database.close()
|
||||||
|
|
||||||
|
|
||||||
async def test_no_sqlite3_call_on_event_loop_with_real_executor(tmp_path) -> None:
|
async def test_no_sqlite3_call_on_event_loop_with_real_executor(tmp_path) -> None:
|
||||||
"""With the conftest hass (real run_in_executor), queries still succeed."""
|
"""With the conftest hass (real run_in_executor), queries still succeed."""
|
||||||
hass, _registry = make_hass()
|
hass, _registry = make_hass()
|
||||||
@@ -133,6 +163,68 @@ async def test_no_sqlite3_call_on_event_loop_with_real_executor(tmp_path) -> Non
|
|||||||
await database.close()
|
await database.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_real_executor_never_touches_loop_thread(tmp_path, monkeypatch) -> None:
|
||||||
|
"""Every real sqlite3 call must run on a non-event-loop thread.
|
||||||
|
|
||||||
|
sqlite3.connect is instrumented so a proxy wraps the connection and
|
||||||
|
every cursor it hands out; each proxied call records the thread it ran
|
||||||
|
on. A recorded thread id equal to the event-loop thread would mean a
|
||||||
|
sqlite3 call crossed the executor boundary.
|
||||||
|
"""
|
||||||
|
loop_thread = threading.get_ident()
|
||||||
|
recorded: list[int] = []
|
||||||
|
guard = threading.Lock()
|
||||||
|
|
||||||
|
def _note() -> None:
|
||||||
|
with guard:
|
||||||
|
recorded.append(threading.get_ident())
|
||||||
|
|
||||||
|
class _Proxy:
|
||||||
|
"""Generic proxy recording the thread of every attribute call."""
|
||||||
|
|
||||||
|
def __init__(self, target: Any) -> None:
|
||||||
|
object.__setattr__(self, "_target", target)
|
||||||
|
|
||||||
|
def __getattr__(self, name: str):
|
||||||
|
attr = getattr(object.__getattribute__(self, "_target"), name)
|
||||||
|
if not callable(attr):
|
||||||
|
return attr
|
||||||
|
|
||||||
|
def _wrapped(*args, **kwargs):
|
||||||
|
_note()
|
||||||
|
result = attr(*args, **kwargs)
|
||||||
|
if isinstance(result, sqlite3.Cursor):
|
||||||
|
return _Proxy(result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
return _wrapped
|
||||||
|
|
||||||
|
real_connect = sqlite3.connect
|
||||||
|
|
||||||
|
def _traced_connect(*args, **kwargs):
|
||||||
|
return _Proxy(real_connect(*args, **kwargs))
|
||||||
|
|
||||||
|
monkeypatch.setattr(db_module.sqlite3, "connect", _traced_connect)
|
||||||
|
|
||||||
|
hass, _registry = make_hass()
|
||||||
|
database = NestQuestDatabase(hass)
|
||||||
|
await database.open(tmp_path / "threads.db")
|
||||||
|
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY)")
|
||||||
|
await database.execute("INSERT INTO t (id) VALUES (?)", (1,))
|
||||||
|
await database.execute_many("INSERT INTO t (id) VALUES (?)", [(2,), (3,)])
|
||||||
|
assert await database.fetch_one("SELECT id FROM t WHERE id = 1") == (1,)
|
||||||
|
assert await database.fetch_all("SELECT id FROM t") == [(1,), (2,), (3,)]
|
||||||
|
async with database.transaction():
|
||||||
|
await database.execute("INSERT INTO t (id) VALUES (?)", (4,))
|
||||||
|
await database.close()
|
||||||
|
|
||||||
|
assert recorded, "expected at least one recorded sqlite3 call"
|
||||||
|
assert loop_thread not in recorded, (
|
||||||
|
f"sqlite3 call ran on the event-loop thread {loop_thread}; "
|
||||||
|
f"recorded threads: {sorted(set(recorded))}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Source scan: sqlite3.* only inside executor-delegated functions
|
# Source scan: sqlite3.* only inside executor-delegated functions
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -205,6 +297,8 @@ def test_close_is_idempotent(tmp_path) -> None:
|
|||||||
_run(database.close())
|
_run(database.close())
|
||||||
assert database.connected is False
|
assert database.connected is False
|
||||||
assert hass.async_add_executor_job.await_count == 2
|
assert hass.async_add_executor_job.await_count == 2
|
||||||
|
assert database.rowcount == -1
|
||||||
|
assert database.lastrowid is None
|
||||||
|
|
||||||
|
|
||||||
def test_operations_after_close_raise_clear_error(tmp_path) -> None:
|
def test_operations_after_close_raise_clear_error(tmp_path) -> None:
|
||||||
@@ -279,6 +373,191 @@ async def test_transaction_persists_across_connections(tmp_path) -> None:
|
|||||||
await other.close()
|
await other.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Serialization: one lock per wrapper, no cross-commit interleaving
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_concurrent_writers_do_not_interleave_commits(tmp_path) -> None:
|
||||||
|
"""Real-executor proof: nothing commits inside another transaction's span.
|
||||||
|
|
||||||
|
A transaction runner repeatedly counts rows, inserts, and counts again
|
||||||
|
inside BEGIN..COMMIT; the count must grow by exactly one, proving no
|
||||||
|
other writer committed in between even though several writer tasks fire
|
||||||
|
statements concurrently on real executor threads. Statements issued
|
||||||
|
while a transaction is open queue behind it on the wrapper lock.
|
||||||
|
"""
|
||||||
|
hass, _registry = make_hass()
|
||||||
|
database = NestQuestDatabase(hass)
|
||||||
|
await database.open(tmp_path / "serial.db")
|
||||||
|
await database.execute("CREATE TABLE t (kind TEXT, seq INTEGER)")
|
||||||
|
tx_rounds, writers, writer_rounds = 6, 5, 8
|
||||||
|
total = tx_rounds + writers * writer_rounds
|
||||||
|
|
||||||
|
async def _tx_runner() -> None:
|
||||||
|
for rnd in range(tx_rounds):
|
||||||
|
async with database.transaction():
|
||||||
|
rows = await database.fetch_all("SELECT COUNT(*) FROM t")
|
||||||
|
seen = rows[0][0] if rows else -1
|
||||||
|
await database.execute(
|
||||||
|
"INSERT INTO t (kind, seq) VALUES (?, ?)", ("tx", rnd)
|
||||||
|
)
|
||||||
|
rows = await database.fetch_all("SELECT COUNT(*) FROM t")
|
||||||
|
grown = rows[0][0] if rows else -1
|
||||||
|
if grown != seen + 1:
|
||||||
|
raise AssertionError(
|
||||||
|
f"interleaved commit: count {seen} -> {grown} "
|
||||||
|
f"in tx round {rnd}"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _writer(worker: int) -> None:
|
||||||
|
for rnd in range(writer_rounds):
|
||||||
|
await database.execute(
|
||||||
|
"INSERT INTO t (kind, seq) VALUES (?, ?)", ("w", worker)
|
||||||
|
)
|
||||||
|
|
||||||
|
await asyncio.gather(
|
||||||
|
_tx_runner(), *(_writer(w) for w in range(writers))
|
||||||
|
)
|
||||||
|
|
||||||
|
rows = await database.fetch_all("SELECT COUNT(*) FROM t")
|
||||||
|
assert rows[0][0] == total, f"expected {total} rows, got {rows[0][0]}"
|
||||||
|
await database.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_transaction_span_excludes_concurrent_transaction(tmp_path) -> None:
|
||||||
|
"""A second transaction caller is rejected with a clear error."""
|
||||||
|
hass, _registry = make_hass()
|
||||||
|
database = NestQuestDatabase(hass)
|
||||||
|
await database.open(tmp_path / "reject.db")
|
||||||
|
await database.execute("CREATE TABLE t (id INTEGER)")
|
||||||
|
|
||||||
|
started = asyncio.Event()
|
||||||
|
|
||||||
|
async def _first() -> None:
|
||||||
|
async with database.transaction():
|
||||||
|
started.set()
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
|
||||||
|
first = asyncio.create_task(_first())
|
||||||
|
await started.wait()
|
||||||
|
with pytest.raises(RuntimeError, match="transaction"):
|
||||||
|
async with database.transaction():
|
||||||
|
pass
|
||||||
|
await first
|
||||||
|
await database.execute("INSERT INTO t (id) VALUES (?)", (1,))
|
||||||
|
assert await database.fetch_one("SELECT id FROM t") == (1,)
|
||||||
|
await database.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_transaction_blocks_other_statements_until_commit(tmp_path) -> None:
|
||||||
|
"""A statement issued during an open transaction queues behind it."""
|
||||||
|
hass, _registry = make_hass()
|
||||||
|
database = NestQuestDatabase(hass)
|
||||||
|
await database.open(tmp_path / "block.db")
|
||||||
|
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)")
|
||||||
|
|
||||||
|
order: list[str] = []
|
||||||
|
started = asyncio.Event()
|
||||||
|
release = asyncio.Event()
|
||||||
|
|
||||||
|
async def _txn() -> None:
|
||||||
|
async with database.transaction():
|
||||||
|
await database.execute("INSERT INTO t (name) VALUES (?)", ("in_tx",))
|
||||||
|
order.append("tx-start")
|
||||||
|
started.set()
|
||||||
|
await release.wait()
|
||||||
|
order.append("tx-end")
|
||||||
|
|
||||||
|
async def _outsider() -> None:
|
||||||
|
await started.wait()
|
||||||
|
await database.execute("INSERT INTO t (name) VALUES (?)", ("after",))
|
||||||
|
order.append("outside")
|
||||||
|
|
||||||
|
txn_task = asyncio.create_task(_txn())
|
||||||
|
await started.wait()
|
||||||
|
outsider_task = asyncio.create_task(_outsider())
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
assert order == ["tx-start"], f"outsider ran inside transaction: {order}"
|
||||||
|
release.set()
|
||||||
|
await asyncio.gather(txn_task, outsider_task)
|
||||||
|
assert order == ["tx-start", "tx-end", "outside"]
|
||||||
|
rows = await database.fetch_all("SELECT name FROM t ORDER BY id")
|
||||||
|
assert rows == [("in_tx",), ("after",)]
|
||||||
|
await database.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_concurrent_transaction_is_rejected_with_clear_error(
|
||||||
|
tmp_path,
|
||||||
|
) -> None:
|
||||||
|
"""A second concurrent transaction() gets a clear RuntimeError."""
|
||||||
|
hass, _registry = make_hass()
|
||||||
|
database = NestQuestDatabase(hass)
|
||||||
|
await database.open(tmp_path / "concurrent-tx.db")
|
||||||
|
await database.execute("CREATE TABLE t (id INTEGER)")
|
||||||
|
|
||||||
|
started = asyncio.Event()
|
||||||
|
|
||||||
|
async def _first() -> None:
|
||||||
|
async with database.transaction():
|
||||||
|
started.set()
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
|
||||||
|
first = asyncio.create_task(_first())
|
||||||
|
await started.wait()
|
||||||
|
with pytest.raises(RuntimeError, match="transaction"):
|
||||||
|
async with database.transaction():
|
||||||
|
pass
|
||||||
|
await first
|
||||||
|
await database.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_close_waits_for_in_flight_transaction(tmp_path) -> None:
|
||||||
|
"""close() serializes: an open transaction finishes before shutdown."""
|
||||||
|
hass, _registry = make_hass()
|
||||||
|
database = NestQuestDatabase(hass)
|
||||||
|
await database.open(tmp_path / "closerace.db")
|
||||||
|
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)")
|
||||||
|
|
||||||
|
started = asyncio.Event()
|
||||||
|
release = asyncio.Event()
|
||||||
|
|
||||||
|
async def _txn() -> None:
|
||||||
|
async with database.transaction():
|
||||||
|
await database.execute("INSERT INTO t (name) VALUES (?)", ("kept",))
|
||||||
|
started.set()
|
||||||
|
await release.wait()
|
||||||
|
|
||||||
|
txn_task = asyncio.create_task(_txn())
|
||||||
|
await started.wait()
|
||||||
|
close_task = asyncio.create_task(database.close())
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
assert database.connected is True, "close() raced an open transaction"
|
||||||
|
release.set()
|
||||||
|
await asyncio.gather(txn_task, close_task)
|
||||||
|
assert database.connected is False
|
||||||
|
|
||||||
|
witness = NestQuestDatabase(hass)
|
||||||
|
await witness.open(tmp_path / "closerace.db")
|
||||||
|
assert await witness.fetch_all("SELECT name FROM t") == [("kept",)]
|
||||||
|
await witness.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_close_during_transaction_from_same_task_is_rejected(
|
||||||
|
tmp_path,
|
||||||
|
) -> None:
|
||||||
|
"""close() inside its own transaction body raises instead of deadlocking."""
|
||||||
|
hass, _registry = make_hass()
|
||||||
|
database = NestQuestDatabase(hass)
|
||||||
|
await database.open(tmp_path / "selfclose.db")
|
||||||
|
await database.execute("CREATE TABLE t (id INTEGER)")
|
||||||
|
with pytest.raises(RuntimeError, match="transaction"):
|
||||||
|
async with database.transaction():
|
||||||
|
await database.close()
|
||||||
|
assert database.connected is True
|
||||||
|
await database.close()
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# rowcount / lastrowid surface
|
# rowcount / lastrowid surface
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
+41
-1
@@ -3,6 +3,8 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from conftest import make_config_entry, make_hass, wire_entry_to_registry
|
from conftest import make_config_entry, make_hass, wire_entry_to_registry
|
||||||
|
|
||||||
from custom_components.nestquest import DOMAIN, async_setup_entry, async_unload_entry
|
from custom_components.nestquest import DOMAIN, async_setup_entry, async_unload_entry
|
||||||
@@ -196,4 +198,42 @@ async def test_setup_awaitable_listener_removal_supported() -> None:
|
|||||||
assert result is True
|
assert result is True
|
||||||
assert removed == [True]
|
assert removed == [True]
|
||||||
assert hass.data == {}
|
assert hass.data == {}
|
||||||
assert entry.runtime_data is None
|
assert entry.runtime_data is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_setup_closes_database_when_listener_registration_fails() -> None:
|
||||||
|
"""A listener-registration failure must not leak the opened database."""
|
||||||
|
from custom_components.nestquest.db import NestQuestDatabase
|
||||||
|
|
||||||
|
class _Boom(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
entry = make_config_entry(entry_id="listener-boom")
|
||||||
|
entry.add_update_listener = lambda listener: (_ for _ in ()).throw(_Boom())
|
||||||
|
hass, _registry = make_hass()
|
||||||
|
with pytest.raises(_Boom):
|
||||||
|
await async_setup_entry(hass, entry)
|
||||||
|
assert hass.data.get(DOMAIN, {}) == {}
|
||||||
|
assert not hasattr(entry, "runtime_data")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_unload_closes_database_even_when_listener_removal_raises() -> None:
|
||||||
|
"""Unload removes the listener, closes the DB in a finally, then raises."""
|
||||||
|
from custom_components.nestquest.db import NestQuestDatabase
|
||||||
|
|
||||||
|
class _RemoveBoom(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _remover():
|
||||||
|
raise _RemoveBoom()
|
||||||
|
|
||||||
|
entry = make_config_entry(entry_id="remove-boom")
|
||||||
|
entry.add_update_listener = lambda listener: _remover
|
||||||
|
hass, _registry = make_hass()
|
||||||
|
await async_setup_entry(hass, entry)
|
||||||
|
database = entry.runtime_data.database
|
||||||
|
with pytest.raises(_RemoveBoom):
|
||||||
|
await async_unload_entry(hass, entry)
|
||||||
|
assert database.connected is False, "database leaked despite remover error"
|
||||||
|
assert entry.runtime_data is None
|
||||||
|
assert DOMAIN not in hass.data
|
||||||
Reference in New Issue
Block a user