Skip to content

Commit d64270f

Browse files
committed
Merge branch 'main' of github.com:FlexMeasures/flexmeasures
2 parents cc0a027 + 9b08a47 commit d64270f

8 files changed

Lines changed: 231 additions & 4 deletions

File tree

documentation/changelog.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ Infrastructure / Support
114114
* ``flexmeasures db upgrade`` now runs ``VACUUM ANALYZE`` after upgrading by default, so Postgres has fresh planner statistics right after a migration; opt out with ``--no-vacuum`` [see `PR #2333 <https://www.github.com/FlexMeasures/flexmeasures/pull/2333>`_]
115115
* Automate Docker Hub image publishing and a PyPI install smoke test on release, add a manually-triggered QA workflow that runs the toy tutorials and HEMS walkthrough against a local Docker Compose stack, and add a helper script to list merged PRs since the last tag [see `PR #2260 <https://www.github.com/FlexMeasures/flexmeasures/pull/2260>`_]
116116
* Warn hosts when the database schema is not at the latest migration, and skip startup template provisioning until migrations are applied [see `PR #2309 <https://www.github.com/FlexMeasures/flexmeasures/pull/2309>`_]
117+
* Avoid creating default data while running database maintenance commands such as ``flexmeasures db upgrade`` and ``flexmeasures db-ops restore``, so hosts can upgrade or restore databases without startup provisioning touching an outdated or partially restored schema [see `PR #2428 <https://www.github.com/FlexMeasures/flexmeasures/pull/2428>`_]
117118
* Add ``FLEXMEASURES_DEFAULT_JOB_TIMEOUT`` and ``FLEXMEASURES_JOB_TIMEOUT`` settings for configuring RQ job timeouts globally and per queue, and log actionable guidance when a forecasting job times out [see `PR #2318 <https://github.com/FlexMeasures/flexmeasures/pull/2318>`_]
118119
* Stop manual runs of the Docker publishing workflow from overwriting the ``latest`` image tag, and let them opt in to it explicitly [see `PR #2316 <https://www.github.com/FlexMeasures/flexmeasures/pull/2316>`_]
119120
* Add a pre-commit hook that blocks image files (png, jpg, gif, bmp, tiff, webp, ico, psd) from being committed outside of ``flexmeasures/ui/static/`` and ``documentation/``, to protect the git history from binary bloat; screenshots belong in the ``FlexMeasures/screenshots`` repo instead [see `PR #2315 <https://www.github.com/FlexMeasures/flexmeasures/pull/2315>`_]

flexmeasures/data/__init__.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,26 @@
1919

2020
ma: Marshmallow = Marshmallow()
2121

22+
DB_COMMANDS = frozenset(db_cli_group.commands.keys())
23+
DB_OPS_COMMANDS = frozenset(("dump", "reset", "restore"))
24+
25+
26+
def is_running_database_command() -> bool:
27+
"""Return whether this process is running a database maintenance command."""
28+
args = sys.argv[1:]
29+
return _has_command_pair(args, "db", DB_COMMANDS) or _has_command_pair(
30+
args, "db-ops", DB_OPS_COMMANDS
31+
)
32+
33+
34+
def _has_command_pair(
35+
args: list[str], command_group: str, commands: frozenset[str]
36+
) -> bool:
37+
return any(
38+
arg == command_group and i + 1 < len(args) and args[i + 1] in commands
39+
for i, arg in enumerate(args)
40+
)
41+
2242

2343
def _is_running_db_upgrade_command() -> bool:
2444
"""Return whether this process is already running the Alembic upgrade command."""

flexmeasures/data/scripts/data_gen.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,18 @@
2020
from flexmeasures.data.utils import TEMPLATE_COPY_GUIDANCE_PREFIX
2121

2222

23+
def _skip_default_data_creation_for_database_command(default_data_name: str) -> bool:
24+
"""Return whether implicit default data creation should be skipped."""
25+
from flexmeasures.data import is_running_database_command
26+
27+
if is_running_database_command():
28+
click.echo(
29+
f"Skipping default {default_data_name} creation during database maintenance command."
30+
)
31+
return True
32+
return False
33+
34+
2335
def add_default_data_sources(db: SQLAlchemy):
2436
for source_name, source_type in (
2537
("Seita", "demo script"),
@@ -234,6 +246,9 @@ def provision_default_template_assets(db: SQLAlchemy):
234246
This currently provisions the single-asset starter templates which are
235247
intended to show up in the asset copy UI.
236248
"""
249+
if _skip_default_data_creation_for_database_command("template asset"):
250+
return
251+
237252
asset_types = add_default_asset_types(db)
238253

239254
# Battery
@@ -372,6 +387,9 @@ def populate_initial_structure(db: SQLAlchemy):
372387
"""
373388
Add initially useful structural data.
374389
"""
390+
if _skip_default_data_creation_for_database_command("initial structure"):
391+
return
392+
375393
click.echo("Populating the database %s with structural data ..." % db.engine)
376394
add_default_data_sources(db)
377395
add_default_user_roles(db)

flexmeasures/data/tests/test_template_assets.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,16 @@
22

33
from flexmeasures.data.models.generic_assets import GenericAsset
44
from flexmeasures.data.models.time_series import Sensor
5-
from flexmeasures.data.scripts.data_gen import provision_default_template_assets
5+
from flexmeasures.data.scripts.data_gen import (
6+
populate_initial_structure,
7+
provision_default_template_assets,
8+
)
9+
10+
11+
class _FailingDb:
12+
@property
13+
def session(self):
14+
raise AssertionError("Default data creation should not touch the database.")
615

716

817
def test_provision_default_template_assets_creates_single_asset_templates(
@@ -71,3 +80,28 @@ def test_provision_default_template_assets_is_idempotent(fresh_db):
7180
fresh_db.session.scalar(select(func.count()).select_from(Sensor))
7281
== sensor_count
7382
)
83+
84+
85+
def test_initial_structure_creation_skips_database_commands(monkeypatch):
86+
monkeypatch.setattr("flexmeasures.data.is_running_database_command", lambda: True)
87+
88+
populate_initial_structure(_FailingDb())
89+
90+
91+
def test_template_asset_provisioning_skips_database_commands(fresh_db, monkeypatch):
92+
monkeypatch.setattr("flexmeasures.data.is_running_database_command", lambda: True)
93+
asset_count = fresh_db.session.scalar(
94+
select(func.count()).select_from(GenericAsset)
95+
)
96+
sensor_count = fresh_db.session.scalar(select(func.count()).select_from(Sensor))
97+
98+
provision_default_template_assets(fresh_db)
99+
100+
assert (
101+
fresh_db.session.scalar(select(func.count()).select_from(GenericAsset))
102+
== asset_count
103+
)
104+
assert (
105+
fresh_db.session.scalar(select(func.count()).select_from(Sensor))
106+
== sensor_count
107+
)

flexmeasures/data/tests/test_utils.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from flexmeasures.data import db, register_at
88
from flexmeasures.data.utils import (
99
DatabaseSchemaRevisionStatus,
10+
database_schema_has_revision,
1011
format_database_schema_revision_status,
1112
get_database_schema_revision_status,
1213
)
@@ -40,6 +41,25 @@ def get_heads(self) -> tuple[str, ...]:
4041
return self._heads
4142

4243

44+
class _DummyRevision:
45+
def __init__(self, revision: str):
46+
self.revision = revision
47+
48+
49+
class _DummyRevisionMap:
50+
def __init__(self, revisions: tuple[str, ...]):
51+
self._revisions = revisions
52+
53+
def iterate_revisions(self, *args, **kwargs):
54+
return (_DummyRevision(revision) for revision in self._revisions)
55+
56+
57+
class _DummyScriptDirectoryWithRevisionMap(_DummyScriptDirectory):
58+
def __init__(self, heads: tuple[str, ...], revisions: tuple[str, ...]):
59+
super().__init__(heads)
60+
self.revision_map = _DummyRevisionMap(revisions)
61+
62+
4363
def test_schema_mismatch_log_record_is_deduplicated(
4464
app, clean_redis, monkeypatch, caplog
4565
):
@@ -181,3 +201,39 @@ def raise_operational_error():
181201
assert status.expected_heads == ("head-a",)
182202
assert status.inspection_error is not None
183203
assert status.is_migrated_to_head is False
204+
205+
206+
def test_database_schema_has_revision_when_revision_is_in_current_history(
207+
app, monkeypatch
208+
):
209+
monkeypatch.setattr(db.engine, "connect", lambda: _DummyConnection())
210+
monkeypatch.setattr(
211+
"flexmeasures.data.utils.MigrationContext.configure",
212+
lambda connection: _DummyMigrationContext(("head-a",)),
213+
)
214+
monkeypatch.setattr(
215+
"flexmeasures.data.utils.ScriptDirectory.from_config",
216+
lambda config: _DummyScriptDirectoryWithRevisionMap(
217+
heads=("head-a",), revisions=("head-a", "required-a")
218+
),
219+
)
220+
221+
assert database_schema_has_revision(app, "required-a") is True
222+
223+
224+
def test_database_schema_has_revision_false_when_revision_is_not_in_current_history(
225+
app, monkeypatch
226+
):
227+
monkeypatch.setattr(db.engine, "connect", lambda: _DummyConnection())
228+
monkeypatch.setattr(
229+
"flexmeasures.data.utils.MigrationContext.configure",
230+
lambda connection: _DummyMigrationContext(("old-a",)),
231+
)
232+
monkeypatch.setattr(
233+
"flexmeasures.data.utils.ScriptDirectory.from_config",
234+
lambda config: _DummyScriptDirectoryWithRevisionMap(
235+
heads=("head-a",), revisions=("old-a",)
236+
),
237+
)
238+
239+
assert database_schema_has_revision(app, "required-a") is False

flexmeasures/data/utils.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from __future__ import annotations
66

77
from alembic.config import Config as AlembicConfig
8+
from alembic.script.revision import RevisionError
89
from alembic.runtime.migration import MigrationContext
910
from alembic.script import ScriptDirectory
1011
from dataclasses import dataclass
@@ -86,6 +87,38 @@ def get_database_schema_revision_status(app) -> DatabaseSchemaRevisionStatus:
8687
)
8788

8889

90+
def database_schema_has_revision(app, required_revision: str) -> bool:
91+
"""Return whether the connected database includes a specific Alembic revision."""
92+
revision_status = get_database_schema_revision_status(app)
93+
if (
94+
revision_status.inspection_error is not None
95+
or not revision_status.current_heads
96+
):
97+
return False
98+
99+
migrate_extension = app.extensions.get("migrate")
100+
if migrate_extension is None:
101+
return False
102+
103+
alembic_config = AlembicConfig()
104+
alembic_config.set_main_option("script_location", migrate_extension.directory)
105+
script = ScriptDirectory.from_config(alembic_config)
106+
107+
for current_head in revision_status.current_heads:
108+
try:
109+
revisions = script.revision_map.iterate_revisions(
110+
current_head,
111+
required_revision,
112+
inclusive=True,
113+
assert_relative_length=False,
114+
)
115+
except RevisionError:
116+
continue
117+
if any(revision.revision == required_revision for revision in revisions):
118+
return True
119+
return False
120+
121+
89122
def format_database_schema_revision_status(
90123
status: DatabaseSchemaRevisionStatus,
91124
) -> str:

flexmeasures/utils/app_utils.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,19 +21,29 @@
2121
_sentry_filter_notfound,
2222
)
2323

24+
TEMPLATE_ASSETS_REQUIRED_MIGRATION = "4b0f2e9c1a6d"
25+
2426

2527
def provision_default_template_assets_on_startup(app: Flask) -> None:
2628
"""Provision starter template assets when startup settings and schema allow it."""
29+
from flexmeasures.data import is_running_database_command
30+
31+
if is_running_database_command():
32+
return
33+
2734
if (
2835
not app.config.get("FLEXMEASURES_CREATE_TEMPLATE_ASSETS_ON_STARTUP", False)
2936
or app.testing
3037
or app.config.get("FLEXMEASURES_ENV") == "documentation"
3138
):
3239
return
3340

34-
if not getattr(app, "database_schema_is_migrated_to_head", True):
41+
from flexmeasures.data.utils import database_schema_has_revision
42+
43+
if not database_schema_has_revision(app, TEMPLATE_ASSETS_REQUIRED_MIGRATION):
3544
app.logger.info(
36-
"Skipping startup template provisioning because the database schema is not at the Alembic head revision yet."
45+
"Skipping startup template provisioning because the database schema is missing the required migration "
46+
f"{TEMPLATE_ASSETS_REQUIRED_MIGRATION}."
3747
)
3848
return
3949

flexmeasures/utils/tests/test_app_utils.py

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from flexmeasures.data import (
1616
_is_running_db_upgrade_command,
1717
_schema_mismatch_deduplication_key,
18+
is_running_database_command,
1819
)
1920
from flexmeasures.data.utils import DatabaseSchemaRevisionStatus
2021
from flexmeasures.utils.app_utils import (
@@ -431,7 +432,10 @@ def fail_if_called(db):
431432
monkeypatch.setitem(
432433
app.config, "FLEXMEASURES_CREATE_TEMPLATE_ASSETS_ON_STARTUP", True
433434
)
434-
monkeypatch.setattr(app, "database_schema_is_migrated_to_head", False)
435+
monkeypatch.setattr("flexmeasures.data.is_running_database_command", lambda: False)
436+
monkeypatch.setattr(
437+
"flexmeasures.data.utils.database_schema_has_revision", lambda app, rev: False
438+
)
435439
monkeypatch.setattr(
436440
"flexmeasures.data.scripts.data_gen.provision_default_template_assets",
437441
fail_if_called,
@@ -443,6 +447,57 @@ def fail_if_called(db):
443447
assert "Skipping startup template provisioning" in caplog.text
444448

445449

450+
def test_provision_default_template_assets_on_startup_skips_database_commands(
451+
app, monkeypatch
452+
):
453+
def fail_if_called(db):
454+
raise AssertionError("Template provisioning should not run.")
455+
456+
monkeypatch.setattr(app, "testing", False)
457+
monkeypatch.setitem(app.config, "FLEXMEASURES_ENV", "production")
458+
monkeypatch.setitem(
459+
app.config, "FLEXMEASURES_CREATE_TEMPLATE_ASSETS_ON_STARTUP", True
460+
)
461+
monkeypatch.setattr("flexmeasures.data.is_running_database_command", lambda: True)
462+
monkeypatch.setattr(
463+
"flexmeasures.data.scripts.data_gen.provision_default_template_assets",
464+
fail_if_called,
465+
)
466+
467+
provision_default_template_assets_on_startup(app)
468+
469+
470+
def test_is_running_database_command(monkeypatch):
471+
monkeypatch.setattr(
472+
"sys.argv",
473+
[
474+
"/path/to/flexmeasures",
475+
"--custom-option-with-value",
476+
"some-value",
477+
"db-ops",
478+
"restore",
479+
],
480+
)
481+
482+
assert is_running_database_command() is True
483+
484+
485+
def test_is_running_database_command_false_for_other_cli_groups(monkeypatch):
486+
monkeypatch.setattr(
487+
"sys.argv", ["/path/to/flexmeasures", "add", "initial-structure"]
488+
)
489+
490+
assert is_running_database_command() is False
491+
492+
493+
def test_is_running_database_command_false_for_option_value(monkeypatch):
494+
monkeypatch.setattr(
495+
"sys.argv", ["/path/to/flexmeasures", "add", "toy-account", "--name", "db"]
496+
)
497+
498+
assert is_running_database_command() is False
499+
500+
446501
def test_is_running_db_upgrade_command(monkeypatch):
447502
monkeypatch.setattr("sys.argv", ["/path/to/flexmeasures", "db", "upgrade", "--sql"])
448503

0 commit comments

Comments
 (0)