Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(robot-server): Implement storage for "enable/disable error recovery" setting #16333

Merged
merged 9 commits into from
Sep 30, 2024
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
Summary of changes from schema 6:

- Adds a new command_intent to store the commands intent in the commands table
- Adds the `boolean_setting` table.
"""

import json
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

from typing import Final

LATEST_VERSION_DIRECTORY: Final = "7"
LATEST_VERSION_DIRECTORY: Final = "7.1"

DECK_CONFIGURATION_FILE: Final = "deck_configuration.json"
PROTOCOLS_DIRECTORY: Final = "protocols"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ async def prepare_active_subdirectory(prepared_root: Path) -> Path:
v3_to_v4.Migration3to4(subdirectory="4"),
v4_to_v5.Migration4to5(subdirectory="5"),
v5_to_v6.Migration5to6(subdirectory="6"),
# Subdirectory "7" was previously used on our edge branch for an in-dev
# schema that was never released to the public. It may be present on
# internal robots.
v6_to_v7.Migration6to7(subdirectory=LATEST_VERSION_DIRECTORY),
Comment on lines +55 to 58
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So we're abandoning subdirectory 7 and replacing the 6->7 migration with 6->7.1. We've talked about this before, but this is the first time we're actually doing it. The downside is that we'll lose the past few weeks of run history on the in-office robots that are on the edge branch.

Alternatives:

  • Add some kind of special step to robot server startup that modifies 7/robot_server.db in-place to add the new table. I didn't do this because I think it would set up a surprising precedent of modifying the database outside of a migration.
  • Keep subdirectory 7 as-is, add the new table to a new schema 8, and make users go through a 6->7->8 chain when we release this. I didn't do this because it would add ongoing performance and maintenance cost. Also, I don't want to set a precedent of edge-only DB changes like this requiring migrations, because I want to keep them as not-annoying as possible so people aren't scared to make improvements on edge.

],
temp_file_prefix="temp-",
Expand Down
4 changes: 4 additions & 0 deletions robot-server/robot_server/persistence/tables/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@
action_table,
run_csv_rtp_table,
data_files_table,
boolean_setting_table,
PrimitiveParamSQLEnum,
ProtocolKindSQLEnum,
BooleanSettingKey,
)


Expand All @@ -28,6 +30,8 @@
"action_table",
"run_csv_rtp_table",
"data_files_table",
"boolean_setting_table",
"PrimitiveParamSQLEnum",
"ProtocolKindSQLEnum",
"BooleanSettingKey",
]
28 changes: 28 additions & 0 deletions robot-server/robot_server/persistence/tables/schema_7.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ class ProtocolKindSQLEnum(enum.Enum):
PrimitiveParamSQLEnum,
values_callable=lambda obj: [e.value for e in obj],
create_constraint=True,
# todo(mm, 2024-09-24): Can we add validate_strings=True here?
),
nullable=False,
),
Expand Down Expand Up @@ -263,3 +264,30 @@ class ProtocolKindSQLEnum(enum.Enum):
nullable=True,
),
)


class BooleanSettingKey(enum.Enum):
"""Keys for boolean settings."""

DISABLE_ERROR_RECOVERY = "disable_error_recovery"


boolean_setting_table = sqlalchemy.Table(
"boolean_setting",
metadata,
sqlalchemy.Column(
"key",
sqlalchemy.Enum(
BooleanSettingKey,
values_callable=lambda obj: [e.value for e in obj],
validate_strings=True,
create_constraint=True,
),
primary_key=True,
),
sqlalchemy.Column(
"value",
sqlalchemy.Boolean,
nullable=False,
),
)
46 changes: 46 additions & 0 deletions robot-server/robot_server/runs/error_recovery_setting_store.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# noqa: D100


import sqlalchemy

from robot_server.persistence.tables import boolean_setting_table, BooleanSettingKey


class ErrorRecoverySettingStore:
"""Persistently stores settings related to error recovery."""

def __init__(self, sql_engine: sqlalchemy.engine.Engine) -> None:
self._sql_engine = sql_engine

def get_is_disabled(self) -> bool | None:
"""Get the value of the "error recovery enabled" setting.

`None` is the default, i.e. it's never been explicitly set one way or the other.
"""
with self._sql_engine.begin() as transaction:
return transaction.execute(
sqlalchemy.select(boolean_setting_table.c.value).where(
boolean_setting_table.c.key
== BooleanSettingKey.DISABLE_ERROR_RECOVERY
)
).scalar_one_or_none()

def set_is_disabled(self, is_disabled: bool | None) -> None:
"""Set the value of the "error recovery enabled" setting.

`None` means revert to the default.
"""
with self._sql_engine.begin() as transaction:
transaction.execute(
sqlalchemy.delete(boolean_setting_table).where(
boolean_setting_table.c.key
== BooleanSettingKey.DISABLE_ERROR_RECOVERY
)
)
if is_disabled is not None:
transaction.execute(
sqlalchemy.insert(boolean_setting_table).values(
key=BooleanSettingKey.DISABLE_ERROR_RECOVERY,
value=is_disabled,
)
)
Original file line number Diff line number Diff line change
Expand Up @@ -30,23 +30,13 @@ def _get_corrupt_persistence_dir() -> Path:
async def _assert_reset_was_successful(
robot_client: RobotClient, persistence_directory: Path
) -> None:
# It should have no protocols.
# We really want to check that the server's persistence directory has been wiped
# clean, but testing that directly would rely on internal implementation details
# of file layout and tend to be brittle.
# As an approximation, just check that there are no protocols or runs left.
assert (await robot_client.get_protocols()).json()["data"] == []

# It should have no runs.
assert (await robot_client.get_runs()).json()["data"] == []

# There should be no files except for robot_server.db
# and an empty protocols/ directory.
all_files_and_directories = set(persistence_directory.glob("**/*"))
expected_files_and_directories = {
persistence_directory / "robot_server.db",
persistence_directory / "7",
persistence_directory / "7" / "protocols",
persistence_directory / "7" / "robot_server.db",
}
assert all_files_and_directories == expected_files_and_directories

Comment on lines -33 to -49
Copy link
Contributor Author

@SyntaxColoring SyntaxColoring Sep 24, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've removed the tests that check the filesystem to make sure that the protocol files, etc., have been removed. This test breaks every time we add a new persistence subdirectory or adjust the files inside it, and I don't personally find the test helpful these days.

Is this a good idea?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No in general, maybe yes for this PR. We really want this testing to exist because we have messed up integrations before. What I'd recommend is to figure out a way to not make it rely on brittle implementation details, by maybe presenting a contract about what files go where.


async def _wait_until_initialization_failed(robot_client: RobotClient) -> None:
"""Wait until the server returns an "initialization failed" health status."""
Expand Down
15 changes: 13 additions & 2 deletions robot-server/tests/persistence/test_tables.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,8 +142,20 @@
FOREIGN KEY(file_id) REFERENCES data_files (id)
)
""",
"""
CREATE TABLE boolean_setting (
"key" VARCHAR(22) NOT NULL,
value BOOLEAN NOT NULL,
PRIMARY KEY ("key"),
CONSTRAINT booleansettingkey CHECK ("key" IN ('disable_error_recovery'))
)
""",
]


EXPECTED_STATEMENTS_V7 = EXPECTED_STATEMENTS_LATEST


EXPECTED_STATEMENTS_V6 = [
"""
CREATE TABLE protocol (
Expand Down Expand Up @@ -257,8 +269,6 @@
]


EXPECTED_STATEMENTS_V7 = EXPECTED_STATEMENTS_LATEST

EXPECTED_STATEMENTS_V5 = [
"""
CREATE TABLE protocol (
Expand Down Expand Up @@ -334,6 +344,7 @@
""",
]


EXPECTED_STATEMENTS_V4 = [
"""
CREATE TABLE protocol (
Expand Down
29 changes: 29 additions & 0 deletions robot-server/tests/runs/test_error_recovery_setting_store.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"""Tests for error_recovery_setting_store."""


from robot_server.runs.error_recovery_setting_store import ErrorRecoverySettingStore

import pytest
import sqlalchemy


@pytest.fixture
def subject(
sql_engine: sqlalchemy.engine.Engine,
) -> ErrorRecoverySettingStore:
"""Return a test subject."""
return ErrorRecoverySettingStore(sql_engine=sql_engine)


def test_error_recovery_setting_store(subject: ErrorRecoverySettingStore) -> None:
"""Test `ErrorRecoverySettingStore`."""
assert subject.get_is_disabled() is None

subject.set_is_disabled(is_disabled=False)
assert subject.get_is_disabled() is False

subject.set_is_disabled(is_disabled=True)
assert subject.get_is_disabled() is True

subject.set_is_disabled(is_disabled=None)
assert subject.get_is_disabled() is None
Loading