dbos 0.23.0a13__py3-none-any.whl → 0.24.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.

Potentially problematic release.


This version of dbos might be problematic. Click here for more details.

dbos/_core.py CHANGED
@@ -58,6 +58,7 @@ from ._error import (
58
58
  )
59
59
  from ._registrations import (
60
60
  DEFAULT_MAX_RECOVERY_ATTEMPTS,
61
+ DBOSFuncInfo,
61
62
  get_config_name,
62
63
  get_dbos_class_name,
63
64
  get_dbos_func_name,
@@ -82,6 +83,7 @@ if TYPE_CHECKING:
82
83
  DBOS,
83
84
  Workflow,
84
85
  WorkflowHandle,
86
+ WorkflowHandleAsync,
85
87
  WorkflowStatus,
86
88
  DBOSRegistry,
87
89
  IsolationLevel,
@@ -136,6 +138,48 @@ class WorkflowHandlePolling(Generic[R]):
136
138
  return stat
137
139
 
138
140
 
141
+ class WorkflowHandleAsyncTask(Generic[R]):
142
+
143
+ def __init__(self, workflow_id: str, task: asyncio.Task[R], dbos: "DBOS"):
144
+ self.workflow_id = workflow_id
145
+ self.task = task
146
+ self.dbos = dbos
147
+
148
+ def get_workflow_id(self) -> str:
149
+ return self.workflow_id
150
+
151
+ async def get_result(self) -> R:
152
+ return await self.task
153
+
154
+ async def get_status(self) -> "WorkflowStatus":
155
+ stat = await asyncio.to_thread(self.dbos.get_workflow_status, self.workflow_id)
156
+ if stat is None:
157
+ raise DBOSNonExistentWorkflowError(self.workflow_id)
158
+ return stat
159
+
160
+
161
+ class WorkflowHandleAsyncPolling(Generic[R]):
162
+
163
+ def __init__(self, workflow_id: str, dbos: "DBOS"):
164
+ self.workflow_id = workflow_id
165
+ self.dbos = dbos
166
+
167
+ def get_workflow_id(self) -> str:
168
+ return self.workflow_id
169
+
170
+ async def get_result(self) -> R:
171
+ res: R = await asyncio.to_thread(
172
+ self.dbos._sys_db.await_workflow_result, self.workflow_id
173
+ )
174
+ return res
175
+
176
+ async def get_status(self) -> "WorkflowStatus":
177
+ stat = await asyncio.to_thread(self.dbos.get_workflow_status, self.workflow_id)
178
+ if stat is None:
179
+ raise DBOSNonExistentWorkflowError(self.workflow_id)
180
+ return stat
181
+
182
+
139
183
  def _init_workflow(
140
184
  dbos: "DBOS",
141
185
  ctx: DBOSContext,
@@ -285,6 +329,32 @@ def _execute_workflow_wthread(
285
329
  raise
286
330
 
287
331
 
332
+ async def _execute_workflow_async(
333
+ dbos: "DBOS",
334
+ status: WorkflowStatusInternal,
335
+ func: "Workflow[P, Coroutine[Any, Any, R]]",
336
+ ctx: DBOSContext,
337
+ *args: Any,
338
+ **kwargs: Any,
339
+ ) -> R:
340
+ attributes: TracedAttributes = {
341
+ "name": func.__name__,
342
+ "operationType": OperationType.WORKFLOW.value,
343
+ }
344
+ with DBOSContextSwap(ctx):
345
+ with EnterDBOSWorkflow(attributes):
346
+ try:
347
+ result = Pending[R](functools.partial(func, *args, **kwargs)).then(
348
+ _get_wf_invoke_func(dbos, status)
349
+ )
350
+ return await result()
351
+ except Exception:
352
+ dbos.logger.error(
353
+ f"Exception encountered in asynchronous workflow: {traceback.format_exc()}"
354
+ )
355
+ raise
356
+
357
+
288
358
  def execute_workflow_by_id(
289
359
  dbos: "DBOS", workflow_id: str, startNew: bool = False
290
360
  ) -> "WorkflowHandle[Any]":
@@ -349,26 +419,29 @@ def execute_workflow_by_id(
349
419
  )
350
420
 
351
421
 
352
- @overload
353
- def start_workflow(
354
- dbos: "DBOS",
355
- func: "Workflow[P, Coroutine[Any, Any, R]]",
356
- queue_name: Optional[str],
357
- execute_workflow: bool,
358
- *args: P.args,
359
- **kwargs: P.kwargs,
360
- ) -> "WorkflowHandle[R]": ...
422
+ def _get_new_wf() -> tuple[str, DBOSContext]:
423
+ # Sequence of events for starting a workflow:
424
+ # First - is there a WF already running?
425
+ # (and not in step as that is an error)
426
+ # Assign an ID to the workflow, if it doesn't have an app-assigned one
427
+ # If this is a root workflow, assign a new ID
428
+ # If this is a child workflow, assign parent wf id with call# suffix
429
+ # Make a (system) DB record for the workflow
430
+ # Pass the new context to a worker thread that will run the wf function
431
+ cur_ctx = get_local_dbos_context()
432
+ if cur_ctx is not None and cur_ctx.is_within_workflow():
433
+ assert cur_ctx.is_workflow() # Not in a step
434
+ cur_ctx.function_id += 1
435
+ if len(cur_ctx.id_assigned_for_next_workflow) == 0:
436
+ cur_ctx.id_assigned_for_next_workflow = (
437
+ cur_ctx.workflow_id + "-" + str(cur_ctx.function_id)
438
+ )
361
439
 
440
+ new_wf_ctx = DBOSContext() if cur_ctx is None else cur_ctx.create_child()
441
+ new_wf_ctx.id_assigned_for_next_workflow = new_wf_ctx.assign_workflow_id()
442
+ new_wf_id = new_wf_ctx.id_assigned_for_next_workflow
362
443
 
363
- @overload
364
- def start_workflow(
365
- dbos: "DBOS",
366
- func: "Workflow[P, R]",
367
- queue_name: Optional[str],
368
- execute_workflow: bool,
369
- *args: P.args,
370
- **kwargs: P.kwargs,
371
- ) -> "WorkflowHandle[R]": ...
444
+ return (new_wf_id, new_wf_ctx)
372
445
 
373
446
 
374
447
  def start_workflow(
@@ -379,6 +452,7 @@ def start_workflow(
379
452
  *args: P.args,
380
453
  **kwargs: P.kwargs,
381
454
  ) -> "WorkflowHandle[R]":
455
+
382
456
  # If the function has a class, add the class object as its first argument
383
457
  fself: Optional[object] = None
384
458
  if hasattr(func, "__self__"):
@@ -399,26 +473,7 @@ def start_workflow(
399
473
  "kwargs": kwargs,
400
474
  }
401
475
 
402
- # Sequence of events for starting a workflow:
403
- # First - is there a WF already running?
404
- # (and not in step as that is an error)
405
- # Assign an ID to the workflow, if it doesn't have an app-assigned one
406
- # If this is a root workflow, assign a new ID
407
- # If this is a child workflow, assign parent wf id with call# suffix
408
- # Make a (system) DB record for the workflow
409
- # Pass the new context to a worker thread that will run the wf function
410
- cur_ctx = get_local_dbos_context()
411
- if cur_ctx is not None and cur_ctx.is_within_workflow():
412
- assert cur_ctx.is_workflow() # Not in a step
413
- cur_ctx.function_id += 1
414
- if len(cur_ctx.id_assigned_for_next_workflow) == 0:
415
- cur_ctx.id_assigned_for_next_workflow = (
416
- cur_ctx.workflow_id + "-" + str(cur_ctx.function_id)
417
- )
418
-
419
- new_wf_ctx = DBOSContext() if cur_ctx is None else cur_ctx.create_child()
420
- new_wf_ctx.id_assigned_for_next_workflow = new_wf_ctx.assign_workflow_id()
421
- new_wf_id = new_wf_ctx.id_assigned_for_next_workflow
476
+ new_wf_id, new_wf_ctx = _get_new_wf()
422
477
 
423
478
  status = _init_workflow(
424
479
  dbos,
@@ -458,6 +513,69 @@ def start_workflow(
458
513
  return WorkflowHandleFuture(new_wf_id, future, dbos)
459
514
 
460
515
 
516
+ async def start_workflow_async(
517
+ dbos: "DBOS",
518
+ func: "Workflow[P, Coroutine[Any, Any, R]]",
519
+ queue_name: Optional[str],
520
+ execute_workflow: bool,
521
+ *args: P.args,
522
+ **kwargs: P.kwargs,
523
+ ) -> "WorkflowHandleAsync[R]":
524
+
525
+ # If the function has a class, add the class object as its first argument
526
+ fself: Optional[object] = None
527
+ if hasattr(func, "__self__"):
528
+ fself = func.__self__
529
+ if fself is not None:
530
+ args = (fself,) + args # type: ignore
531
+
532
+ fi = get_func_info(func)
533
+ if fi is None:
534
+ raise DBOSWorkflowFunctionNotFoundError(
535
+ "<NONE>", f"start_workflow: function {func.__name__} is not registered"
536
+ )
537
+
538
+ func = cast("Workflow[P, R]", func.__orig_func) # type: ignore
539
+
540
+ inputs: WorkflowInputs = {
541
+ "args": args,
542
+ "kwargs": kwargs,
543
+ }
544
+
545
+ new_wf_id, new_wf_ctx = _get_new_wf()
546
+
547
+ status = await asyncio.to_thread(
548
+ _init_workflow,
549
+ dbos,
550
+ new_wf_ctx,
551
+ inputs=inputs,
552
+ wf_name=get_dbos_func_name(func),
553
+ class_name=get_dbos_class_name(fi, func, args),
554
+ config_name=get_config_name(fi, func, args),
555
+ temp_wf_type=get_temp_workflow_type(func),
556
+ queue=queue_name,
557
+ max_recovery_attempts=fi.max_recovery_attempts,
558
+ )
559
+
560
+ wf_status = status["status"]
561
+
562
+ if not execute_workflow or (
563
+ not dbos.debug_mode
564
+ and (
565
+ wf_status == WorkflowStatusString.ERROR.value
566
+ or wf_status == WorkflowStatusString.SUCCESS.value
567
+ )
568
+ ):
569
+ dbos.logger.debug(
570
+ f"Workflow {new_wf_id} already completed with status {wf_status}. Directly returning a workflow handle."
571
+ )
572
+ return WorkflowHandleAsyncPolling(new_wf_id, dbos)
573
+
574
+ coro = _execute_workflow_async(dbos, status, func, new_wf_ctx, *args, **kwargs)
575
+ task = asyncio.create_task(coro)
576
+ return WorkflowHandleAsyncTask(new_wf_id, task, dbos)
577
+
578
+
461
579
  if sys.version_info < (3, 12):
462
580
 
463
581
  def _mark_coroutine(func: Callable[P, R]) -> Callable[P, R]:
@@ -824,7 +942,9 @@ def decorate_step(
824
942
  stepOutcome = Outcome[R].make(functools.partial(func, *args, **kwargs))
825
943
  if retries_allowed:
826
944
  stepOutcome = stepOutcome.retry(
827
- max_attempts, on_exception, lambda i: DBOSMaxStepRetriesExceeded()
945
+ max_attempts,
946
+ on_exception,
947
+ lambda i: DBOSMaxStepRetriesExceeded(func.__name__, i),
828
948
  )
829
949
 
830
950
  outcome = (
dbos/_db_wizard.py CHANGED
@@ -28,7 +28,20 @@ class DatabaseConnection(TypedDict):
28
28
  local_suffix: Optional[bool]
29
29
 
30
30
 
31
- def db_wizard(config: "ConfigFile", config_file_path: str) -> "ConfigFile":
31
+ def db_wizard(config: "ConfigFile") -> "ConfigFile":
32
+ """Checks database connectivity and helps the user start a database if needed
33
+
34
+ First, check connectivity to the database configured in the provided `config` object.
35
+ If it fails:
36
+ - Return an error if the connection failed due to incorrect credentials.
37
+ - Return an error if it detects a non-default configuration.
38
+ - Otherwise assume the configured database is not running and guide the user through setting it up.
39
+
40
+ The wizard will first attempt to start a local Postgres instance using Docker.
41
+ If Docker is not available, it will prompt the user to connect to a DBOS Cloud database.
42
+
43
+ Finally, if a database was configured, its connection details will be saved in the local `.dbos/db_connection` file.
44
+ """
32
45
  # 1. Check the connectivity to the database. Return if successful. If cannot connect, continue to the following steps.
33
46
  db_connection_error = _check_db_connectivity(config)
34
47
  if db_connection_error is None:
@@ -44,27 +57,18 @@ def db_wizard(config: "ConfigFile", config_file_path: str) -> "ConfigFile":
44
57
  raise DBOSInitializationError(
45
58
  f"Could not connect to Postgres: password authentication failed: {db_connection_error}"
46
59
  )
47
- db_config = config["database"]
48
-
49
- # Read the config file and check if the database hostname/port/username are set. If so, skip the wizard.
50
- with open(config_file_path, "r") as file:
51
- content = file.read()
52
- local_config = yaml.safe_load(content)
53
- if "database" not in local_config:
54
- local_config["database"] = {}
55
- local_config = cast("ConfigFile", local_config)
56
60
 
61
+ # If the database config is not the default one, surface the error and exit.
62
+ db_config = config["database"] # FIXME: what if database is not in config?
57
63
  if (
58
- local_config["database"].get("hostname")
59
- or local_config["database"].get("port")
60
- or local_config["database"].get("username")
61
- or db_config["hostname"] != "localhost"
64
+ db_config["hostname"] != "localhost"
62
65
  or db_config["port"] != 5432
63
66
  or db_config["username"] != "postgres"
64
67
  ):
65
68
  raise DBOSInitializationError(
66
69
  f"Could not connect to the database. Exception: {db_connection_error}"
67
70
  )
71
+
68
72
  print("[yellow]Postgres not detected locally[/yellow]")
69
73
 
70
74
  # 3. If the database config is the default one, check if the user has Docker properly installed.
dbos/_dbos.py CHANGED
@@ -49,6 +49,7 @@ from ._core import (
49
49
  send,
50
50
  set_event,
51
51
  start_workflow,
52
+ start_workflow_async,
52
53
  workflow_wrapper,
53
54
  )
54
55
  from ._queue import Queue, queue_thread
@@ -88,13 +89,23 @@ from ._context import (
88
89
  assert_current_dbos_context,
89
90
  get_local_dbos_context,
90
91
  )
91
- from ._dbos_config import ConfigFile, load_config, set_env_vars
92
+ from ._dbos_config import (
93
+ ConfigFile,
94
+ DBOSConfig,
95
+ check_config_consistency,
96
+ is_dbos_configfile,
97
+ load_config,
98
+ overwrite_config,
99
+ process_config,
100
+ set_env_vars,
101
+ translate_dbos_config_to_config_file,
102
+ )
92
103
  from ._error import (
93
104
  DBOSConflictingRegistrationError,
94
105
  DBOSException,
95
106
  DBOSNonExistentWorkflowError,
96
107
  )
97
- from ._logger import add_otlp_to_all_loggers, dbos_logger
108
+ from ._logger import add_otlp_to_all_loggers, config_logger, dbos_logger, init_logger
98
109
  from ._sys_db import SystemDatabase
99
110
 
100
111
  # Most DBOS functions are just any callable F, so decorators / wrappers work on F
@@ -257,7 +268,7 @@ class DBOS:
257
268
  def __new__(
258
269
  cls: Type[DBOS],
259
270
  *,
260
- config: Optional[ConfigFile] = None,
271
+ config: Optional[Union[ConfigFile, DBOSConfig]] = None,
261
272
  fastapi: Optional["FastAPI"] = None,
262
273
  flask: Optional["Flask"] = None,
263
274
  conductor_url: Optional[str] = None,
@@ -302,7 +313,7 @@ class DBOS:
302
313
  def __init__(
303
314
  self,
304
315
  *,
305
- config: Optional[ConfigFile] = None,
316
+ config: Optional[Union[ConfigFile, DBOSConfig]] = None,
306
317
  fastapi: Optional["FastAPI"] = None,
307
318
  flask: Optional["Flask"] = None,
308
319
  conductor_url: Optional[str] = None,
@@ -312,12 +323,7 @@ class DBOS:
312
323
  return
313
324
 
314
325
  self._initialized: bool = True
315
- if config is None:
316
- config = load_config()
317
- set_env_vars(config)
318
- dbos_tracer.config(config)
319
- dbos_logger.info("Initializing DBOS")
320
- self.config: ConfigFile = config
326
+
321
327
  self._launched: bool = False
322
328
  self._debug_mode: bool = False
323
329
  self._sys_db_field: Optional[SystemDatabase] = None
@@ -334,6 +340,36 @@ class DBOS:
334
340
  self.conductor_key: Optional[str] = conductor_key
335
341
  self.conductor_websocket: Optional[ConductorWebsocket] = None
336
342
 
343
+ init_logger()
344
+
345
+ unvalidated_config: Optional[ConfigFile] = None
346
+
347
+ if config is None:
348
+ # If no config is provided, load it from dbos-config.yaml
349
+ unvalidated_config = load_config(run_process_config=False)
350
+ elif is_dbos_configfile(config):
351
+ unvalidated_config = cast(ConfigFile, config)
352
+ if os.environ.get("DBOS__CLOUD") == "true":
353
+ unvalidated_config = overwrite_config(unvalidated_config)
354
+ check_config_consistency(name=unvalidated_config["name"])
355
+ else:
356
+ unvalidated_config = translate_dbos_config_to_config_file(
357
+ cast(DBOSConfig, config)
358
+ )
359
+ if os.environ.get("DBOS__CLOUD") == "true":
360
+ unvalidated_config = overwrite_config(unvalidated_config)
361
+ check_config_consistency(name=unvalidated_config["name"])
362
+
363
+ if unvalidated_config is not None:
364
+ self.config: ConfigFile = process_config(data=unvalidated_config)
365
+ else:
366
+ raise ValueError("No valid configuration was loaded.")
367
+
368
+ set_env_vars(self.config)
369
+ config_logger(self.config)
370
+ dbos_tracer.config(self.config)
371
+ dbos_logger.info("Initializing DBOS")
372
+
337
373
  # If using FastAPI, set up middleware and lifecycle events
338
374
  if self.fastapi is not None:
339
375
  from ._fastapi import setup_fastapi_middleware
@@ -419,10 +455,17 @@ class DBOS:
419
455
  if debug_mode:
420
456
  return
421
457
 
422
- admin_port = self.config["runtimeConfig"].get("admin_port")
458
+ admin_port = self.config.get("runtimeConfig", {}).get("admin_port")
423
459
  if admin_port is None:
424
460
  admin_port = 3001
425
- self._admin_server_field = AdminServer(dbos=self, port=admin_port)
461
+ run_admin_server = self.config.get("runtimeConfig", {}).get(
462
+ "run_admin_server"
463
+ )
464
+ if run_admin_server:
465
+ try:
466
+ self._admin_server_field = AdminServer(dbos=self, port=admin_port)
467
+ except Exception as e:
468
+ dbos_logger.warning(f"Failed to start admin server: {e}")
426
469
 
427
470
  workflow_ids = self._sys_db.get_pending_workflows(
428
471
  GlobalParams.executor_id, GlobalParams.app_version
@@ -660,35 +703,26 @@ class DBOS:
660
703
  f"{e.name} dependency not found. Please install {e.name} via your package manager."
661
704
  ) from e
662
705
 
663
- @overload
664
- @classmethod
665
- def start_workflow(
666
- cls,
667
- func: Workflow[P, Coroutine[Any, Any, R]],
668
- *args: P.args,
669
- **kwargs: P.kwargs,
670
- ) -> WorkflowHandle[R]: ...
671
-
672
- @overload
673
706
  @classmethod
674
707
  def start_workflow(
675
708
  cls,
676
709
  func: Workflow[P, R],
677
710
  *args: P.args,
678
711
  **kwargs: P.kwargs,
679
- ) -> WorkflowHandle[R]: ...
712
+ ) -> WorkflowHandle[R]:
713
+ """Invoke a workflow function in the background, returning a handle to the ongoing execution."""
714
+ return start_workflow(_get_dbos_instance(), func, None, True, *args, **kwargs)
680
715
 
681
716
  @classmethod
682
- def start_workflow(
717
+ async def start_workflow_async(
683
718
  cls,
684
- func: Workflow[P, Union[R, Coroutine[Any, Any, R]]],
719
+ func: Workflow[P, Coroutine[Any, Any, R]],
685
720
  *args: P.args,
686
721
  **kwargs: P.kwargs,
687
- ) -> WorkflowHandle[R]:
688
- """Invoke a workflow function in the background, returning a handle to the ongoing execution."""
689
- return cast(
690
- WorkflowHandle[R],
691
- start_workflow(_get_dbos_instance(), func, None, True, *args, **kwargs),
722
+ ) -> WorkflowHandleAsync[R]:
723
+ """Invoke a workflow function on the event loop, returning a handle to the ongoing execution."""
724
+ return await start_workflow_async(
725
+ _get_dbos_instance(), func, None, True, *args, **kwargs
692
726
  )
693
727
 
694
728
  @classmethod
@@ -923,7 +957,9 @@ class DBOS:
923
957
  reg = _get_or_create_dbos_registry()
924
958
  if reg.config is not None:
925
959
  return reg.config
926
- config = load_config()
960
+ config = (
961
+ load_config()
962
+ ) # This will return the processed & validated config (with defaults)
927
963
  reg.config = config
928
964
  return config
929
965
 
@@ -1063,6 +1099,35 @@ class WorkflowHandle(Generic[R], Protocol):
1063
1099
  ...
1064
1100
 
1065
1101
 
1102
+ class WorkflowHandleAsync(Generic[R], Protocol):
1103
+ """
1104
+ Handle to a workflow function.
1105
+
1106
+ `WorkflowHandleAsync` represents a current or previous workflow function invocation,
1107
+ allowing its status and result to be accessed.
1108
+
1109
+ Attributes:
1110
+ workflow_id(str): Workflow ID of the function invocation
1111
+
1112
+ """
1113
+
1114
+ def __init__(self, workflow_id: str) -> None: ...
1115
+
1116
+ workflow_id: str
1117
+
1118
+ def get_workflow_id(self) -> str:
1119
+ """Return the applicable workflow ID."""
1120
+ ...
1121
+
1122
+ async def get_result(self) -> R:
1123
+ """Return the result of the workflow function invocation, waiting if necessary."""
1124
+ ...
1125
+
1126
+ async def get_status(self) -> WorkflowStatus:
1127
+ """Return the current workflow function invocation status as `WorkflowStatus`."""
1128
+ ...
1129
+
1130
+
1066
1131
  class DBOSConfiguredInstance:
1067
1132
  """
1068
1133
  Base class for classes containing DBOS member functions.