Add CI job which runs recorder tests on PostgreSQL (#80614)

Co-authored-by: Franck Nijhof <git@frenck.dev>
Co-authored-by: J. Nick Koston <nick@koston.org>
pull/86436/head
Erik Montnemery 2023-02-07 16:46:14 +01:00 committed by GitHub
parent 3a83b2f66f
commit 720f51657d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 187 additions and 41 deletions

View File

@ -1006,12 +1006,116 @@ jobs:
run: |
./script/check_dirty
pytest-postgres:
runs-on: ubuntu-20.04
services:
postgres:
image: postgres:15.0
ports:
- 5432:5432
env:
POSTGRES_PASSWORD: password
options: --health-cmd="pg_isready -hlocalhost -Upostgres" --health-interval=5s --health-timeout=2s --health-retries=3
if: |
(github.event_name != 'push' || github.event.repository.full_name == 'home-assistant/core')
&& github.event.inputs.lint-only != 'true'
&& needs.info.outputs.test_full_suite == 'true'
needs:
- info
- base
- gen-requirements-all
- hassfest
- lint-black
- lint-other
- lint-isort
- mypy
strategy:
fail-fast: false
matrix:
python-version: ${{ fromJson(needs.info.outputs.python_versions) }}
name: >-
Run tests Python ${{ matrix.python-version }} (postgresql)
steps:
- name: Install additional OS dependencies
run: |
sudo apt-get update
sudo apt-get -y install \
bluez \
ffmpeg \
postgresql-server-dev-12
- name: Check out code from GitHub
uses: actions/checkout@v3.1.0
- name: Set up Python ${{ matrix.python-version }}
id: python
uses: actions/setup-python@v4.3.0
with:
python-version: ${{ matrix.python-version }}
check-latest: true
- name: Restore full Python ${{ matrix.python-version }} virtual environment
id: cache-venv
uses: actions/cache@v3.0.11
with:
path: venv
key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{
needs.info.outputs.python_cache_key }}
- name: Fail job if Python cache restore failed
if: steps.cache-venv.outputs.cache-hit != 'true'
run: |
echo "Failed to restore Python virtual environment from cache"
exit 1
- name: Register Python problem matcher
run: |
echo "::add-matcher::.github/workflows/matchers/python.json"
- name: Install Pytest Annotation plugin
run: |
. venv/bin/activate
# Ideally this should be part of our dependencies
# However this plugin is fairly new and doesn't run correctly
# on a non-GitHub environment.
pip install pytest-github-actions-annotate-failures==0.1.3
- name: Register pytest slow test problem matcher
run: |
echo "::add-matcher::.github/workflows/matchers/pytest-slow.json"
- name: Install SQL Python libraries
run: |
. venv/bin/activate
pip install psycopg2 sqlalchemy_utils
- name: Run pytest (partially)
timeout-minutes: 10
shell: bash
run: |
. venv/bin/activate
python --version
python3 -X dev -m pytest \
-qq \
--timeout=9 \
-n 1 \
--cov="homeassistant.components.recorder" \
--cov-report=xml \
--cov-report=term-missing \
-o console_output_style=count \
--durations=0 \
--durations-min=10 \
-p no:sugar \
--dburl=postgresql://postgres:password@127.0.0.1/homeassistant-test \
tests/components/recorder
- name: Upload coverage artifact
uses: actions/upload-artifact@v3.1.0
with:
name: coverage-${{ matrix.python-version }}-postgresql
path: coverage.xml
- name: Check dirty
run: |
./script/check_dirty
coverage:
name: Upload test coverage to Codecov
runs-on: ubuntu-20.04
needs:
- info
- pytest
- pytest-postgres
steps:
- name: Check out code from GitHub
uses: actions/checkout@v3.3.0

View File

@ -503,10 +503,15 @@ def test_get_significant_states_without_initial(hass_recorder):
hass = hass_recorder()
zero, four, states = record_states(hass)
one = zero + timedelta(seconds=1)
one_with_microsecond = zero + timedelta(seconds=1, microseconds=1)
one_and_half = zero + timedelta(seconds=1.5)
for entity_id in states:
states[entity_id] = list(
filter(lambda s: s.last_changed != one, states[entity_id])
filter(
lambda s: s.last_changed != one
and s.last_changed != one_with_microsecond,
states[entity_id],
)
)
del states["media_player.test2"]
@ -687,9 +692,6 @@ def record_states(hass) -> tuple[datetime, datetime, dict[str, list[State]]]:
states[mp].append(
set_state(mp, "idle", attributes={"media_title": str(sentinel.mt1)})
)
states[mp].append(
set_state(mp, "YouTube", attributes={"media_title": str(sentinel.mt2)})
)
states[mp2].append(
set_state(mp2, "YouTube", attributes={"media_title": str(sentinel.mt2)})
)
@ -700,6 +702,14 @@ def record_states(hass) -> tuple[datetime, datetime, dict[str, list[State]]]:
set_state(therm, 20, attributes={"current_temperature": 19.5})
)
with patch(
"homeassistant.components.recorder.core.dt_util.utcnow",
return_value=one + timedelta(microseconds=1),
):
states[mp].append(
set_state(mp, "YouTube", attributes={"media_title": str(sentinel.mt2)})
)
with patch(
"homeassistant.components.recorder.core.dt_util.utcnow", return_value=two
):
@ -740,8 +750,8 @@ async def test_state_changes_during_period_query_during_migration_to_schema_25(
recorder_db_url: str,
):
"""Test we can query data prior to schema 25 and during migration to schema 25."""
if recorder_db_url.startswith("mysql://"):
# This test doesn't run on MySQL / MariaDB; we can't drop table state_attributes
if recorder_db_url.startswith(("mysql://", "postgresql://")):
# This test doesn't run on MySQL / MariaDB / Postgresql; we can't drop table state_attributes
return
instance = await async_setup_recorder_instance(hass, {})
@ -795,8 +805,8 @@ async def test_get_states_query_during_migration_to_schema_25(
recorder_db_url: str,
):
"""Test we can query data prior to schema 25 and during migration to schema 25."""
if recorder_db_url.startswith("mysql://"):
# This test doesn't run on MySQL / MariaDB; we can't drop table state_attributes
if recorder_db_url.startswith(("mysql://", "postgresql://")):
# This test doesn't run on MySQL / MariaDB / Postgresql; we can't drop table state_attributes
return
instance = await async_setup_recorder_instance(hass, {})
@ -846,8 +856,8 @@ async def test_get_states_query_during_migration_to_schema_25_multiple_entities(
recorder_db_url: str,
):
"""Test we can query data prior to schema 25 and during migration to schema 25."""
if recorder_db_url.startswith("mysql://"):
# This test doesn't run on MySQL / MariaDB; we can't drop table state_attributes
if recorder_db_url.startswith(("mysql://", "postgresql://")):
# This test doesn't run on MySQL / MariaDB / Postgresql; we can't drop table state_attributes
return
instance = await async_setup_recorder_instance(hass, {})

View File

@ -400,12 +400,13 @@ def test_get_significant_states_with_initial(hass_recorder):
hass = hass_recorder()
zero, four, states = record_states(hass)
one = zero + timedelta(seconds=1)
one_with_microsecond = zero + timedelta(seconds=1, microseconds=1)
one_and_half = zero + timedelta(seconds=1.5)
for entity_id in states:
if entity_id == "media_player.test":
states[entity_id] = states[entity_id][1:]
for state in states[entity_id]:
if state.last_changed == one:
if state.last_changed == one or state.last_changed == one_with_microsecond:
state.last_changed = one_and_half
state.last_updated = one_and_half
@ -428,10 +429,15 @@ def test_get_significant_states_without_initial(hass_recorder):
hass = hass_recorder()
zero, four, states = record_states(hass)
one = zero + timedelta(seconds=1)
one_with_microsecond = zero + timedelta(seconds=1, microseconds=1)
one_and_half = zero + timedelta(seconds=1.5)
for entity_id in states:
states[entity_id] = list(
filter(lambda s: s.last_changed != one, states[entity_id])
filter(
lambda s: s.last_changed != one
and s.last_changed != one_with_microsecond,
states[entity_id],
)
)
del states["media_player.test2"]
@ -594,9 +600,6 @@ def record_states(hass) -> tuple[datetime, datetime, dict[str, list[State]]]:
states[mp].append(
set_state(mp, "idle", attributes={"media_title": str(sentinel.mt1)})
)
states[mp].append(
set_state(mp, "YouTube", attributes={"media_title": str(sentinel.mt2)})
)
states[mp2].append(
set_state(mp2, "YouTube", attributes={"media_title": str(sentinel.mt2)})
)
@ -607,6 +610,14 @@ def record_states(hass) -> tuple[datetime, datetime, dict[str, list[State]]]:
set_state(therm, 20, attributes={"current_temperature": 19.5})
)
with patch(
"homeassistant.components.recorder.core.dt_util.utcnow",
return_value=one + timedelta(microseconds=1),
):
states[mp].append(
set_state(mp, "YouTube", attributes={"media_title": str(sentinel.mt2)})
)
with patch(
"homeassistant.components.recorder.core.dt_util.utcnow", return_value=two
):

View File

@ -567,10 +567,15 @@ def test_saving_state_include_domains_globs(hass_recorder):
hass, ["test.recorder", "test2.recorder", "test3.included_entity"]
)
assert len(states) == 2
assert _state_with_context(hass, "test2.recorder").as_dict() == states[0].as_dict()
state_map = {state.entity_id: state for state in states}
assert (
_state_with_context(hass, "test2.recorder").as_dict()
== state_map["test2.recorder"].as_dict()
)
assert (
_state_with_context(hass, "test3.included_entity").as_dict()
== states[1].as_dict()
== state_map["test3.included_entity"].as_dict()
)
@ -1595,7 +1600,7 @@ async def test_database_lock_and_overflow(
async def test_database_lock_timeout(recorder_mock, hass, recorder_db_url):
"""Test locking database timeout when recorder stopped."""
if recorder_db_url.startswith("mysql://"):
if recorder_db_url.startswith(("mysql://", "postgresql://")):
# This test is specific for SQLite: Locking is not implemented for other engines
return
@ -1667,7 +1672,7 @@ async def test_database_connection_keep_alive_disabled_on_sqlite(
recorder_db_url: str,
):
"""Test we do not do keep alive for sqlite."""
if recorder_db_url.startswith("mysql://"):
if recorder_db_url.startswith(("mysql://", "postgresql://")):
# This test is specific for SQLite, keepalive runs on other engines
return

View File

@ -90,9 +90,15 @@ async def test_purge_old_states(
assert "test.recorder2" in instance._old_states
states_after_purge = session.query(States)
assert states_after_purge[1].old_state_id == states_after_purge[0].state_id
assert states_after_purge[0].old_state_id is None
states_after_purge = list(session.query(States))
# Since these states are deleted in batches, we can't guarantee the order
# but we can look them up by state
state_map_by_state = {state.state: state for state in states_after_purge}
dontpurgeme_5 = state_map_by_state["dontpurgeme_5"]
dontpurgeme_4 = state_map_by_state["dontpurgeme_4"]
assert dontpurgeme_5.old_state_id == dontpurgeme_4.state_id
assert dontpurgeme_4.old_state_id is None
finished = purge_old_data(instance, purge_before, repack=False)
assert finished
@ -140,7 +146,7 @@ async def test_purge_old_states_encouters_database_corruption(
recorder_db_url: str,
):
"""Test database image image is malformed while deleting old states."""
if recorder_db_url.startswith("mysql://"):
if recorder_db_url.startswith(("mysql://", "postgresql://")):
# This test is specific for SQLite, wiping the database on error only happens
# with SQLite.
return

View File

@ -1733,14 +1733,19 @@ def record_states(hass):
states[mp].append(
set_state(mp, "idle", attributes={"media_title": str(sentinel.mt1)})
)
states[mp].append(
set_state(mp, "YouTube", attributes={"media_title": str(sentinel.mt2)})
)
states[sns1].append(set_state(sns1, "10", attributes=sns1_attr))
states[sns2].append(set_state(sns2, "10", attributes=sns2_attr))
states[sns3].append(set_state(sns3, "10", attributes=sns3_attr))
states[sns4].append(set_state(sns4, "10", attributes=sns4_attr))
with patch(
"homeassistant.components.recorder.core.dt_util.utcnow",
return_value=one + timedelta(microseconds=1),
):
states[mp].append(
set_state(mp, "YouTube", attributes={"media_title": str(sentinel.mt2)})
)
with patch(
"homeassistant.components.recorder.core.dt_util.utcnow", return_value=two
):

View File

@ -16,7 +16,7 @@ from tests.common import SetupRecorderInstanceT, get_system_health_info
async def test_recorder_system_health(recorder_mock, hass, recorder_db_url):
"""Test recorder system health."""
if recorder_db_url.startswith("mysql://"):
if recorder_db_url.startswith(("mysql://", "postgresql://")):
# This test is specific for SQLite
return
@ -94,7 +94,7 @@ async def test_recorder_system_health_crashed_recorder_runs_table(
recorder_db_url: str,
):
"""Test recorder system health with crashed recorder runs table."""
if recorder_db_url.startswith("mysql://"):
if recorder_db_url.startswith(("mysql://", "postgresql://")):
# This test is specific for SQLite
return

View File

@ -16,7 +16,10 @@ from homeassistant.components import recorder
from homeassistant.components.recorder import history, util
from homeassistant.components.recorder.const import DOMAIN, SQLITE_URL_PREFIX
from homeassistant.components.recorder.db_schema import RecorderRuns
from homeassistant.components.recorder.models import UnsupportedDialect
from homeassistant.components.recorder.models import (
UnsupportedDialect,
process_timestamp,
)
from homeassistant.components.recorder.util import (
end_incomplete_runs,
is_second_sunday,
@ -44,8 +47,8 @@ def test_session_scope_not_setup(hass_recorder):
def test_recorder_bad_commit(hass_recorder, recorder_db_url):
"""Bad _commit should retry 3 times."""
if recorder_db_url.startswith("mysql://"):
# This test is specific for SQLite: mysql does not raise an OperationalError
if recorder_db_url.startswith(("mysql://", "postgresql://")):
# This test is specific for SQLite: mysql/postgresql does not raise an OperationalError
# which triggers retries for the bad query below, it raises ProgrammingError
# on which we give up
return
@ -696,7 +699,7 @@ async def test_no_issue_for_mariadb_with_MDEV_25020(hass, caplog, mysql_version)
def test_basic_sanity_check(hass_recorder, recorder_db_url):
"""Test the basic sanity checks with a missing table."""
if recorder_db_url.startswith("mysql://"):
if recorder_db_url.startswith(("mysql://", "postgresql://")):
# This test is specific for SQLite
return
@ -714,7 +717,7 @@ def test_basic_sanity_check(hass_recorder, recorder_db_url):
def test_combined_checks(hass_recorder, caplog, recorder_db_url):
"""Run Checks on the open database."""
if recorder_db_url.startswith("mysql://"):
if recorder_db_url.startswith(("mysql://", "postgresql://")):
# This test is specific for SQLite
return
@ -780,24 +783,23 @@ def test_end_incomplete_runs(hass_recorder, caplog):
assert run_info.closed_incorrect is False
now = dt_util.utcnow()
now_without_tz = now.replace(tzinfo=None)
end_incomplete_runs(session, now)
run_info = run_information_with_session(session)
assert run_info.closed_incorrect is True
assert run_info.end == now_without_tz
assert process_timestamp(run_info.end) == now
session.flush()
later = dt_util.utcnow()
end_incomplete_runs(session, later)
run_info = run_information_with_session(session)
assert run_info.end == now_without_tz
assert process_timestamp(run_info.end) == now
assert "Ended unfinished session" in caplog.text
def test_periodic_db_cleanups(hass_recorder, recorder_db_url):
"""Test periodic db cleanups."""
if recorder_db_url.startswith("mysql://"):
if recorder_db_url.startswith(("mysql://", "postgresql://")):
# This test is specific for SQLite
return

View File

@ -2163,7 +2163,7 @@ async def test_backup_start_timeout(
recorder_mock, hass, hass_ws_client, hass_supervisor_access_token, recorder_db_url
):
"""Test getting backup start when recorder is not present."""
if recorder_db_url.startswith("mysql://"):
if recorder_db_url.startswith(("mysql://", "postgresql://")):
# This test is specific for SQLite: Locking is not implemented for other engines
return
@ -2204,7 +2204,7 @@ async def test_backup_end_without_start(
recorder_mock, hass, hass_ws_client, hass_supervisor_access_token, recorder_db_url
):
"""Test backup start."""
if recorder_db_url.startswith("mysql://"):
if recorder_db_url.startswith(("mysql://", "postgresql://")):
# This test is specific for SQLite: Locking is not implemented for other engines
return

View File

@ -1002,9 +1002,12 @@ def recorder_db_url(pytestconfig):
assert not sqlalchemy_utils.database_exists(db_url)
sqlalchemy_utils.create_database(db_url, encoding=charset)
elif db_url.startswith("postgresql://"):
pass
import sqlalchemy_utils
assert not sqlalchemy_utils.database_exists(db_url)
sqlalchemy_utils.create_database(db_url, encoding="utf8")
yield db_url
if db_url.startswith("mysql://"):
if db_url.startswith("mysql://") or db_url.startswith("postgresql://"):
sqlalchemy_utils.drop_database(db_url)