dbos 0.25.1__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/_dbos.py CHANGED
@@ -4,7 +4,6 @@ import asyncio
4
4
  import atexit
5
5
  import hashlib
6
6
  import inspect
7
- import json
8
7
  import os
9
8
  import sys
10
9
  import threading
@@ -31,14 +30,10 @@ from typing import (
31
30
 
32
31
  from opentelemetry.trace import Span
33
32
 
34
- from dbos import _serialization
35
33
  from dbos._conductor.conductor import ConductorWebsocket
36
- from dbos._utils import GlobalParams
37
- from dbos._workflow_commands import (
38
- WorkflowStatus,
39
- list_queued_workflows,
40
- list_workflows,
41
- )
34
+ from dbos._sys_db import WorkflowStatus
35
+ from dbos._utils import INTERNAL_QUEUE_NAME, GlobalParams
36
+ from dbos._workflow_commands import fork_workflow, list_queued_workflows, list_workflows
42
37
 
43
38
  from ._classproperty import classproperty
44
39
  from ._core import (
@@ -62,13 +57,14 @@ from ._recovery import recover_pending_workflows, startup_recovery_thread
62
57
  from ._registrations import (
63
58
  DEFAULT_MAX_RECOVERY_ATTEMPTS,
64
59
  DBOSClassInfo,
60
+ _class_fqn,
65
61
  get_or_create_class_info,
66
62
  set_dbos_func_name,
67
63
  set_temp_workflow_type,
68
64
  )
69
65
  from ._roles import default_required_roles, required_roles
70
66
  from ._scheduler import ScheduledWorkflow, scheduled
71
- from ._sys_db import reset_system_database
67
+ from ._sys_db import StepInfo, WorkflowStatus, reset_system_database
72
68
  from ._tracer import dbos_tracer
73
69
 
74
70
  if TYPE_CHECKING:
@@ -111,9 +107,10 @@ from ._error import (
111
107
  DBOSException,
112
108
  DBOSNonExistentWorkflowError,
113
109
  )
110
+ from ._event_loop import BackgroundEventLoop
114
111
  from ._logger import add_otlp_to_all_loggers, config_logger, dbos_logger, init_logger
115
112
  from ._sys_db import SystemDatabase
116
- from ._workflow_commands import WorkflowStatus, get_workflow
113
+ from ._workflow_commands import get_workflow, list_workflow_steps
117
114
 
118
115
  # Most DBOS functions are just any callable F, so decorators / wrappers work on F
119
116
  # There are cases where the parameters P and return value R should be separate
@@ -166,25 +163,32 @@ class DBOSRegistry:
166
163
  self.pollers: list[RegisteredJob] = []
167
164
  self.dbos: Optional[DBOS] = None
168
165
  self.config: Optional[ConfigFile] = None
169
- self.workflow_cancelled_map: dict[str, bool] = {}
170
166
 
171
167
  def register_wf_function(self, name: str, wrapped_func: F, functype: str) -> None:
172
168
  if name in self.function_type_map:
173
169
  if self.function_type_map[name] != functype:
174
170
  raise DBOSConflictingRegistrationError(name)
171
+ if name != TEMP_SEND_WF_NAME:
172
+ # Remove the `<temp>` prefix from the function name to avoid confusion
173
+ truncated_name = name.replace("<temp>.", "")
174
+ dbos_logger.warning(
175
+ f"Duplicate registration of function '{truncated_name}'. A function named '{truncated_name}' has already been registered with DBOS. All functions registered with DBOS must have unique names."
176
+ )
175
177
  self.function_type_map[name] = functype
176
178
  self.workflow_info_map[name] = wrapped_func
177
179
 
178
180
  def register_class(self, cls: type, ci: DBOSClassInfo) -> None:
179
- class_name = cls.__name__
181
+ class_name = _class_fqn(cls)
180
182
  if class_name in self.class_info_map:
181
183
  if self.class_info_map[class_name] is not cls:
182
184
  raise Exception(f"Duplicate type registration for class '{class_name}'")
183
185
  else:
184
186
  self.class_info_map[class_name] = cls
185
187
 
186
- def create_class_info(self, cls: Type[T]) -> Type[T]:
187
- ci = get_or_create_class_info(cls)
188
+ def create_class_info(
189
+ self, cls: Type[T], class_name: Optional[str] = None
190
+ ) -> Type[T]:
191
+ ci = get_or_create_class_info(cls, class_name)
188
192
  self.register_class(cls, ci)
189
193
  return cls
190
194
 
@@ -199,7 +203,7 @@ class DBOSRegistry:
199
203
 
200
204
  def register_instance(self, inst: object) -> None:
201
205
  config_name = getattr(inst, "config_name")
202
- class_name = inst.__class__.__name__
206
+ class_name = _class_fqn(inst.__class__)
203
207
  fn = f"{class_name}/{config_name}"
204
208
  if fn in self.instance_info_map:
205
209
  if self.instance_info_map[fn] is not inst:
@@ -209,15 +213,6 @@ class DBOSRegistry:
209
213
  else:
210
214
  self.instance_info_map[fn] = inst
211
215
 
212
- def cancel_workflow(self, workflow_id: str) -> None:
213
- self.workflow_cancelled_map[workflow_id] = True
214
-
215
- def is_workflow_cancelled(self, workflow_id: str) -> bool:
216
- return self.workflow_cancelled_map.get(workflow_id, False)
217
-
218
- def clear_workflow_cancelled(self, workflow_id: str) -> None:
219
- self.workflow_cancelled_map.pop(workflow_id, None)
220
-
221
216
  def compute_app_version(self) -> str:
222
217
  """
223
218
  An application's version is computed from a hash of the source of its workflows.
@@ -234,6 +229,13 @@ class DBOSRegistry:
234
229
  hasher.update(source.encode("utf-8"))
235
230
  return hasher.hexdigest()
236
231
 
232
+ def get_internal_queue(self) -> Queue:
233
+ """
234
+ Get or create the internal queue used for the DBOS scheduler, for Kafka, and for
235
+ programmatic resuming and restarting of workflows.
236
+ """
237
+ return Queue(INTERNAL_QUEUE_NAME)
238
+
237
239
 
238
240
  class DBOS:
239
241
  """
@@ -335,6 +337,7 @@ class DBOS:
335
337
  self.conductor_url: Optional[str] = conductor_url
336
338
  self.conductor_key: Optional[str] = conductor_key
337
339
  self.conductor_websocket: Optional[ConductorWebsocket] = None
340
+ self._background_event_loop: BackgroundEventLoop = BackgroundEventLoop()
338
341
 
339
342
  init_logger()
340
343
 
@@ -344,6 +347,9 @@ class DBOS:
344
347
  # If no config is provided, load it from dbos-config.yaml
345
348
  unvalidated_config = load_config(run_process_config=False)
346
349
  elif is_dbos_configfile(config):
350
+ dbos_logger.warning(
351
+ "ConfigFile config strutcture detected. This will be deprecated in favor of DBOSConfig in an upcoming release."
352
+ )
347
353
  unvalidated_config = cast(ConfigFile, config)
348
354
  if os.environ.get("DBOS__CLOUD") == "true":
349
355
  unvalidated_config = overwrite_config(unvalidated_config)
@@ -357,13 +363,13 @@ class DBOS:
357
363
  check_config_consistency(name=unvalidated_config["name"])
358
364
 
359
365
  if unvalidated_config is not None:
360
- self.config: ConfigFile = process_config(data=unvalidated_config)
366
+ self._config: ConfigFile = process_config(data=unvalidated_config)
361
367
  else:
362
368
  raise ValueError("No valid configuration was loaded.")
363
369
 
364
- set_env_vars(self.config)
365
- config_logger(self.config)
366
- dbos_tracer.config(self.config)
370
+ set_env_vars(self._config)
371
+ config_logger(self._config)
372
+ dbos_tracer.config(self._config)
367
373
  dbos_logger.info("Initializing DBOS")
368
374
 
369
375
  # If using FastAPI, set up middleware and lifecycle events
@@ -445,20 +451,21 @@ class DBOS:
445
451
  dbos_logger.info(f"Executor ID: {GlobalParams.executor_id}")
446
452
  dbos_logger.info(f"Application version: {GlobalParams.app_version}")
447
453
  self._executor_field = ThreadPoolExecutor(max_workers=64)
454
+ self._background_event_loop.start()
448
455
  self._sys_db_field = SystemDatabase(
449
- self.config["database"], debug_mode=debug_mode
456
+ self._config["database"], debug_mode=debug_mode
450
457
  )
451
458
  self._app_db_field = ApplicationDatabase(
452
- self.config["database"], debug_mode=debug_mode
459
+ self._config["database"], debug_mode=debug_mode
453
460
  )
454
461
 
455
462
  if debug_mode:
456
463
  return
457
464
 
458
- admin_port = self.config.get("runtimeConfig", {}).get("admin_port")
465
+ admin_port = self._config.get("runtimeConfig", {}).get("admin_port")
459
466
  if admin_port is None:
460
467
  admin_port = 3001
461
- run_admin_server = self.config.get("runtimeConfig", {}).get(
468
+ run_admin_server = self._config.get("runtimeConfig", {}).get(
462
469
  "run_admin_server"
463
470
  )
464
471
  if run_admin_server:
@@ -489,6 +496,9 @@ class DBOS:
489
496
  notification_listener_thread.start()
490
497
  self._background_threads.append(notification_listener_thread)
491
498
 
499
+ # Create the internal queue if it has not yet been created
500
+ self._registry.get_internal_queue()
501
+
492
502
  # Start the queue thread
493
503
  evt = threading.Event()
494
504
  self.stop_events.append(evt)
@@ -553,12 +563,13 @@ class DBOS:
553
563
  assert (
554
564
  not self._launched
555
565
  ), "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."
556
- reset_system_database(self.config)
566
+ reset_system_database(self._config)
557
567
 
558
568
  def _destroy(self) -> None:
559
569
  self._initialized = False
560
570
  for event in self.stop_events:
561
571
  event.set()
572
+ self._background_event_loop.stop()
562
573
  if self._sys_db_field is not None:
563
574
  self._sys_db_field.destroy()
564
575
  self._sys_db_field = None
@@ -588,7 +599,7 @@ class DBOS:
588
599
  # Decorators for DBOS functionality
589
600
  @classmethod
590
601
  def workflow(
591
- cls, *, max_recovery_attempts: int = DEFAULT_MAX_RECOVERY_ATTEMPTS
602
+ cls, *, max_recovery_attempts: Optional[int] = DEFAULT_MAX_RECOVERY_ATTEMPTS
592
603
  ) -> Callable[[Callable[P, R]], Callable[P, R]]:
593
604
  """Decorate a function for use as a DBOS workflow."""
594
605
  return decorate_workflow(_get_or_create_dbos_registry(), max_recovery_attempts)
@@ -635,15 +646,22 @@ class DBOS:
635
646
  )
636
647
 
637
648
  @classmethod
638
- def dbos_class(cls) -> Callable[[Type[T]], Type[T]]:
649
+ def dbos_class(
650
+ cls, class_name: Optional[str] = None
651
+ ) -> Callable[[Type[T]], Type[T]]:
639
652
  """
640
653
  Decorate a class that contains DBOS member functions.
641
654
 
642
655
  All DBOS classes must be decorated, as this associates the class with
643
- its member functions.
656
+ its member functions. Class names must be globally unique. By default, the class name is class.__qualname__ but you can optionally provide a class name that is different from the default name.
644
657
  """
645
658
 
646
- return _get_or_create_dbos_registry().create_class_info
659
+ def register_class(cls: Type[T]) -> Type[T]:
660
+ # Register the class with the DBOS registry
661
+ _get_or_create_dbos_registry().create_class_info(cls, class_name)
662
+ return cls
663
+
664
+ return register_class
647
665
 
648
666
  @classmethod
649
667
  def default_required_roles(cls, roles: List[str]) -> Callable[[Type[T]], Type[T]]:
@@ -724,32 +742,11 @@ class DBOS:
724
742
  @classmethod
725
743
  def get_workflow_status(cls, workflow_id: str) -> Optional[WorkflowStatus]:
726
744
  """Return the status of a workflow execution."""
727
- sys_db = _get_dbos_instance()._sys_db
728
- ctx = get_local_dbos_context()
729
- if ctx and ctx.is_within_workflow():
730
- ctx.function_id += 1
731
- res = sys_db.check_operation_execution(ctx.workflow_id, ctx.function_id)
732
- if res is not None:
733
- if res["output"]:
734
- resstat: WorkflowStatus = _serialization.deserialize(res["output"])
735
- return resstat
736
- else:
737
- raise DBOSException(
738
- "Workflow status record not found. This should not happen! \033[1m Hint: Check if your workflow is deterministic.\033[0m"
739
- )
740
- stat = get_workflow(_get_dbos_instance()._sys_db, workflow_id, True)
741
-
742
- if ctx and ctx.is_within_workflow():
743
- sys_db.record_operation_result(
744
- {
745
- "workflow_uuid": ctx.workflow_id,
746
- "function_id": ctx.function_id,
747
- "function_name": "DBOS.getStatus",
748
- "output": _serialization.serialize(stat),
749
- "error": None,
750
- }
751
- )
752
- return stat
745
+
746
+ def fn() -> Optional[WorkflowStatus]:
747
+ return get_workflow(_get_dbos_instance()._sys_db, workflow_id, True)
748
+
749
+ return _get_dbos_instance()._sys_db.call_function_as_step(fn, "DBOS.getStatus")
753
750
 
754
751
  @classmethod
755
752
  async def get_workflow_status_async(
@@ -925,17 +922,12 @@ class DBOS:
925
922
  )
926
923
 
927
924
  @classmethod
928
- def execute_workflow_id(cls, workflow_id: str) -> WorkflowHandle[Any]:
925
+ def _execute_workflow_id(cls, workflow_id: str) -> WorkflowHandle[Any]:
929
926
  """Execute a workflow by ID (for recovery)."""
930
927
  return execute_workflow_by_id(_get_dbos_instance(), workflow_id)
931
928
 
932
929
  @classmethod
933
- def restart_workflow(cls, workflow_id: str) -> None:
934
- """Execute a workflow by ID (for recovery)."""
935
- execute_workflow_by_id(_get_dbos_instance(), workflow_id, True)
936
-
937
- @classmethod
938
- def recover_pending_workflows(
930
+ def _recover_pending_workflows(
939
931
  cls, executor_ids: List[str] = ["local"]
940
932
  ) -> List[WorkflowHandle[Any]]:
941
933
  """Find all PENDING workflows and execute them."""
@@ -944,17 +936,48 @@ class DBOS:
944
936
  @classmethod
945
937
  def cancel_workflow(cls, workflow_id: str) -> None:
946
938
  """Cancel a workflow by ID."""
947
- dbos_logger.info(f"Cancelling workflow: {workflow_id}")
948
- _get_dbos_instance()._sys_db.cancel_workflow(workflow_id)
949
- _get_or_create_dbos_registry().cancel_workflow(workflow_id)
939
+
940
+ def fn() -> None:
941
+ dbos_logger.info(f"Cancelling workflow: {workflow_id}")
942
+ _get_dbos_instance()._sys_db.cancel_workflow(workflow_id)
943
+
944
+ return _get_dbos_instance()._sys_db.call_function_as_step(
945
+ fn, "DBOS.cancelWorkflow"
946
+ )
950
947
 
951
948
  @classmethod
952
949
  def resume_workflow(cls, workflow_id: str) -> WorkflowHandle[Any]:
953
950
  """Resume a workflow by ID."""
954
- dbos_logger.info(f"Resuming workflow: {workflow_id}")
955
- _get_dbos_instance()._sys_db.resume_workflow(workflow_id)
956
- _get_or_create_dbos_registry().clear_workflow_cancelled(workflow_id)
957
- return execute_workflow_by_id(_get_dbos_instance(), workflow_id, False)
951
+
952
+ def fn() -> None:
953
+ dbos_logger.info(f"Resuming workflow: {workflow_id}")
954
+ _get_dbos_instance()._sys_db.resume_workflow(workflow_id)
955
+
956
+ _get_dbos_instance()._sys_db.call_function_as_step(fn, "DBOS.resumeWorkflow")
957
+ return cls.retrieve_workflow(workflow_id)
958
+
959
+ @classmethod
960
+ def restart_workflow(cls, workflow_id: str) -> WorkflowHandle[Any]:
961
+ """Restart a workflow with a new workflow ID"""
962
+ return cls.fork_workflow(workflow_id, 1)
963
+
964
+ @classmethod
965
+ def fork_workflow(cls, workflow_id: str, start_step: int) -> WorkflowHandle[Any]:
966
+ """Restart a workflow with a new workflow ID from a specific step"""
967
+
968
+ def fn() -> str:
969
+ dbos_logger.info(f"Forking workflow: {workflow_id} from step {start_step}")
970
+ return fork_workflow(
971
+ _get_dbos_instance()._sys_db,
972
+ _get_dbos_instance()._app_db,
973
+ workflow_id,
974
+ start_step,
975
+ )
976
+
977
+ new_id = _get_dbos_instance()._sys_db.call_function_as_step(
978
+ fn, "DBOS.forkWorkflow"
979
+ )
980
+ return cls.retrieve_workflow(new_id)
958
981
 
959
982
  @classmethod
960
983
  def list_workflows(
@@ -970,19 +993,26 @@ class DBOS:
970
993
  limit: Optional[int] = None,
971
994
  offset: Optional[int] = None,
972
995
  sort_desc: bool = False,
996
+ workflow_id_prefix: Optional[str] = None,
973
997
  ) -> List[WorkflowStatus]:
974
- return list_workflows(
975
- _get_dbos_instance()._sys_db,
976
- workflow_ids=workflow_ids,
977
- status=status,
978
- start_time=start_time,
979
- end_time=end_time,
980
- name=name,
981
- app_version=app_version,
982
- user=user,
983
- limit=limit,
984
- offset=offset,
985
- sort_desc=sort_desc,
998
+ def fn() -> List[WorkflowStatus]:
999
+ return list_workflows(
1000
+ _get_dbos_instance()._sys_db,
1001
+ workflow_ids=workflow_ids,
1002
+ status=status,
1003
+ start_time=start_time,
1004
+ end_time=end_time,
1005
+ name=name,
1006
+ app_version=app_version,
1007
+ user=user,
1008
+ limit=limit,
1009
+ offset=offset,
1010
+ sort_desc=sort_desc,
1011
+ workflow_id_prefix=workflow_id_prefix,
1012
+ )
1013
+
1014
+ return _get_dbos_instance()._sys_db.call_function_as_step(
1015
+ fn, "DBOS.listWorkflows"
986
1016
  )
987
1017
 
988
1018
  @classmethod
@@ -998,16 +1028,32 @@ class DBOS:
998
1028
  offset: Optional[int] = None,
999
1029
  sort_desc: bool = False,
1000
1030
  ) -> List[WorkflowStatus]:
1001
- return list_queued_workflows(
1002
- _get_dbos_instance()._sys_db,
1003
- queue_name=queue_name,
1004
- status=status,
1005
- start_time=start_time,
1006
- end_time=end_time,
1007
- name=name,
1008
- limit=limit,
1009
- offset=offset,
1010
- sort_desc=sort_desc,
1031
+ def fn() -> List[WorkflowStatus]:
1032
+ return list_queued_workflows(
1033
+ _get_dbos_instance()._sys_db,
1034
+ queue_name=queue_name,
1035
+ status=status,
1036
+ start_time=start_time,
1037
+ end_time=end_time,
1038
+ name=name,
1039
+ limit=limit,
1040
+ offset=offset,
1041
+ sort_desc=sort_desc,
1042
+ )
1043
+
1044
+ return _get_dbos_instance()._sys_db.call_function_as_step(
1045
+ fn, "DBOS.listQueuedWorkflows"
1046
+ )
1047
+
1048
+ @classmethod
1049
+ def list_workflow_steps(cls, workflow_id: str) -> List[StepInfo]:
1050
+ def fn() -> List[StepInfo]:
1051
+ return list_workflow_steps(
1052
+ _get_dbos_instance()._sys_db, _get_dbos_instance()._app_db, workflow_id
1053
+ )
1054
+
1055
+ return _get_dbos_instance()._sys_db.call_function_as_step(
1056
+ fn, "DBOS.listWorkflowSteps"
1011
1057
  )
1012
1058
 
1013
1059
  @classproperty
@@ -1020,15 +1066,15 @@ class DBOS:
1020
1066
  """Return the DBOS `ConfigFile` for the current context."""
1021
1067
  global _dbos_global_instance
1022
1068
  if _dbos_global_instance is not None:
1023
- return _dbos_global_instance.config
1069
+ return _dbos_global_instance._config
1024
1070
  reg = _get_or_create_dbos_registry()
1025
1071
  if reg.config is not None:
1026
1072
  return reg.config
1027
- config = (
1073
+ loaded_config = (
1028
1074
  load_config()
1029
1075
  ) # This will return the processed & validated config (with defaults)
1030
- reg.config = config
1031
- return config
1076
+ reg.config = loaded_config
1077
+ return loaded_config
1032
1078
 
1033
1079
  @classproperty
1034
1080
  def sql_session(cls) -> Session:
@@ -1050,11 +1096,11 @@ class DBOS:
1050
1096
 
1051
1097
  @classproperty
1052
1098
  def step_id(cls) -> int:
1053
- """Return the step ID for the current context. This is a unique identifier of the current step within the workflow."""
1099
+ """Return the step ID for the currently executing step. This is a unique identifier of the current step within the workflow."""
1054
1100
  ctx = assert_current_dbos_context()
1055
1101
  assert (
1056
- ctx.is_within_workflow()
1057
- ), "step_id is only available within a DBOS workflow."
1102
+ ctx.is_step() or ctx.is_transaction()
1103
+ ), "step_id is only available within a DBOS step."
1058
1104
  return ctx.function_id
1059
1105
 
1060
1106
  @classproperty