Restore use of local timezone for MariaDB/MySQL in SQL integration (#91313)
* Use local timezone for recorder connection The fix in #90335 had an unexpected side effect of using UTC for the timezone since all recorder operations use UTC. Since only sqlite much use the database executor we can use a seperate connection pool which uses local time This also ensures that the engines are disposed of when Home Assistant is shutdown as previously we did not cleanly disconnect * coverage * fix unclean shutdown in config flow * tweaks
This commit is contained in:
parent
2d1ae6660b
commit
4366f83ac8
5 changed files with 124 additions and 8 deletions
|
@ -32,6 +32,7 @@ from .const import ( # noqa: F401
|
||||||
INTEGRATION_PLATFORM_EXCLUDE_ATTRIBUTES,
|
INTEGRATION_PLATFORM_EXCLUDE_ATTRIBUTES,
|
||||||
INTEGRATION_PLATFORMS_LOAD_IN_RECORDER_THREAD,
|
INTEGRATION_PLATFORMS_LOAD_IN_RECORDER_THREAD,
|
||||||
SQLITE_URL_PREFIX,
|
SQLITE_URL_PREFIX,
|
||||||
|
SupportedDialect,
|
||||||
)
|
)
|
||||||
from .core import Recorder
|
from .core import Recorder
|
||||||
from .services import async_register_services
|
from .services import async_register_services
|
||||||
|
|
|
@ -77,6 +77,7 @@ def validate_query(db_url: str, query: str, column: str) -> bool:
|
||||||
|
|
||||||
if sess:
|
if sess:
|
||||||
sess.close()
|
sess.close()
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
16
homeassistant/components/sql/models.py
Normal file
16
homeassistant/components/sql/models.py
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
"""The sql integration models."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from sqlalchemy.orm import scoped_session
|
||||||
|
|
||||||
|
from homeassistant.core import CALLBACK_TYPE
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class SQLData:
|
||||||
|
"""Data for the sql integration."""
|
||||||
|
|
||||||
|
shutdown_event_cancel: CALLBACK_TYPE
|
||||||
|
session_makers_by_db_url: dict[str, scoped_session]
|
|
@ -13,7 +13,11 @@ from sqlalchemy.orm import Session, scoped_session, sessionmaker
|
||||||
from sqlalchemy.sql.lambdas import StatementLambdaElement
|
from sqlalchemy.sql.lambdas import StatementLambdaElement
|
||||||
from sqlalchemy.util import LRUCache
|
from sqlalchemy.util import LRUCache
|
||||||
|
|
||||||
from homeassistant.components.recorder import CONF_DB_URL, get_instance
|
from homeassistant.components.recorder import (
|
||||||
|
CONF_DB_URL,
|
||||||
|
SupportedDialect,
|
||||||
|
get_instance,
|
||||||
|
)
|
||||||
from homeassistant.components.sensor import (
|
from homeassistant.components.sensor import (
|
||||||
CONF_STATE_CLASS,
|
CONF_STATE_CLASS,
|
||||||
SensorDeviceClass,
|
SensorDeviceClass,
|
||||||
|
@ -27,8 +31,9 @@ from homeassistant.const import (
|
||||||
CONF_UNIQUE_ID,
|
CONF_UNIQUE_ID,
|
||||||
CONF_UNIT_OF_MEASUREMENT,
|
CONF_UNIT_OF_MEASUREMENT,
|
||||||
CONF_VALUE_TEMPLATE,
|
CONF_VALUE_TEMPLATE,
|
||||||
|
EVENT_HOMEASSISTANT_STOP,
|
||||||
)
|
)
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import Event, HomeAssistant, callback
|
||||||
from homeassistant.exceptions import TemplateError
|
from homeassistant.exceptions import TemplateError
|
||||||
from homeassistant.helpers import issue_registry as ir
|
from homeassistant.helpers import issue_registry as ir
|
||||||
from homeassistant.helpers.device_registry import DeviceEntryType
|
from homeassistant.helpers.device_registry import DeviceEntryType
|
||||||
|
@ -38,6 +43,7 @@ from homeassistant.helpers.template import Template
|
||||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||||
|
|
||||||
from .const import CONF_COLUMN_NAME, CONF_QUERY, DB_URL_RE, DOMAIN
|
from .const import CONF_COLUMN_NAME, CONF_QUERY, DB_URL_RE, DOMAIN
|
||||||
|
from .models import SQLData
|
||||||
from .util import resolve_db_url
|
from .util import resolve_db_url
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
@ -127,6 +133,36 @@ async def async_setup_entry(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _async_get_or_init_domain_data(hass: HomeAssistant) -> SQLData:
|
||||||
|
"""Get or initialize domain data."""
|
||||||
|
if DOMAIN in hass.data:
|
||||||
|
sql_data: SQLData = hass.data[DOMAIN]
|
||||||
|
return sql_data
|
||||||
|
|
||||||
|
session_makers_by_db_url: dict[str, scoped_session] = {}
|
||||||
|
|
||||||
|
#
|
||||||
|
# Ensure we dispose of all engines at shutdown
|
||||||
|
# to avoid unclean disconnects
|
||||||
|
#
|
||||||
|
# Shutdown all sessions in the executor since they will
|
||||||
|
# do blocking I/O
|
||||||
|
#
|
||||||
|
def _shutdown_db_engines(event: Event) -> None:
|
||||||
|
"""Shutdown all database engines."""
|
||||||
|
for sessmaker in session_makers_by_db_url.values():
|
||||||
|
sessmaker.connection().engine.dispose()
|
||||||
|
|
||||||
|
cancel_shutdown = hass.bus.async_listen_once(
|
||||||
|
EVENT_HOMEASSISTANT_STOP, _shutdown_db_engines
|
||||||
|
)
|
||||||
|
|
||||||
|
sql_data = SQLData(cancel_shutdown, session_makers_by_db_url)
|
||||||
|
hass.data[DOMAIN] = sql_data
|
||||||
|
return sql_data
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_sensor(
|
async def async_setup_sensor(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
name: str,
|
name: str,
|
||||||
|
@ -144,18 +180,30 @@ async def async_setup_sensor(
|
||||||
"""Set up the SQL sensor."""
|
"""Set up the SQL sensor."""
|
||||||
instance = get_instance(hass)
|
instance = get_instance(hass)
|
||||||
sessmaker: scoped_session | None
|
sessmaker: scoped_session | None
|
||||||
if use_database_executor := (db_url == instance.db_url):
|
sql_data = _async_get_or_init_domain_data(hass)
|
||||||
|
uses_recorder_db = db_url == instance.db_url
|
||||||
|
use_database_executor = False
|
||||||
|
if uses_recorder_db and instance.dialect_name == SupportedDialect.SQLITE:
|
||||||
|
use_database_executor = True
|
||||||
assert instance.engine is not None
|
assert instance.engine is not None
|
||||||
sessmaker = scoped_session(sessionmaker(bind=instance.engine, future=True))
|
sessmaker = scoped_session(sessionmaker(bind=instance.engine, future=True))
|
||||||
elif not (
|
# For other databases we need to create a new engine since
|
||||||
sessmaker := await hass.async_add_executor_job(
|
# we want the connection to use the default timezone and these
|
||||||
_validate_and_get_session_maker_for_db_url, db_url
|
# database engines will use QueuePool as its only sqlite that
|
||||||
)
|
# needs our custom pool. If there is already a session maker
|
||||||
|
# for this db_url we can use that so we do not create a new engine
|
||||||
|
# for every sensor.
|
||||||
|
elif db_url in sql_data.session_makers_by_db_url:
|
||||||
|
sessmaker = sql_data.session_makers_by_db_url[db_url]
|
||||||
|
elif sessmaker := await hass.async_add_executor_job(
|
||||||
|
_validate_and_get_session_maker_for_db_url, db_url
|
||||||
):
|
):
|
||||||
|
sql_data.session_makers_by_db_url[db_url] = sessmaker
|
||||||
|
else:
|
||||||
return
|
return
|
||||||
|
|
||||||
upper_query = query_str.upper()
|
upper_query = query_str.upper()
|
||||||
if use_database_executor:
|
if uses_recorder_db:
|
||||||
redacted_query = redact_credentials(query_str)
|
redacted_query = redact_credentials(query_str)
|
||||||
|
|
||||||
issue_key = unique_id if unique_id else redacted_query
|
issue_key = unique_id if unique_id else redacted_query
|
||||||
|
|
|
@ -407,3 +407,53 @@ async def test_no_issue_when_view_has_the_text_entity_id_in_it(
|
||||||
"Query contains entity_id but does not reference states_meta" not in caplog.text
|
"Query contains entity_id but does not reference states_meta" not in caplog.text
|
||||||
)
|
)
|
||||||
assert hass.states.get("sensor.get_entity_id") is not None
|
assert hass.states.get("sensor.get_entity_id") is not None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_multiple_sensors_using_same_db(
|
||||||
|
recorder_mock: Recorder, hass: HomeAssistant
|
||||||
|
) -> None:
|
||||||
|
"""Test multiple sensors using the same db."""
|
||||||
|
config = {
|
||||||
|
"db_url": "sqlite:///",
|
||||||
|
"query": "SELECT 5 as value",
|
||||||
|
"column": "value",
|
||||||
|
"name": "Select value SQL query",
|
||||||
|
}
|
||||||
|
config2 = {
|
||||||
|
"db_url": "sqlite:///",
|
||||||
|
"query": "SELECT 5 as value",
|
||||||
|
"column": "value",
|
||||||
|
"name": "Select value SQL query 2",
|
||||||
|
}
|
||||||
|
await init_integration(hass, config)
|
||||||
|
await init_integration(hass, config2, entry_id="2")
|
||||||
|
|
||||||
|
state = hass.states.get("sensor.select_value_sql_query")
|
||||||
|
assert state.state == "5"
|
||||||
|
assert state.attributes["value"] == 5
|
||||||
|
|
||||||
|
state = hass.states.get("sensor.select_value_sql_query_2")
|
||||||
|
assert state.state == "5"
|
||||||
|
assert state.attributes["value"] == 5
|
||||||
|
|
||||||
|
|
||||||
|
async def test_engine_is_disposed_at_stop(
|
||||||
|
recorder_mock: Recorder, hass: HomeAssistant
|
||||||
|
) -> None:
|
||||||
|
"""Test we dispose of the engine at stop."""
|
||||||
|
config = {
|
||||||
|
"db_url": "sqlite:///",
|
||||||
|
"query": "SELECT 5 as value",
|
||||||
|
"column": "value",
|
||||||
|
"name": "Select value SQL query",
|
||||||
|
}
|
||||||
|
await init_integration(hass, config)
|
||||||
|
|
||||||
|
state = hass.states.get("sensor.select_value_sql_query")
|
||||||
|
assert state.state == "5"
|
||||||
|
assert state.attributes["value"] == 5
|
||||||
|
|
||||||
|
with patch("sqlalchemy.engine.base.Engine.dispose") as mock_engine_dispose:
|
||||||
|
await hass.async_stop()
|
||||||
|
|
||||||
|
assert mock_engine_dispose.call_count == 2
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue