Skip to content

Commit c560d0b

Browse files
authored
System Database URL (#442)
This PR enables configuring DBOS with just a system database, like this: ```python config: DBOSConfig = { "name": "dbos-starter", "system_database_url": os.environ.get("DBOS_SYSTEM_DATABASE_URL"), } DBOS(config=config) ``` If using transactions, the application database can be configured separately: ```python config: DBOSConfig = { "name": "dbos-starter", "system_database_url": os.environ.get("DBOS_SYSTEM_DATABASE_URL"), "application_database_url": os.environ.get("APP_DATABASE_URL"), } DBOS(config=config) ``` This is fully backwards-compatible—the old way of configuring DBOS with just `database_url` (which points to an application database, with the system database name suffixed with `_dbos_sys` if not provided) is still supported, though it will be removed in a future major release. The client interface and CLI are updated similarly. The client can now be created with just a system database URL: ```python client = DBOSClient( system_database_url=os.environ.get("DBOS_DATABASE_URL"), ) ``` All CLI commands now accept a `--sys-db-url` parameter.
1 parent 8629cb2 commit c560d0b

17 files changed

+590
-331
lines changed

dbos/_app_db.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -383,7 +383,6 @@ def run_migrations(self) -> None:
383383
).fetchone()
384384

385385
if result is None:
386-
# Create the table with proper SQLite syntax
387386
conn.execute(
388387
sa.text(
389388
f"""
@@ -402,7 +401,6 @@ def run_migrations(self) -> None:
402401
"""
403402
)
404403
)
405-
# Create the index
406404
conn.execute(
407405
sa.text(
408406
"CREATE INDEX transaction_outputs_created_at_index ON transaction_outputs (created_at)"

dbos/_client.py

Lines changed: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,11 @@
2525

2626
from dbos import _serialization
2727
from dbos._dbos import WorkflowHandle, WorkflowHandleAsync
28-
from dbos._dbos_config import get_system_database_url, is_valid_database_url
28+
from dbos._dbos_config import (
29+
get_application_database_url,
30+
get_system_database_url,
31+
is_valid_database_url,
32+
)
2933
from dbos._error import DBOSException, DBOSNonExistentWorkflowError
3034
from dbos._registrations import DEFAULT_MAX_RECOVERY_ATTEMPTS
3135
from dbos._serialization import WorkflowInputs
@@ -113,21 +117,32 @@ async def get_status(self) -> WorkflowStatus:
113117
class DBOSClient:
114118
def __init__(
115119
self,
116-
database_url: str,
120+
database_url: Optional[str] = None, # DEPRECATED
117121
*,
118122
system_database_url: Optional[str] = None,
119-
system_database: Optional[str] = None,
123+
application_database_url: Optional[str] = None,
124+
system_database: Optional[str] = None, # DEPRECATED
120125
):
121-
assert is_valid_database_url(database_url)
126+
application_database_url = get_application_database_url(
127+
{
128+
"system_database_url": system_database_url,
129+
"database_url": (
130+
database_url if database_url else application_database_url
131+
),
132+
}
133+
)
134+
system_database_url = get_system_database_url(
135+
{
136+
"system_database_url": system_database_url,
137+
"database_url": application_database_url,
138+
"database": {"sys_db_name": system_database},
139+
}
140+
)
141+
assert is_valid_database_url(system_database_url)
142+
assert is_valid_database_url(application_database_url)
122143
# We only create database connections but do not run migrations
123144
self._sys_db = SystemDatabase.create(
124-
system_database_url=get_system_database_url(
125-
{
126-
"system_database_url": system_database_url,
127-
"database_url": database_url,
128-
"database": {"sys_db_name": system_database},
129-
}
130-
),
145+
system_database_url=system_database_url,
131146
engine_kwargs={
132147
"pool_timeout": 30,
133148
"max_overflow": 0,
@@ -136,14 +151,13 @@ def __init__(
136151
)
137152
self._sys_db.check_connection()
138153
self._app_db = ApplicationDatabase.create(
139-
database_url=database_url,
154+
database_url=application_database_url,
140155
engine_kwargs={
141156
"pool_timeout": 30,
142157
"max_overflow": 0,
143158
"pool_size": 2,
144159
},
145160
)
146-
self._db_url = database_url
147161

148162
def destroy(self) -> None:
149163
self._sys_db.destroy()

dbos/_dbos_config.py

Lines changed: 107 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,10 @@ class DBOSConfig(TypedDict, total=False):
2222
2323
Attributes:
2424
name (str): Application name
25-
database_url (str): Database connection string
26-
system_database_url (str): Connection string for the system database (if different from the application database)
27-
sys_db_name (str): System database name (deprecated)
25+
system_database_url (str): Connection string for the DBOS system database. Defaults to sqlite:///{name} if not provided.
26+
application_database_url (str): Connection string for the DBOS application database, in which DBOS @Transaction functions run. Optional. Should be the same type of database (SQLite or Postgres) as the system database.
27+
database_url (str): (DEPRECATED) Database connection string
28+
sys_db_name (str): (DEPRECATED) System database name
2829
sys_db_pool_size (int): System database pool size
2930
db_engine_kwargs (Dict[str, Any]): SQLAlchemy engine kwargs (See https://docs.sqlalchemy.org/en/20/core/engines.html#sqlalchemy.create_engine)
3031
log_level (str): Log level
@@ -39,8 +40,9 @@ class DBOSConfig(TypedDict, total=False):
3940
"""
4041

4142
name: str
42-
database_url: Optional[str]
4343
system_database_url: Optional[str]
44+
application_database_url: Optional[str]
45+
database_url: Optional[str]
4446
sys_db_name: Optional[str]
4547
sys_db_pool_size: Optional[int]
4648
db_engine_kwargs: Optional[Dict[str, Any]]
@@ -107,12 +109,12 @@ class ConfigFile(TypedDict, total=False):
107109
108110
Attributes:
109111
name (str): Application name
110-
runtimeConfig (RuntimeConfig): Configuration for request serving
112+
runtimeConfig (RuntimeConfig): Configuration for DBOS Cloud
111113
database (DatabaseConfig): Configure pool sizes, migrate commands
112-
database_url (str): Database connection string
114+
database_url (str): Application database URL
115+
system_database_url (str): System database URL
113116
telemetry (TelemetryConfig): Configuration for tracing / logging
114-
env (Dict[str,str]): Environment varialbes
115-
application (Dict[str, Any]): Application-specific configuration section
117+
env (Dict[str,str]): Environment variables
116118
117119
"""
118120

@@ -144,8 +146,12 @@ def translate_dbos_config_to_config_file(config: DBOSConfig) -> ConfigFile:
144146
if db_config:
145147
translated_config["database"] = db_config
146148

149+
# Use application_database_url instead of the deprecated database_url if provided
147150
if "database_url" in config:
148151
translated_config["database_url"] = config.get("database_url")
152+
elif "application_database_url" in config:
153+
translated_config["database_url"] = config.get("application_database_url")
154+
149155
if "system_database_url" in config:
150156
translated_config["system_database_url"] = config.get("system_database_url")
151157

@@ -233,7 +239,6 @@ def replace_secret_func(match: re.Match[str]) -> str:
233239
def load_config(
234240
config_file_path: str = DBOS_CONFIG_PATH,
235241
*,
236-
run_process_config: bool = True,
237242
silent: bool = False,
238243
) -> ConfigFile:
239244
"""
@@ -283,8 +288,6 @@ def load_config(
283288
]
284289

285290
data = cast(ConfigFile, data)
286-
if run_process_config:
287-
data = process_config(data=data, silent=silent)
288291
return data # type: ignore
289292

290293

@@ -333,25 +336,16 @@ def process_config(
333336

334337
# Ensure database dict exists
335338
data.setdefault("database", {})
336-
337-
# Database URL resolution
338339
connect_timeout = None
339-
if data.get("database_url") is not None and data["database_url"] != "":
340+
341+
# Process the application database URL, if provided
342+
if data.get("database_url"):
340343
# Parse the db string and check required fields
341344
assert data["database_url"] is not None
342345
assert is_valid_database_url(data["database_url"])
343346

344347
url = make_url(data["database_url"])
345348

346-
if data["database_url"].startswith("sqlite"):
347-
data["system_database_url"] = data["database_url"]
348-
else:
349-
assert url.database is not None
350-
if not data["database"].get("sys_db_name"):
351-
data["database"]["sys_db_name"] = (
352-
url.database + SystemSchema.sysdb_suffix
353-
)
354-
355349
# Gather connect_timeout from the URL if provided. It should be used in engine kwargs if not provided there (instead of our default)
356350
connect_timeout_str = url.query.get("connect_timeout")
357351
if connect_timeout_str is not None:
@@ -376,18 +370,80 @@ def process_config(
376370
host=os.getenv("DBOS_DBHOST", url.host),
377371
port=port,
378372
).render_as_string(hide_password=False)
379-
else:
373+
374+
# Process the system database URL, if provided
375+
if data.get("system_database_url"):
376+
# Parse the db string and check required fields
377+
assert data["system_database_url"]
378+
assert is_valid_database_url(data["system_database_url"])
379+
380+
url = make_url(data["system_database_url"])
381+
382+
# Gather connect_timeout from the URL if provided. It should be used in engine kwargs if not provided there (instead of our default). This overrides a timeout from the application database, if any.
383+
connect_timeout_str = url.query.get("connect_timeout")
384+
if connect_timeout_str is not None:
385+
assert isinstance(
386+
connect_timeout_str, str
387+
), "connect_timeout must be a string and defined once in the URL"
388+
if connect_timeout_str.isdigit():
389+
connect_timeout = int(connect_timeout_str)
390+
391+
# In debug mode perform env vars overrides
392+
if isDebugMode:
393+
# Override the username, password, host, and port
394+
port_str = os.getenv("DBOS_DBPORT")
395+
port = (
396+
int(port_str)
397+
if port_str is not None and port_str.isdigit()
398+
else url.port
399+
)
400+
data["system_database_url"] = url.set(
401+
username=os.getenv("DBOS_DBUSER", url.username),
402+
password=os.getenv("DBOS_DBPASSWORD", url.password),
403+
host=os.getenv("DBOS_DBHOST", url.host),
404+
port=port,
405+
).render_as_string(hide_password=False)
406+
407+
# If an application database URL is provided but not the system database URL,
408+
# construct the system database URL.
409+
if data.get("database_url") and not data.get("system_database_url"):
410+
assert data["database_url"]
411+
if data["database_url"].startswith("sqlite"):
412+
data["system_database_url"] = data["database_url"]
413+
else:
414+
url = make_url(data["database_url"])
415+
assert url.database
416+
if data["database"].get("sys_db_name"):
417+
url = url.set(database=data["database"]["sys_db_name"])
418+
else:
419+
url = url.set(database=f"{url.database}{SystemSchema.sysdb_suffix}")
420+
data["system_database_url"] = url.render_as_string(hide_password=False)
421+
422+
# If a system database URL is provided but not an application database URL, set the
423+
# application database URL to the system database URL.
424+
if data.get("system_database_url") and not data.get("database_url"):
425+
assert data["system_database_url"]
426+
data["database_url"] = data["system_database_url"]
427+
428+
# If neither URL is provided, use a default SQLite database URL.
429+
if not data.get("database_url") and not data.get("system_database_url"):
380430
_app_db_name = _app_name_to_db_name(data["name"])
381-
data["database_url"] = f"sqlite:///{_app_db_name}.sqlite"
382-
data["system_database_url"] = data["database_url"]
431+
data["system_database_url"] = data["database_url"] = (
432+
f"sqlite:///{_app_db_name}.sqlite"
433+
)
383434

384435
configure_db_engine_parameters(data["database"], connect_timeout=connect_timeout)
385436

386-
# Pretty-print where we've loaded database connection information from, respecting the log level
387437
assert data["database_url"] is not None
438+
assert data["system_database_url"] is not None
439+
# Pretty-print connection information, respecting log level
388440
if not silent and logs["logLevel"] == "INFO" or logs["logLevel"] == "DEBUG":
389-
log_url = make_url(data["database_url"]).render_as_string(hide_password=True)
390-
print(f"[bold blue]Using database connection string: {log_url}[/bold blue]")
441+
printable_sys_db_url = make_url(data["system_database_url"]).render_as_string(
442+
hide_password=True
443+
)
444+
print(
445+
f"[bold blue]DBOS system database URL: {printable_sys_db_url}[/bold blue]"
446+
)
391447
if data["database_url"].startswith("sqlite"):
392448
print(
393449
f"[bold blue]Using SQLite as a system database. The SQLite system database is for development and testing. PostgreSQL is recommended for production use.[/bold blue]"
@@ -474,36 +530,34 @@ def _app_name_to_db_name(app_name: str) -> str:
474530

475531
def overwrite_config(provided_config: ConfigFile) -> ConfigFile:
476532
# Load the DBOS configuration file and force the use of:
477-
# 1. The database url provided by DBOS_DATABASE_URL
533+
# 1. The application and system database url provided by DBOS_DATABASE_URL and DBOS_SYSTEM_DATABASE_URL
478534
# 2. OTLP traces endpoints (add the config data to the provided config)
479535
# 3. Use the application name from the file. This is a defensive measure to ensure the application name is whatever it was registered with in the cloud
480536
# 4. Remove admin_port is provided in code
481537
# 5. Remove env vars if provided in code
482538
# Optimistically assume that expected fields in config_from_file are present
483539

484-
config_from_file = load_config(run_process_config=False)
540+
config_from_file = load_config()
485541
# Be defensive
486542
if config_from_file is None:
487543
return provided_config
488544

489-
# Name
545+
# Set the application name to the cloud app name
490546
provided_config["name"] = config_from_file["name"]
491547

492-
# Database config. Expects DBOS_DATABASE_URL to be set
493-
if "database" not in provided_config:
494-
provided_config["database"] = {}
495-
provided_config["database"]["sys_db_name"] = config_from_file["database"][
496-
"sys_db_name"
497-
]
498-
548+
# Use the DBOS Cloud application and system database URLs
499549
db_url = os.environ.get("DBOS_DATABASE_URL")
500550
if db_url is None:
501551
raise DBOSInitializationError(
502552
"DBOS_DATABASE_URL environment variable is not set. This is required to connect to the database."
503553
)
504554
provided_config["database_url"] = db_url
505-
if "system_database_url" in provided_config:
506-
del provided_config["system_database_url"]
555+
system_db_url = os.environ.get("DBOS_SYSTEM_DATABASE_URL")
556+
if system_db_url is None:
557+
raise DBOSInitializationError(
558+
"DBOS_SYSTEM_DATABASE_URL environment variable is not set. This is required to connect to the database."
559+
)
560+
provided_config["system_database_url"] = system_db_url
507561

508562
# Telemetry config
509563
if "telemetry" not in provided_config or provided_config["telemetry"] is None:
@@ -563,11 +617,22 @@ def get_system_database_url(config: ConfigFile) -> str:
563617
if config["database_url"].startswith("sqlite"):
564618
return config["database_url"]
565619
app_db_url = make_url(config["database_url"])
566-
if config["database"].get("sys_db_name") is not None:
620+
if config.get("database") and config["database"].get("sys_db_name") is not None:
567621
sys_db_name = config["database"]["sys_db_name"]
568622
else:
569623
assert app_db_url.database is not None
570624
sys_db_name = app_db_url.database + SystemSchema.sysdb_suffix
571625
return app_db_url.set(database=sys_db_name).render_as_string(
572626
hide_password=False
573627
)
628+
629+
630+
def get_application_database_url(config: ConfigFile) -> str:
631+
# For backwards compatibility, the application database URL is "database_url"
632+
if config.get("database_url"):
633+
assert config["database_url"]
634+
return config["database_url"]
635+
else:
636+
# If the application database URL is not specified, set it to the system database URL
637+
assert config["system_database_url"]
638+
return config["system_database_url"]

dbos/_sys_db.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1422,7 +1422,6 @@ def create(
14221422
debug_mode=debug_mode,
14231423
)
14241424
else:
1425-
# Default to PostgreSQL for postgresql://, postgres://, or other URLs
14261425
from ._sys_db_postgres import PostgresSystemDatabase
14271426

14281427
return PostgresSystemDatabase(

dbos/_sys_db_sqlite.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -92,18 +92,15 @@ def run_migrations(self) -> None:
9292
last_applied = i
9393

9494
def _cleanup_connections(self) -> None:
95-
"""Clean up SQLite-specific connections."""
9695
# SQLite doesn't require special connection cleanup
9796
pass
9897

9998
def _is_unique_constraint_violation(self, dbapi_error: DBAPIError) -> bool:
10099
"""Check if the error is a unique constraint violation in SQLite."""
101-
# SQLite UNIQUE constraint error
102100
return "UNIQUE constraint failed" in str(dbapi_error.orig)
103101

104102
def _is_foreign_key_violation(self, dbapi_error: DBAPIError) -> bool:
105103
"""Check if the error is a foreign key violation in SQLite."""
106-
# SQLite FOREIGN KEY constraint error
107104
return "FOREIGN KEY constraint failed" in str(dbapi_error.orig)
108105

109106
@staticmethod

0 commit comments

Comments
 (0)