dbos 0.25.0a16__py3-none-any.whl → 0.26.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dbos/__init__.py +1 -2
- dbos/_admin_server.py +56 -6
- dbos/_app_db.py +135 -8
- dbos/_client.py +175 -15
- dbos/_conductor/conductor.py +2 -1
- dbos/_conductor/protocol.py +1 -2
- dbos/_context.py +66 -2
- dbos/_core.py +130 -76
- dbos/_dbos.py +155 -107
- dbos/_dbos_config.py +53 -67
- dbos/_debug.py +1 -1
- dbos/_docker_pg_helper.py +191 -0
- dbos/_error.py +61 -15
- dbos/_event_loop.py +67 -0
- dbos/_kafka.py +1 -1
- dbos/_migrations/versions/83f3732ae8e7_workflow_timeout.py +44 -0
- dbos/_queue.py +2 -1
- dbos/_recovery.py +1 -1
- dbos/_registrations.py +20 -5
- dbos/_scheduler.py +1 -1
- dbos/_schemas/application_database.py +1 -0
- dbos/_schemas/system_database.py +3 -1
- dbos/_sys_db.py +533 -130
- dbos/_utils.py +2 -0
- dbos/_workflow_commands.py +49 -104
- dbos/cli/cli.py +70 -4
- dbos/dbos-config.schema.json +26 -21
- {dbos-0.25.0a16.dist-info → dbos-0.26.0.dist-info}/METADATA +1 -1
- {dbos-0.25.0a16.dist-info → dbos-0.26.0.dist-info}/RECORD +32 -33
- {dbos-0.25.0a16.dist-info → dbos-0.26.0.dist-info}/WHEEL +1 -1
- dbos/_cloudutils/authentication.py +0 -163
- dbos/_cloudutils/cloudutils.py +0 -254
- dbos/_cloudutils/databases.py +0 -241
- dbos/_db_wizard.py +0 -220
- {dbos-0.25.0a16.dist-info → dbos-0.26.0.dist-info}/entry_points.txt +0 -0
- {dbos-0.25.0a16.dist-info → dbos-0.26.0.dist-info}/licenses/LICENSE +0 -0
dbos/_dbos_config.py
CHANGED
|
@@ -15,7 +15,6 @@ from jsonschema import ValidationError, validate
|
|
|
15
15
|
from rich import print
|
|
16
16
|
from sqlalchemy import URL, make_url
|
|
17
17
|
|
|
18
|
-
from ._db_wizard import db_wizard, load_db_connection
|
|
19
18
|
from ._error import DBOSInitializationError
|
|
20
19
|
from ._logger import dbos_logger
|
|
21
20
|
|
|
@@ -59,20 +58,28 @@ class RuntimeConfig(TypedDict, total=False):
|
|
|
59
58
|
|
|
60
59
|
|
|
61
60
|
class DatabaseConfig(TypedDict, total=False):
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
61
|
+
"""
|
|
62
|
+
Internal data structure containing the DBOS database configuration.
|
|
63
|
+
Attributes:
|
|
64
|
+
app_db_pool_size (int): Application database pool size
|
|
65
|
+
sys_db_name (str): System database name
|
|
66
|
+
sys_db_pool_size (int): System database pool size
|
|
67
|
+
migrate (List[str]): Migration commands to run on startup
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
hostname: str # Will be removed in a future version
|
|
71
|
+
port: int # Will be removed in a future version
|
|
72
|
+
username: str # Will be removed in a future version
|
|
73
|
+
password: str # Will be removed in a future version
|
|
74
|
+
connectionTimeoutMillis: Optional[int] # Will be removed in a future version
|
|
75
|
+
app_db_name: str # Will be removed in a future version
|
|
68
76
|
app_db_pool_size: Optional[int]
|
|
69
77
|
sys_db_name: Optional[str]
|
|
70
78
|
sys_db_pool_size: Optional[int]
|
|
71
|
-
ssl: Optional[bool]
|
|
72
|
-
ssl_ca: Optional[str]
|
|
73
|
-
local_suffix: Optional[bool]
|
|
79
|
+
ssl: Optional[bool] # Will be removed in a future version
|
|
80
|
+
ssl_ca: Optional[str] # Will be removed in a future version
|
|
74
81
|
migrate: Optional[List[str]]
|
|
75
|
-
rollback: Optional[List[str]]
|
|
82
|
+
rollback: Optional[List[str]] # Will be removed in a future version
|
|
76
83
|
|
|
77
84
|
|
|
78
85
|
def parse_database_url_to_dbconfig(database_url: str) -> DatabaseConfig:
|
|
@@ -119,7 +126,7 @@ class ConfigFile(TypedDict, total=False):
|
|
|
119
126
|
Attributes:
|
|
120
127
|
name (str): Application name
|
|
121
128
|
runtimeConfig (RuntimeConfig): Configuration for request serving
|
|
122
|
-
database (DatabaseConfig):
|
|
129
|
+
database (DatabaseConfig): Configure pool sizes, migrate commands
|
|
123
130
|
database_url (str): Database connection string
|
|
124
131
|
telemetry (TelemetryConfig): Configuration for tracing / logging
|
|
125
132
|
env (Dict[str,str]): Environment varialbes
|
|
@@ -215,9 +222,13 @@ def translate_dbos_config_to_config_file(config: DBOSConfig) -> ConfigFile:
|
|
|
215
222
|
|
|
216
223
|
|
|
217
224
|
def _substitute_env_vars(content: str, silent: bool = False) -> str:
|
|
218
|
-
regex = r"\$\{([^}]+)\}" # Regex to match ${VAR_NAME} style placeholders
|
|
219
225
|
|
|
220
|
-
|
|
226
|
+
# Regex to match ${DOCKER_SECRET:SECRET_NAME} style placeholders for Docker secrets
|
|
227
|
+
secret_regex = r"\$\{DOCKER_SECRET:([^}]+)\}"
|
|
228
|
+
# Regex to match ${VAR_NAME} style placeholders for environment variables
|
|
229
|
+
env_regex = r"\$\{(?!DOCKER_SECRET:)([^}]+)\}"
|
|
230
|
+
|
|
231
|
+
def replace_env_func(match: re.Match[str]) -> str:
|
|
221
232
|
var_name = match.group(1)
|
|
222
233
|
value = os.environ.get(
|
|
223
234
|
var_name, ""
|
|
@@ -228,7 +239,30 @@ def _substitute_env_vars(content: str, silent: bool = False) -> str:
|
|
|
228
239
|
)
|
|
229
240
|
return value
|
|
230
241
|
|
|
231
|
-
|
|
242
|
+
def replace_secret_func(match: re.Match[str]) -> str:
|
|
243
|
+
secret_name = match.group(1)
|
|
244
|
+
try:
|
|
245
|
+
# Docker secrets are stored in /run/secrets/
|
|
246
|
+
secret_path = f"/run/secrets/{secret_name}"
|
|
247
|
+
if os.path.exists(secret_path):
|
|
248
|
+
with open(secret_path, "r") as f:
|
|
249
|
+
return f.read().strip()
|
|
250
|
+
elif not silent:
|
|
251
|
+
dbos_logger.warning(
|
|
252
|
+
f"Docker secret {secret_name} would be substituted from /run/secrets/{secret_name}, but the file does not exist"
|
|
253
|
+
)
|
|
254
|
+
return ""
|
|
255
|
+
except Exception as e:
|
|
256
|
+
if not silent:
|
|
257
|
+
dbos_logger.warning(
|
|
258
|
+
f"Error reading Docker secret {secret_name}: {str(e)}"
|
|
259
|
+
)
|
|
260
|
+
return ""
|
|
261
|
+
|
|
262
|
+
# First replace Docker secrets
|
|
263
|
+
content = re.sub(secret_regex, replace_secret_func, content)
|
|
264
|
+
# Then replace environment variables
|
|
265
|
+
return re.sub(env_regex, replace_env_func, content)
|
|
232
266
|
|
|
233
267
|
|
|
234
268
|
def get_dbos_database_url(config_file_path: str = DBOS_CONFIG_PATH) -> str:
|
|
@@ -261,7 +295,6 @@ def load_config(
|
|
|
261
295
|
config_file_path: str = DBOS_CONFIG_PATH,
|
|
262
296
|
*,
|
|
263
297
|
run_process_config: bool = True,
|
|
264
|
-
use_db_wizard: bool = True,
|
|
265
298
|
silent: bool = False,
|
|
266
299
|
) -> ConfigFile:
|
|
267
300
|
"""
|
|
@@ -312,13 +345,12 @@ def load_config(
|
|
|
312
345
|
|
|
313
346
|
data = cast(ConfigFile, data)
|
|
314
347
|
if run_process_config:
|
|
315
|
-
data = process_config(data=data,
|
|
348
|
+
data = process_config(data=data, silent=silent)
|
|
316
349
|
return data # type: ignore
|
|
317
350
|
|
|
318
351
|
|
|
319
352
|
def process_config(
|
|
320
353
|
*,
|
|
321
|
-
use_db_wizard: bool = True,
|
|
322
354
|
data: ConfigFile,
|
|
323
355
|
silent: bool = False,
|
|
324
356
|
) -> ConfigFile:
|
|
@@ -345,22 +377,17 @@ def process_config(
|
|
|
345
377
|
# database_url takes precedence over database config, but we need to preserve rollback and migrate if they exist
|
|
346
378
|
migrate = data["database"].get("migrate", False)
|
|
347
379
|
rollback = data["database"].get("rollback", False)
|
|
348
|
-
local_suffix = data["database"].get("local_suffix", False)
|
|
349
380
|
if data.get("database_url"):
|
|
350
381
|
dbconfig = parse_database_url_to_dbconfig(cast(str, data["database_url"]))
|
|
351
382
|
if migrate:
|
|
352
383
|
dbconfig["migrate"] = cast(List[str], migrate)
|
|
353
384
|
if rollback:
|
|
354
385
|
dbconfig["rollback"] = cast(List[str], rollback)
|
|
355
|
-
if local_suffix:
|
|
356
|
-
dbconfig["local_suffix"] = cast(bool, local_suffix)
|
|
357
386
|
data["database"] = dbconfig
|
|
358
387
|
|
|
359
388
|
if "app_db_name" not in data["database"] or not (data["database"]["app_db_name"]):
|
|
360
389
|
data["database"]["app_db_name"] = _app_name_to_db_name(data["name"])
|
|
361
390
|
|
|
362
|
-
# Load the DB connection file. Use its values for missing connection parameters. Use defaults otherwise.
|
|
363
|
-
db_connection = load_db_connection()
|
|
364
391
|
connection_passed_in = data["database"].get("hostname", None) is not None
|
|
365
392
|
|
|
366
393
|
dbos_dbport: Optional[int] = None
|
|
@@ -370,49 +397,22 @@ def process_config(
|
|
|
370
397
|
dbos_dbport = int(dbport_env)
|
|
371
398
|
except ValueError:
|
|
372
399
|
pass
|
|
373
|
-
dbos_dblocalsuffix: Optional[bool] = None
|
|
374
|
-
dblocalsuffix_env = os.getenv("DBOS_DBLOCALSUFFIX")
|
|
375
|
-
if dblocalsuffix_env:
|
|
376
|
-
try:
|
|
377
|
-
dbos_dblocalsuffix = dblocalsuffix_env.casefold() == "true".casefold()
|
|
378
|
-
except ValueError:
|
|
379
|
-
pass
|
|
380
400
|
|
|
381
401
|
data["database"]["hostname"] = (
|
|
382
|
-
os.getenv("DBOS_DBHOST")
|
|
383
|
-
or data["database"].get("hostname")
|
|
384
|
-
or db_connection.get("hostname")
|
|
385
|
-
or "localhost"
|
|
402
|
+
os.getenv("DBOS_DBHOST") or data["database"].get("hostname") or "localhost"
|
|
386
403
|
)
|
|
387
404
|
|
|
388
|
-
data["database"]["port"] = (
|
|
389
|
-
dbos_dbport or data["database"].get("port") or db_connection.get("port") or 5432
|
|
390
|
-
)
|
|
405
|
+
data["database"]["port"] = dbos_dbport or data["database"].get("port") or 5432
|
|
391
406
|
data["database"]["username"] = (
|
|
392
|
-
os.getenv("DBOS_DBUSER")
|
|
393
|
-
or data["database"].get("username")
|
|
394
|
-
or db_connection.get("username")
|
|
395
|
-
or "postgres"
|
|
407
|
+
os.getenv("DBOS_DBUSER") or data["database"].get("username") or "postgres"
|
|
396
408
|
)
|
|
397
409
|
data["database"]["password"] = (
|
|
398
410
|
os.getenv("DBOS_DBPASSWORD")
|
|
399
411
|
or data["database"].get("password")
|
|
400
|
-
or db_connection.get("password")
|
|
401
412
|
or os.environ.get("PGPASSWORD")
|
|
402
413
|
or "dbos"
|
|
403
414
|
)
|
|
404
415
|
|
|
405
|
-
local_suffix = False
|
|
406
|
-
dbcon_local_suffix = db_connection.get("local_suffix")
|
|
407
|
-
if dbcon_local_suffix is not None:
|
|
408
|
-
local_suffix = dbcon_local_suffix
|
|
409
|
-
db_local_suffix = data["database"].get("local_suffix")
|
|
410
|
-
if db_local_suffix is not None:
|
|
411
|
-
local_suffix = db_local_suffix
|
|
412
|
-
if dbos_dblocalsuffix is not None:
|
|
413
|
-
local_suffix = dbos_dblocalsuffix
|
|
414
|
-
data["database"]["local_suffix"] = local_suffix
|
|
415
|
-
|
|
416
416
|
if not data["database"].get("app_db_pool_size"):
|
|
417
417
|
data["database"]["app_db_pool_size"] = 20
|
|
418
418
|
if not data["database"].get("sys_db_pool_size"):
|
|
@@ -427,10 +427,6 @@ def process_config(
|
|
|
427
427
|
elif "run_admin_server" not in data["runtimeConfig"]:
|
|
428
428
|
data["runtimeConfig"]["run_admin_server"] = True
|
|
429
429
|
|
|
430
|
-
# Check the connectivity to the database and make sure it's properly configured
|
|
431
|
-
# Note, never use db wizard if the DBOS is running in debug mode (i.e. DBOS_DEBUG_WORKFLOW_ID env var is set)
|
|
432
|
-
debugWorkflowId = os.getenv("DBOS_DEBUG_WORKFLOW_ID")
|
|
433
|
-
|
|
434
430
|
# Pretty-print where we've loaded database connection information from, respecting the log level
|
|
435
431
|
if not silent and logs["logLevel"] == "INFO" or logs["logLevel"] == "DEBUG":
|
|
436
432
|
d = data["database"]
|
|
@@ -443,21 +439,11 @@ def process_config(
|
|
|
443
439
|
print(
|
|
444
440
|
f"[bold blue]Using database connection string: {conn_string}[/bold blue]"
|
|
445
441
|
)
|
|
446
|
-
elif db_connection.get("hostname"):
|
|
447
|
-
print(
|
|
448
|
-
f"[bold blue]Loading database connection string from .dbos/db_connection: {conn_string}[/bold blue]"
|
|
449
|
-
)
|
|
450
442
|
else:
|
|
451
443
|
print(
|
|
452
444
|
f"[bold blue]Using default database connection string: {conn_string}[/bold blue]"
|
|
453
445
|
)
|
|
454
446
|
|
|
455
|
-
if use_db_wizard and debugWorkflowId is None:
|
|
456
|
-
data = db_wizard(data)
|
|
457
|
-
|
|
458
|
-
if "local_suffix" in data["database"] and data["database"]["local_suffix"]:
|
|
459
|
-
data["database"]["app_db_name"] = f"{data['database']['app_db_name']}_local"
|
|
460
|
-
|
|
461
447
|
# Return data as ConfigFile type
|
|
462
448
|
return data
|
|
463
449
|
|
dbos/_debug.py
CHANGED
|
@@ -28,7 +28,7 @@ def debug_workflow(workflow_id: str, entrypoint: Union[str, PythonModule]) -> No
|
|
|
28
28
|
|
|
29
29
|
DBOS.logger.info(f"Debugging workflow {workflow_id}...")
|
|
30
30
|
DBOS.launch(debug_mode=True)
|
|
31
|
-
handle = DBOS.
|
|
31
|
+
handle = DBOS._execute_workflow_id(workflow_id)
|
|
32
32
|
handle.get_result()
|
|
33
33
|
DBOS.logger.info("Workflow Debugging complete. Exiting process.")
|
|
34
34
|
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
import subprocess
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
import docker
|
|
7
|
+
import psycopg
|
|
8
|
+
from docker.errors import APIError, NotFound
|
|
9
|
+
|
|
10
|
+
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
|
|
11
|
+
from typing import Any, Dict, Optional, Tuple
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def start_docker_pg() -> None:
|
|
15
|
+
"""
|
|
16
|
+
Starts a PostgreSQL database in a Docker container.
|
|
17
|
+
|
|
18
|
+
This function checks if Docker is installed, and if so, starts a local PostgreSQL
|
|
19
|
+
database in a Docker container. It configures the database with default settings
|
|
20
|
+
and provides connection information upon successful startup.
|
|
21
|
+
|
|
22
|
+
The function uses environment variable PGPASSWORD if available, otherwise
|
|
23
|
+
defaults to 'dbos' as the database password.
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
None
|
|
27
|
+
|
|
28
|
+
Raises:
|
|
29
|
+
Exception: If there is an error starting the Docker container or if the
|
|
30
|
+
PostgreSQL service does not become available within the timeout period.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
logging.info("Attempting to create a Docker Postgres container...")
|
|
34
|
+
has_docker = check_docker_installed()
|
|
35
|
+
|
|
36
|
+
pool_config = {
|
|
37
|
+
"host": "localhost",
|
|
38
|
+
"port": 5432,
|
|
39
|
+
"password": os.environ.get("PGPASSWORD", "dbos"),
|
|
40
|
+
"user": "postgres",
|
|
41
|
+
"database": "postgres",
|
|
42
|
+
"connect_timeout": 2,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
# If Docker is installed, start a local Docker based Postgres
|
|
46
|
+
if has_docker:
|
|
47
|
+
start_docker_postgres(pool_config)
|
|
48
|
+
logging.info(
|
|
49
|
+
f"Postgres available at postgresql://postgres:{pool_config['password']}@{pool_config['host']}:{pool_config['port']}"
|
|
50
|
+
)
|
|
51
|
+
else:
|
|
52
|
+
logging.warning("Docker not detected locally")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def check_db_connectivity(config: Dict[str, Any]) -> Optional[Exception]:
|
|
56
|
+
conn = None
|
|
57
|
+
try:
|
|
58
|
+
conn = psycopg.connect(
|
|
59
|
+
host=config["host"],
|
|
60
|
+
port=config["port"],
|
|
61
|
+
user=config["user"],
|
|
62
|
+
password=config["password"],
|
|
63
|
+
dbname=config["database"],
|
|
64
|
+
connect_timeout=config.get("connect_timeout", 30),
|
|
65
|
+
)
|
|
66
|
+
cursor = conn.cursor()
|
|
67
|
+
cursor.execute("SELECT 1;")
|
|
68
|
+
cursor.close()
|
|
69
|
+
return None
|
|
70
|
+
except Exception as error:
|
|
71
|
+
return error
|
|
72
|
+
finally:
|
|
73
|
+
if conn is not None:
|
|
74
|
+
conn.close()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def exec_sync(cmd: str) -> Tuple[str, str]:
|
|
78
|
+
result = subprocess.run(cmd, shell=True, text=True, capture_output=True, check=True)
|
|
79
|
+
return result.stdout, result.stderr
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def start_docker_postgres(pool_config: Dict[str, Any]) -> bool:
|
|
83
|
+
logging.info("Starting a Postgres Docker container...")
|
|
84
|
+
container_name = "dbos-db"
|
|
85
|
+
pg_data = "/var/lib/postgresql/data"
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
client = docker.from_env()
|
|
89
|
+
|
|
90
|
+
# Check if the container already exists
|
|
91
|
+
try:
|
|
92
|
+
container = client.containers.get(container_name)
|
|
93
|
+
if container.status == "running":
|
|
94
|
+
logging.info(f"Container '{container_name}' is already running.")
|
|
95
|
+
return True
|
|
96
|
+
elif container.status == "exited":
|
|
97
|
+
container.start()
|
|
98
|
+
logging.info(
|
|
99
|
+
f"Container '{container_name}' was stopped and has been restarted."
|
|
100
|
+
)
|
|
101
|
+
return True
|
|
102
|
+
except NotFound:
|
|
103
|
+
# Container doesn't exist, proceed with creation
|
|
104
|
+
pass
|
|
105
|
+
|
|
106
|
+
# Create and start the container
|
|
107
|
+
container = client.containers.run(
|
|
108
|
+
image="pgvector/pgvector:pg16",
|
|
109
|
+
name=container_name,
|
|
110
|
+
detach=True,
|
|
111
|
+
environment={
|
|
112
|
+
"POSTGRES_PASSWORD": pool_config["password"],
|
|
113
|
+
"PGDATA": pg_data,
|
|
114
|
+
},
|
|
115
|
+
ports={"5432/tcp": pool_config["port"]},
|
|
116
|
+
volumes={pg_data: {"bind": pg_data, "mode": "rw"}},
|
|
117
|
+
remove=True, # Equivalent to --rm
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
logging.info(f"Created container: {container.id}")
|
|
121
|
+
|
|
122
|
+
except APIError as e:
|
|
123
|
+
raise Exception(f"Docker API error: {str(e)}")
|
|
124
|
+
|
|
125
|
+
# Wait for PostgreSQL to be ready
|
|
126
|
+
attempts = 30
|
|
127
|
+
while attempts > 0:
|
|
128
|
+
if attempts % 5 == 0:
|
|
129
|
+
logging.info("Waiting for Postgres Docker container to start...")
|
|
130
|
+
|
|
131
|
+
if check_db_connectivity(pool_config) is None:
|
|
132
|
+
return True
|
|
133
|
+
|
|
134
|
+
attempts -= 1
|
|
135
|
+
time.sleep(1)
|
|
136
|
+
|
|
137
|
+
raise Exception(
|
|
138
|
+
f"Failed to start Docker container: Container {container_name} did not start in time."
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def check_docker_installed() -> bool:
|
|
143
|
+
"""
|
|
144
|
+
Check if Docker is installed and running using the docker library.
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
bool: True if Docker is installed and running, False otherwise.
|
|
148
|
+
"""
|
|
149
|
+
try:
|
|
150
|
+
client = docker.from_env()
|
|
151
|
+
client.ping() # type: ignore
|
|
152
|
+
return True
|
|
153
|
+
except Exception:
|
|
154
|
+
return False
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def stop_docker_pg() -> None:
|
|
158
|
+
"""
|
|
159
|
+
Stops the Docker Postgres container.
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
bool: True if the container was successfully stopped, False if it wasn't running
|
|
163
|
+
|
|
164
|
+
Raises:
|
|
165
|
+
Exception: If there was an error stopping the container
|
|
166
|
+
"""
|
|
167
|
+
logger = logging.getLogger()
|
|
168
|
+
container_name = "dbos-db"
|
|
169
|
+
try:
|
|
170
|
+
logger.info(f"Stopping Docker Postgres container {container_name}...")
|
|
171
|
+
|
|
172
|
+
client = docker.from_env()
|
|
173
|
+
|
|
174
|
+
try:
|
|
175
|
+
container = client.containers.get(container_name)
|
|
176
|
+
|
|
177
|
+
if container.status == "running":
|
|
178
|
+
container.stop()
|
|
179
|
+
logger.info(
|
|
180
|
+
f"Successfully stopped Docker Postgres container {container_name}."
|
|
181
|
+
)
|
|
182
|
+
else:
|
|
183
|
+
logger.info(f"Container {container_name} exists but is not running.")
|
|
184
|
+
|
|
185
|
+
except docker.errors.NotFound:
|
|
186
|
+
logger.info(f"Container {container_name} does not exist.")
|
|
187
|
+
|
|
188
|
+
except Exception as error:
|
|
189
|
+
error_message = str(error)
|
|
190
|
+
logger.error(f"Failed to stop Docker Postgres container: {error_message}")
|
|
191
|
+
raise
|
dbos/_error.py
CHANGED
|
@@ -26,6 +26,29 @@ class DBOSException(Exception):
|
|
|
26
26
|
return f"DBOS Error: {self.message}"
|
|
27
27
|
|
|
28
28
|
|
|
29
|
+
class DBOSBaseException(BaseException):
|
|
30
|
+
"""
|
|
31
|
+
This class is for DBOS exceptions that should not be caught by user code.
|
|
32
|
+
It inherits from BaseException instead of Exception so it cannot be caught
|
|
33
|
+
except by code specifically trying to catch it.
|
|
34
|
+
|
|
35
|
+
Attributes:
|
|
36
|
+
message(str): The error message string
|
|
37
|
+
dbos_error_code(DBOSErrorCode): The error code, from the `DBOSErrorCode` enum
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(self, message: str, dbos_error_code: Optional[int] = None):
|
|
41
|
+
self.message = message
|
|
42
|
+
self.dbos_error_code = dbos_error_code
|
|
43
|
+
self.status_code: Optional[int] = None
|
|
44
|
+
super().__init__(self.message)
|
|
45
|
+
|
|
46
|
+
def __str__(self) -> str:
|
|
47
|
+
if self.dbos_error_code:
|
|
48
|
+
return f"DBOS Error {self.dbos_error_code}: {self.message}"
|
|
49
|
+
return f"DBOS Error: {self.message}"
|
|
50
|
+
|
|
51
|
+
|
|
29
52
|
class DBOSErrorCode(Enum):
|
|
30
53
|
ConflictingIDError = 1
|
|
31
54
|
RecoveryError = 2
|
|
@@ -37,17 +60,13 @@ class DBOSErrorCode(Enum):
|
|
|
37
60
|
NotAuthorized = 8
|
|
38
61
|
ConflictingWorkflowError = 9
|
|
39
62
|
WorkflowCancelled = 10
|
|
63
|
+
UnexpectedStep = 11
|
|
40
64
|
ConflictingRegistrationError = 25
|
|
41
65
|
|
|
42
66
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
def __init__(self, workflow_id: str):
|
|
47
|
-
super().__init__(
|
|
48
|
-
f"Conflicting workflow ID {workflow_id}",
|
|
49
|
-
dbos_error_code=DBOSErrorCode.ConflictingIDError.value,
|
|
50
|
-
)
|
|
67
|
+
#######################################
|
|
68
|
+
## Exception
|
|
69
|
+
#######################################
|
|
51
70
|
|
|
52
71
|
|
|
53
72
|
class DBOSConflictingWorkflowError(DBOSException):
|
|
@@ -137,8 +156,35 @@ class DBOSMaxStepRetriesExceeded(DBOSException):
|
|
|
137
156
|
return (self.__class__, (self.step_name, self.max_retries))
|
|
138
157
|
|
|
139
158
|
|
|
140
|
-
class
|
|
141
|
-
"""Exception raised when
|
|
159
|
+
class DBOSConflictingRegistrationError(DBOSException):
|
|
160
|
+
"""Exception raised when conflicting decorators are applied to the same function."""
|
|
161
|
+
|
|
162
|
+
def __init__(self, name: str) -> None:
|
|
163
|
+
super().__init__(
|
|
164
|
+
f"Operation (Name: {name}) is already registered with a conflicting function type",
|
|
165
|
+
dbos_error_code=DBOSErrorCode.ConflictingRegistrationError.value,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class DBOSUnexpectedStepError(DBOSException):
|
|
170
|
+
"""Exception raised when a step has an unexpected recorded name."""
|
|
171
|
+
|
|
172
|
+
def __init__(
|
|
173
|
+
self, workflow_id: str, step_id: int, expected_name: str, recorded_name: str
|
|
174
|
+
) -> None:
|
|
175
|
+
super().__init__(
|
|
176
|
+
f"During execution of workflow {workflow_id} step {step_id}, function {recorded_name} was recorded when {expected_name} was expected. Check that your workflow is deterministic.",
|
|
177
|
+
dbos_error_code=DBOSErrorCode.UnexpectedStep.value,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
#######################################
|
|
182
|
+
## BaseException
|
|
183
|
+
#######################################
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class DBOSWorkflowCancelledError(DBOSBaseException):
|
|
187
|
+
"""BaseException raised when the workflow has already been cancelled."""
|
|
142
188
|
|
|
143
189
|
def __init__(self, msg: str) -> None:
|
|
144
190
|
super().__init__(
|
|
@@ -147,11 +193,11 @@ class DBOSWorkflowCancelledError(DBOSException):
|
|
|
147
193
|
)
|
|
148
194
|
|
|
149
195
|
|
|
150
|
-
class
|
|
151
|
-
"""
|
|
196
|
+
class DBOSWorkflowConflictIDError(DBOSBaseException):
|
|
197
|
+
"""BaseException raised when a workflow database record already exists."""
|
|
152
198
|
|
|
153
|
-
def __init__(self,
|
|
199
|
+
def __init__(self, workflow_id: str):
|
|
154
200
|
super().__init__(
|
|
155
|
-
f"
|
|
156
|
-
dbos_error_code=DBOSErrorCode.
|
|
201
|
+
f"Conflicting workflow ID {workflow_id}",
|
|
202
|
+
dbos_error_code=DBOSErrorCode.ConflictingIDError.value,
|
|
157
203
|
)
|
dbos/_event_loop.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import threading
|
|
3
|
+
from typing import Any, Coroutine, Optional, TypeVar
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class BackgroundEventLoop:
|
|
7
|
+
"""
|
|
8
|
+
This is the event loop to which DBOS submits any coroutines that are not started from within an event loop.
|
|
9
|
+
In particular, coroutines submitted to queues (such as from scheduled workflows) run on this event loop.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
def __init__(self) -> None:
|
|
13
|
+
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
|
14
|
+
self._thread: Optional[threading.Thread] = None
|
|
15
|
+
self._running = False
|
|
16
|
+
self._ready = threading.Event()
|
|
17
|
+
|
|
18
|
+
def start(self) -> None:
|
|
19
|
+
if self._running:
|
|
20
|
+
return
|
|
21
|
+
|
|
22
|
+
self._thread = threading.Thread(target=self._run_event_loop, daemon=True)
|
|
23
|
+
self._thread.start()
|
|
24
|
+
self._ready.wait() # Wait until the loop is running
|
|
25
|
+
|
|
26
|
+
def stop(self) -> None:
|
|
27
|
+
if not self._running or self._loop is None or self._thread is None:
|
|
28
|
+
return
|
|
29
|
+
|
|
30
|
+
asyncio.run_coroutine_threadsafe(self._shutdown(), self._loop)
|
|
31
|
+
self._thread.join()
|
|
32
|
+
self._running = False
|
|
33
|
+
|
|
34
|
+
def _run_event_loop(self) -> None:
|
|
35
|
+
self._loop = asyncio.new_event_loop()
|
|
36
|
+
asyncio.set_event_loop(self._loop)
|
|
37
|
+
|
|
38
|
+
self._running = True
|
|
39
|
+
self._ready.set() # Signal that the loop is ready
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
self._loop.run_forever()
|
|
43
|
+
finally:
|
|
44
|
+
self._loop.close()
|
|
45
|
+
|
|
46
|
+
async def _shutdown(self) -> None:
|
|
47
|
+
if self._loop is None:
|
|
48
|
+
raise RuntimeError("Event loop not started")
|
|
49
|
+
tasks = [
|
|
50
|
+
task
|
|
51
|
+
for task in asyncio.all_tasks(self._loop)
|
|
52
|
+
if task is not asyncio.current_task(self._loop)
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
for task in tasks:
|
|
56
|
+
task.cancel()
|
|
57
|
+
|
|
58
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
59
|
+
self._loop.stop()
|
|
60
|
+
|
|
61
|
+
T = TypeVar("T")
|
|
62
|
+
|
|
63
|
+
def submit_coroutine(self, coro: Coroutine[Any, Any, T]) -> T:
|
|
64
|
+
"""Submit a coroutine to the background event loop"""
|
|
65
|
+
if self._loop is None:
|
|
66
|
+
raise RuntimeError("Event loop not started")
|
|
67
|
+
return asyncio.run_coroutine_threadsafe(coro, self._loop).result()
|
dbos/_kafka.py
CHANGED
|
@@ -115,7 +115,7 @@ def kafka_consumer(
|
|
|
115
115
|
_in_order_kafka_queues[topic] = queue
|
|
116
116
|
else:
|
|
117
117
|
global _kafka_queue
|
|
118
|
-
_kafka_queue =
|
|
118
|
+
_kafka_queue = dbosreg.get_internal_queue()
|
|
119
119
|
stop_event = threading.Event()
|
|
120
120
|
dbosreg.register_poller(
|
|
121
121
|
stop_event, _kafka_consumer_loop, func, config, topics, stop_event, in_order
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""workflow_timeout
|
|
2
|
+
|
|
3
|
+
Revision ID: 83f3732ae8e7
|
|
4
|
+
Revises: f4b9b32ba814
|
|
5
|
+
Create Date: 2025-04-16 17:05:36.642395
|
|
6
|
+
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from typing import Sequence, Union
|
|
10
|
+
|
|
11
|
+
import sqlalchemy as sa
|
|
12
|
+
from alembic import op
|
|
13
|
+
|
|
14
|
+
# revision identifiers, used by Alembic.
|
|
15
|
+
revision: str = "83f3732ae8e7"
|
|
16
|
+
down_revision: Union[str, None] = "f4b9b32ba814"
|
|
17
|
+
branch_labels: Union[str, Sequence[str], None] = None
|
|
18
|
+
depends_on: Union[str, Sequence[str], None] = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def upgrade() -> None:
|
|
22
|
+
op.add_column(
|
|
23
|
+
"workflow_status",
|
|
24
|
+
sa.Column(
|
|
25
|
+
"workflow_timeout_ms",
|
|
26
|
+
sa.BigInteger(),
|
|
27
|
+
nullable=True,
|
|
28
|
+
),
|
|
29
|
+
schema="dbos",
|
|
30
|
+
)
|
|
31
|
+
op.add_column(
|
|
32
|
+
"workflow_status",
|
|
33
|
+
sa.Column(
|
|
34
|
+
"workflow_deadline_epoch_ms",
|
|
35
|
+
sa.BigInteger(),
|
|
36
|
+
nullable=True,
|
|
37
|
+
),
|
|
38
|
+
schema="dbos",
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def downgrade() -> None:
|
|
43
|
+
op.drop_column("workflow_status", "workflow_deadline_epoch_ms", schema="dbos")
|
|
44
|
+
op.drop_column("workflow_status", "workflow_timeout_ms", schema="dbos")
|
dbos/_queue.py
CHANGED
|
@@ -82,7 +82,8 @@ def queue_thread(stop_event: threading.Event, dbos: "DBOS") -> None:
|
|
|
82
82
|
while not stop_event.is_set():
|
|
83
83
|
if stop_event.wait(timeout=1):
|
|
84
84
|
return
|
|
85
|
-
|
|
85
|
+
queues = dict(dbos._registry.queue_info_map)
|
|
86
|
+
for _, queue in queues.items():
|
|
86
87
|
try:
|
|
87
88
|
wf_ids = dbos._sys_db.start_queued_workflows(
|
|
88
89
|
queue, GlobalParams.executor_id, GlobalParams.app_version
|
dbos/_recovery.py
CHANGED
|
@@ -17,7 +17,7 @@ if TYPE_CHECKING:
|
|
|
17
17
|
def _recover_workflow(
|
|
18
18
|
dbos: "DBOS", workflow: GetPendingWorkflowsOutput
|
|
19
19
|
) -> "WorkflowHandle[Any]":
|
|
20
|
-
if workflow.queue_name
|
|
20
|
+
if workflow.queue_name:
|
|
21
21
|
cleared = dbos._sys_db.clear_queue_assignment(workflow.workflow_uuid)
|
|
22
22
|
if cleared:
|
|
23
23
|
return dbos.retrieve_workflow(workflow.workflow_uuid)
|