Skip to content

Commit bc5f2cd

Browse files
authored
Reset System Database (#197)
A new command for programmatically resetting the DBOS system database. This is primarily useful for testing a DBOS app, to reset the internal state of DBOS between tests.
1 parent b200aaf commit bc5f2cd

File tree

5 files changed

+136
-50
lines changed

5 files changed

+136
-50
lines changed

dbos/_dbos.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@
5656
)
5757
from ._roles import default_required_roles, required_roles
5858
from ._scheduler import ScheduledWorkflow, scheduled
59-
from ._sys_db import WorkflowStatusString
59+
from ._sys_db import WorkflowStatusString, reset_system_database
6060
from ._tracer import dbos_tracer
6161

6262
if TYPE_CHECKING:
@@ -409,6 +409,22 @@ def _launch(self) -> None:
409409
dbos_logger.error(f"DBOS failed to launch: {traceback.format_exc()}")
410410
raise
411411

412+
@classmethod
413+
def reset_system_database(cls) -> None:
414+
"""
415+
Destroy the DBOS system database. Useful for resetting the state of DBOS between tests.
416+
This is a destructive operation and should only be used in a test environment.
417+
More information on testing DBOS apps: https://docs.dbos.dev/python/tutorials/testing
418+
"""
419+
if _dbos_global_instance is not None:
420+
_dbos_global_instance._reset_system_database()
421+
422+
def _reset_system_database(self) -> None:
423+
assert (
424+
not self._launched
425+
), "The system database cannot be reset after DBOS is launched. Resetting the system database is a destructive operation that should only be used in a test environment."
426+
reset_system_database(self.config)
427+
412428
def _destroy(self) -> None:
413429
self._initialized = False
414430
for event in self.stop_events:

dbos/_sys_db.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1265,3 +1265,46 @@ def remove_from_queue(self, workflow_id: str, queue: "Queue") -> None:
12651265
.where(SystemSchema.workflow_queue.c.workflow_uuid == workflow_id)
12661266
.values(completed_at_epoch_ms=int(time.time() * 1000))
12671267
)
1268+
1269+
1270+
def reset_system_database(config: ConfigFile) -> None:
1271+
sysdb_name = (
1272+
config["database"]["sys_db_name"]
1273+
if "sys_db_name" in config["database"] and config["database"]["sys_db_name"]
1274+
else config["database"]["app_db_name"] + SystemSchema.sysdb_suffix
1275+
)
1276+
postgres_db_url = sa.URL.create(
1277+
"postgresql+psycopg",
1278+
username=config["database"]["username"],
1279+
password=config["database"]["password"],
1280+
host=config["database"]["hostname"],
1281+
port=config["database"]["port"],
1282+
database="postgres",
1283+
)
1284+
try:
1285+
# Connect to postgres default database
1286+
engine = sa.create_engine(postgres_db_url)
1287+
1288+
with engine.connect() as conn:
1289+
# Set autocommit required for database dropping
1290+
conn.execution_options(isolation_level="AUTOCOMMIT")
1291+
1292+
# Terminate existing connections
1293+
conn.execute(
1294+
sa.text(
1295+
"""
1296+
SELECT pg_terminate_backend(pg_stat_activity.pid)
1297+
FROM pg_stat_activity
1298+
WHERE pg_stat_activity.datname = :db_name
1299+
AND pid <> pg_backend_pid()
1300+
"""
1301+
),
1302+
{"db_name": sysdb_name},
1303+
)
1304+
1305+
# Drop the database
1306+
conn.execute(sa.text(f"DROP DATABASE IF EXISTS {sysdb_name}"))
1307+
1308+
except sa.exc.SQLAlchemyError as e:
1309+
dbos_logger.error(f"Error resetting system database: {str(e)}")
1310+
raise e

dbos/cli/cli.py

Lines changed: 3 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,7 @@
1818
from .. import load_config
1919
from .._app_db import ApplicationDatabase
2020
from .._dbos_config import _is_valid_app_name
21-
from .._schemas.system_database import SystemSchema
22-
from .._sys_db import SystemDatabase
21+
from .._sys_db import SystemDatabase, reset_system_database
2322
from .._workflow_commands import _cancel_workflow, _get_workflow, _list_workflows
2423
from ..cli._github_init import create_template_from_github
2524
from ._template_init import copy_template, get_project_name, get_templates_directory
@@ -224,56 +223,12 @@ def reset(
224223
typer.echo("Operation cancelled.")
225224
raise typer.Exit()
226225
config = load_config()
227-
sysdb_name = (
228-
config["database"]["sys_db_name"]
229-
if "sys_db_name" in config["database"] and config["database"]["sys_db_name"]
230-
else config["database"]["app_db_name"] + SystemSchema.sysdb_suffix
231-
)
232-
postgres_db_url = sa.URL.create(
233-
"postgresql+psycopg",
234-
username=config["database"]["username"],
235-
password=config["database"]["password"],
236-
host=config["database"]["hostname"],
237-
port=config["database"]["port"],
238-
database="postgres",
239-
)
240226
try:
241-
# Connect to postgres default database
242-
engine = sa.create_engine(postgres_db_url)
243-
244-
with engine.connect() as conn:
245-
# Set autocommit required for database dropping
246-
conn.execution_options(isolation_level="AUTOCOMMIT")
247-
248-
# Terminate existing connections
249-
conn.execute(
250-
sa.text(
251-
"""
252-
SELECT pg_terminate_backend(pg_stat_activity.pid)
253-
FROM pg_stat_activity
254-
WHERE pg_stat_activity.datname = :db_name
255-
AND pid <> pg_backend_pid()
256-
"""
257-
),
258-
{"db_name": sysdb_name},
259-
)
260-
261-
# Drop the database
262-
conn.execute(sa.text(f"DROP DATABASE IF EXISTS {sysdb_name}"))
263-
227+
reset_system_database(config)
264228
except sa.exc.SQLAlchemyError as e:
265-
typer.echo(f"Error dropping database: {str(e)}")
229+
typer.echo(f"Error resetting system database: {str(e)}")
266230
return
267231

268-
sys_db = None
269-
try:
270-
sys_db = SystemDatabase(config)
271-
except Exception as e:
272-
typer.echo(f"DBOS system schema migration failed: {e}")
273-
finally:
274-
if sys_db:
275-
sys_db.destroy()
276-
277232

278233
@workflow.command(help="List workflows for your application")
279234
def list(

tests/test_package.py

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ def test_package(build_wheel: str, postgres_db_engine: sa.Engine) -> None:
5050

5151
# initalize the app with dbos scaffolding
5252
subprocess.check_call(
53-
["dbos", "init", template_name, "--template", "dbos-db-starter"],
53+
["dbos", "init", template_name, "--template", template_name],
5454
cwd=temp_path,
5555
env=venv,
5656
)
@@ -115,3 +115,35 @@ def test_init_config() -> None:
115115
actual_yaml = yaml.safe_load(f)
116116

117117
assert actual_yaml == expected_yaml
118+
119+
120+
def test_reset(postgres_db_engine: sa.Engine) -> None:
121+
app_name = "reset-app"
122+
sysdb_name = "reset_app_dbos_sys"
123+
with tempfile.TemporaryDirectory() as temp_path:
124+
subprocess.check_call(
125+
["dbos", "init", app_name, "--template", "dbos-db-starter"],
126+
cwd=temp_path,
127+
)
128+
129+
# Create a system database and verify it exists
130+
subprocess.check_call(["dbos", "migrate"], cwd=temp_path)
131+
with postgres_db_engine.connect() as c:
132+
c.execution_options(isolation_level="AUTOCOMMIT")
133+
result = c.execute(
134+
sa.text(
135+
f"SELECT COUNT(*) FROM pg_database WHERE datname = '{sysdb_name}'"
136+
)
137+
).scalar()
138+
assert result == 1
139+
140+
# Call reset and verify it's destroyed
141+
subprocess.check_call(["dbos", "reset", "-y"], cwd=temp_path)
142+
with postgres_db_engine.connect() as c:
143+
c.execution_options(isolation_level="AUTOCOMMIT")
144+
result = c.execute(
145+
sa.text(
146+
f"SELECT COUNT(*) FROM pg_database WHERE datname = '{sysdb_name}'"
147+
)
148+
).scalar()
149+
assert result == 0

tests/test_schema_migration.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,3 +102,43 @@ def rollback_system_db(sysdb_url: str) -> None:
102102
)
103103
alembic_cfg.set_main_option("sqlalchemy.url", escaped_conn_string)
104104
command.downgrade(alembic_cfg, "base") # Rollback all migrations
105+
106+
107+
def test_reset(config: ConfigFile, postgres_db_engine: sa.Engine) -> None:
108+
DBOS.destroy()
109+
dbos = DBOS(config=config)
110+
DBOS.launch()
111+
112+
# Make sure the system database exists
113+
with dbos._sys_db.engine.connect() as c:
114+
sql = SystemSchema.workflow_status.select()
115+
result = c.execute(sql)
116+
assert result.fetchall() == []
117+
118+
DBOS.destroy()
119+
dbos = DBOS(config=config)
120+
DBOS.reset_system_database()
121+
122+
sysdb_name = (
123+
config["database"]["sys_db_name"]
124+
if "sys_db_name" in config["database"] and config["database"]["sys_db_name"]
125+
else config["database"]["app_db_name"] + SystemSchema.sysdb_suffix
126+
)
127+
with postgres_db_engine.connect() as c:
128+
c.execution_options(isolation_level="AUTOCOMMIT")
129+
count: int = c.execute(
130+
sa.text(f"SELECT COUNT(*) FROM pg_database WHERE datname = '{sysdb_name}'")
131+
).scalar_one()
132+
assert count == 0
133+
134+
DBOS.launch()
135+
136+
# Make sure the system database is recreated
137+
with dbos._sys_db.engine.connect() as c:
138+
sql = SystemSchema.workflow_status.select()
139+
result = c.execute(sql)
140+
assert result.fetchall() == []
141+
142+
# Verify that resetting after launch throws
143+
with pytest.raises(AssertionError):
144+
DBOS.reset_system_database()

0 commit comments

Comments
 (0)