Skip the sqlite quick_check on clean restarts (#38972)

This commit is contained in:
J. Nick Koston 2020-08-21 07:20:46 -05:00 committed by GitHub
parent 05d54a60b0
commit 7c346c2f7c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 130 additions and 6 deletions

View file

@ -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)

View file

@ -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."""

View file

@ -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")