Skip the sqlite quick_check on clean restarts (#38972)
This commit is contained in:
parent
05d54a60b0
commit
7c346c2f7c
3 changed files with 130 additions and 6 deletions
|
@ -30,11 +30,18 @@ _LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
DB_TIMEZONE = "+00:00"
|
DB_TIMEZONE = "+00:00"
|
||||||
|
|
||||||
|
TABLE_EVENTS = "events"
|
||||||
|
TABLE_STATES = "states"
|
||||||
|
TABLE_RECORDER_RUNS = "recorder_runs"
|
||||||
|
TABLE_SCHEMA_CHANGES = "schema_changes"
|
||||||
|
|
||||||
|
ALL_TABLES = [TABLE_EVENTS, TABLE_STATES, TABLE_RECORDER_RUNS, TABLE_SCHEMA_CHANGES]
|
||||||
|
|
||||||
|
|
||||||
class Events(Base): # type: ignore
|
class Events(Base): # type: ignore
|
||||||
"""Event history data."""
|
"""Event history data."""
|
||||||
|
|
||||||
__tablename__ = "events"
|
__tablename__ = TABLE_EVENTS
|
||||||
event_id = Column(Integer, primary_key=True)
|
event_id = Column(Integer, primary_key=True)
|
||||||
event_type = Column(String(32))
|
event_type = Column(String(32))
|
||||||
event_data = Column(Text)
|
event_data = Column(Text)
|
||||||
|
@ -88,7 +95,7 @@ class Events(Base): # type: ignore
|
||||||
class States(Base): # type: ignore
|
class States(Base): # type: ignore
|
||||||
"""State change history."""
|
"""State change history."""
|
||||||
|
|
||||||
__tablename__ = "states"
|
__tablename__ = TABLE_STATES
|
||||||
state_id = Column(Integer, primary_key=True)
|
state_id = Column(Integer, primary_key=True)
|
||||||
domain = Column(String(64))
|
domain = Column(String(64))
|
||||||
entity_id = Column(String(255))
|
entity_id = Column(String(255))
|
||||||
|
@ -153,7 +160,7 @@ class States(Base): # type: ignore
|
||||||
class RecorderRuns(Base): # type: ignore
|
class RecorderRuns(Base): # type: ignore
|
||||||
"""Representation of recorder run."""
|
"""Representation of recorder run."""
|
||||||
|
|
||||||
__tablename__ = "recorder_runs"
|
__tablename__ = TABLE_RECORDER_RUNS
|
||||||
run_id = Column(Integer, primary_key=True)
|
run_id = Column(Integer, primary_key=True)
|
||||||
start = Column(DateTime(timezone=True), default=dt_util.utcnow)
|
start = Column(DateTime(timezone=True), default=dt_util.utcnow)
|
||||||
end = Column(DateTime(timezone=True))
|
end = Column(DateTime(timezone=True))
|
||||||
|
@ -191,7 +198,7 @@ class RecorderRuns(Base): # type: ignore
|
||||||
class SchemaChanges(Base): # type: ignore
|
class SchemaChanges(Base): # type: ignore
|
||||||
"""Representation of schema version changes."""
|
"""Representation of schema version changes."""
|
||||||
|
|
||||||
__tablename__ = "schema_changes"
|
__tablename__ = TABLE_SCHEMA_CHANGES
|
||||||
change_id = Column(Integer, primary_key=True)
|
change_id = Column(Integer, primary_key=True)
|
||||||
schema_version = Column(Integer)
|
schema_version = Column(Integer)
|
||||||
changed = Column(DateTime(timezone=True), default=dt_util.utcnow)
|
changed = Column(DateTime(timezone=True), default=dt_util.utcnow)
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
"""SQLAlchemy util functions."""
|
"""SQLAlchemy util functions."""
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
|
from datetime import timedelta
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
|
@ -9,6 +10,7 @@ from sqlalchemy.exc import OperationalError, SQLAlchemyError
|
||||||
import homeassistant.util.dt as dt_util
|
import homeassistant.util.dt as dt_util
|
||||||
|
|
||||||
from .const import DATA_INSTANCE, SQLITE_URL_PREFIX
|
from .const import DATA_INSTANCE, SQLITE_URL_PREFIX
|
||||||
|
from .models import ALL_TABLES, process_timestamp
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
@ -16,6 +18,11 @@ RETRIES = 3
|
||||||
QUERY_RETRY_WAIT = 0.1
|
QUERY_RETRY_WAIT = 0.1
|
||||||
SQLITE3_POSTFIXES = ["", "-wal", "-shm"]
|
SQLITE3_POSTFIXES = ["", "-wal", "-shm"]
|
||||||
|
|
||||||
|
# This is the maximum time after the recorder ends the session
|
||||||
|
# before we no longer consider startup to be a "restart" and we
|
||||||
|
# should do a check on the sqlite3 database.
|
||||||
|
MAX_RESTART_TIME = timedelta(minutes=6)
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def session_scope(*, hass=None, session=None):
|
def session_scope(*, hass=None, session=None):
|
||||||
|
@ -116,13 +123,42 @@ def validate_or_move_away_sqlite_database(dburl: str) -> bool:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def last_run_was_recently_clean(cursor):
|
||||||
|
"""Verify the last recorder run was recently clean."""
|
||||||
|
|
||||||
|
cursor.execute("SELECT end FROM recorder_runs ORDER BY start DESC LIMIT 1;")
|
||||||
|
end_time = cursor.fetchone()
|
||||||
|
|
||||||
|
if not end_time or not end_time[0]:
|
||||||
|
return False
|
||||||
|
|
||||||
|
last_run_end_time = process_timestamp(dt_util.parse_datetime(end_time[0]))
|
||||||
|
now = dt_util.utcnow()
|
||||||
|
|
||||||
|
_LOGGER.debug("The last run ended at: %s (now: %s)", last_run_end_time, now)
|
||||||
|
|
||||||
|
if last_run_end_time + MAX_RESTART_TIME < now:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def basic_sanity_check(cursor):
|
||||||
|
"""Check tables to make sure select does not fail."""
|
||||||
|
|
||||||
|
for table in ALL_TABLES:
|
||||||
|
cursor.execute(f"SELECT * FROM {table} LIMIT 1;") # sec: not injection
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def validate_sqlite_database(dbpath: str) -> bool:
|
def validate_sqlite_database(dbpath: str) -> bool:
|
||||||
"""Run a quick check on an sqlite database to see if it is corrupt."""
|
"""Run a quick check on an sqlite database to see if it is corrupt."""
|
||||||
import sqlite3 # pylint: disable=import-outside-toplevel
|
import sqlite3 # pylint: disable=import-outside-toplevel
|
||||||
|
|
||||||
try:
|
try:
|
||||||
conn = sqlite3.connect(dbpath)
|
conn = sqlite3.connect(dbpath)
|
||||||
conn.cursor().execute("PRAGMA QUICK_CHECK")
|
run_checks_on_open_db(dbpath, conn.cursor())
|
||||||
conn.close()
|
conn.close()
|
||||||
except sqlite3.DatabaseError:
|
except sqlite3.DatabaseError:
|
||||||
_LOGGER.exception("The database at %s is corrupt or malformed.", dbpath)
|
_LOGGER.exception("The database at %s is corrupt or malformed.", dbpath)
|
||||||
|
@ -131,6 +167,20 @@ def validate_sqlite_database(dbpath: str) -> bool:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def run_checks_on_open_db(dbpath, cursor):
|
||||||
|
"""Run checks that will generate a sqlite3 exception if there is corruption."""
|
||||||
|
if basic_sanity_check(cursor) and last_run_was_recently_clean(cursor):
|
||||||
|
_LOGGER.debug(
|
||||||
|
"The quick_check will be skipped as the system was restarted cleanly and passed the basic sanity check"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
_LOGGER.debug(
|
||||||
|
"A quick_check is being performed on the sqlite3 database at %s", dbpath
|
||||||
|
)
|
||||||
|
cursor.execute("PRAGMA QUICK_CHECK")
|
||||||
|
|
||||||
|
|
||||||
def _move_away_broken_database(dbfile: str) -> None:
|
def _move_away_broken_database(dbfile: str) -> None:
|
||||||
"""Move away a broken sqlite3 database."""
|
"""Move away a broken sqlite3 database."""
|
||||||
|
|
||||||
|
|
|
@ -1,10 +1,13 @@
|
||||||
"""Test util methods."""
|
"""Test util methods."""
|
||||||
|
from datetime import timedelta
|
||||||
import os
|
import os
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from homeassistant.components.recorder import util
|
from homeassistant.components.recorder import util
|
||||||
from homeassistant.components.recorder.const import DATA_INSTANCE, SQLITE_URL_PREFIX
|
from homeassistant.components.recorder.const import DATA_INSTANCE, SQLITE_URL_PREFIX
|
||||||
|
from homeassistant.util import dt as dt_util
|
||||||
|
|
||||||
from tests.async_mock import MagicMock, patch
|
from tests.async_mock import MagicMock, patch
|
||||||
from tests.common import get_test_home_assistant, init_recorder_component
|
from tests.common import get_test_home_assistant, init_recorder_component
|
||||||
|
@ -74,17 +77,81 @@ def test_validate_or_move_away_sqlite_database(hass, tmpdir, caplog):
|
||||||
util.validate_sqlite_database(test_db_file) is True
|
util.validate_sqlite_database(test_db_file) is True
|
||||||
|
|
||||||
assert os.path.exists(test_db_file) is True
|
assert os.path.exists(test_db_file) is True
|
||||||
assert util.validate_or_move_away_sqlite_database(dburl) is True
|
assert util.validate_or_move_away_sqlite_database(dburl) is False
|
||||||
|
|
||||||
_corrupt_db_file(test_db_file)
|
_corrupt_db_file(test_db_file)
|
||||||
|
|
||||||
|
assert util.validate_sqlite_database(dburl) is False
|
||||||
|
|
||||||
assert util.validate_or_move_away_sqlite_database(dburl) is False
|
assert util.validate_or_move_away_sqlite_database(dburl) is False
|
||||||
|
|
||||||
assert "corrupt or malformed" in caplog.text
|
assert "corrupt or malformed" in caplog.text
|
||||||
|
|
||||||
|
assert util.validate_sqlite_database(dburl) is False
|
||||||
|
|
||||||
assert util.validate_or_move_away_sqlite_database(dburl) is True
|
assert util.validate_or_move_away_sqlite_database(dburl) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_last_run_was_recently_clean(hass_recorder):
|
||||||
|
"""Test we can check if the last recorder run was recently clean."""
|
||||||
|
hass = hass_recorder()
|
||||||
|
|
||||||
|
cursor = hass.data[DATA_INSTANCE].engine.raw_connection().cursor()
|
||||||
|
|
||||||
|
assert util.last_run_was_recently_clean(cursor) is False
|
||||||
|
|
||||||
|
hass.data[DATA_INSTANCE]._close_run()
|
||||||
|
|
||||||
|
assert util.last_run_was_recently_clean(cursor) is True
|
||||||
|
|
||||||
|
thirty_min_future_time = dt_util.utcnow() + timedelta(minutes=30)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.recorder.dt_util.utcnow",
|
||||||
|
return_value=thirty_min_future_time,
|
||||||
|
):
|
||||||
|
assert util.last_run_was_recently_clean(cursor) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_basic_sanity_check(hass_recorder):
|
||||||
|
"""Test the basic sanity checks with a missing table."""
|
||||||
|
hass = hass_recorder()
|
||||||
|
|
||||||
|
cursor = hass.data[DATA_INSTANCE].engine.raw_connection().cursor()
|
||||||
|
|
||||||
|
assert util.basic_sanity_check(cursor) is True
|
||||||
|
|
||||||
|
cursor.execute("DROP TABLE states;")
|
||||||
|
|
||||||
|
with pytest.raises(sqlite3.DatabaseError):
|
||||||
|
util.basic_sanity_check(cursor)
|
||||||
|
|
||||||
|
|
||||||
|
def test_combined_checks(hass_recorder):
|
||||||
|
"""Run Checks on the open database."""
|
||||||
|
hass = hass_recorder()
|
||||||
|
|
||||||
|
cursor = hass.data[DATA_INSTANCE].engine.raw_connection().cursor()
|
||||||
|
|
||||||
|
assert util.run_checks_on_open_db("fake_db_path", cursor) is None
|
||||||
|
|
||||||
|
# We are patching recorder.util here in order
|
||||||
|
# to avoid creating the full database on disk
|
||||||
|
with patch("homeassistant.components.recorder.util.last_run_was_recently_clean"):
|
||||||
|
assert util.run_checks_on_open_db("fake_db_path", cursor) is None
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.recorder.util.last_run_was_recently_clean",
|
||||||
|
side_effect=sqlite3.DatabaseError,
|
||||||
|
), pytest.raises(sqlite3.DatabaseError):
|
||||||
|
util.run_checks_on_open_db("fake_db_path", cursor)
|
||||||
|
|
||||||
|
cursor.execute("DROP TABLE events;")
|
||||||
|
|
||||||
|
with pytest.raises(sqlite3.DatabaseError):
|
||||||
|
util.run_checks_on_open_db("fake_db_path", cursor)
|
||||||
|
|
||||||
|
|
||||||
def _corrupt_db_file(test_db_file):
|
def _corrupt_db_file(test_db_file):
|
||||||
"""Corrupt an sqlite3 database file."""
|
"""Corrupt an sqlite3 database file."""
|
||||||
f = open(test_db_file, "a")
|
f = open(test_db_file, "a")
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue