dbos 0.26.0a11__tar.gz → 0.26.0a14__tar.gz

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.
Files changed (103) hide show
  1. {dbos-0.26.0a11 → dbos-0.26.0a14}/PKG-INFO +1 -1
  2. {dbos-0.26.0a11 → dbos-0.26.0a14}/dbos/_core.py +3 -1
  3. {dbos-0.26.0a11 → dbos-0.26.0a14}/dbos/_dbos.py +21 -9
  4. {dbos-0.26.0a11 → dbos-0.26.0a14}/dbos/_docker_pg_helper.py +1 -1
  5. {dbos-0.26.0a11 → dbos-0.26.0a14}/dbos/_error.py +51 -18
  6. dbos-0.26.0a14/dbos/_event_loop.py +67 -0
  7. {dbos-0.26.0a11 → dbos-0.26.0a14}/dbos/_registrations.py +19 -4
  8. {dbos-0.26.0a11 → dbos-0.26.0a14}/pyproject.toml +1 -1
  9. dbos-0.26.0a14/tests/dupname_classdefs1.py +9 -0
  10. dbos-0.26.0a14/tests/dupname_classdefsa.py +9 -0
  11. {dbos-0.26.0a11 → dbos-0.26.0a14}/tests/test_async.py +70 -4
  12. {dbos-0.26.0a11 → dbos-0.26.0a14}/tests/test_classdecorators.py +37 -16
  13. {dbos-0.26.0a11 → dbos-0.26.0a14}/tests/test_singleton.py +1 -1
  14. {dbos-0.26.0a11 → dbos-0.26.0a14}/tests/test_workflow_management.py +5 -1
  15. {dbos-0.26.0a11 → dbos-0.26.0a14}/LICENSE +0 -0
  16. {dbos-0.26.0a11 → dbos-0.26.0a14}/README.md +0 -0
  17. {dbos-0.26.0a11 → dbos-0.26.0a14}/dbos/__init__.py +0 -0
  18. {dbos-0.26.0a11 → dbos-0.26.0a14}/dbos/__main__.py +0 -0
  19. {dbos-0.26.0a11 → dbos-0.26.0a14}/dbos/_admin_server.py +0 -0
  20. {dbos-0.26.0a11 → dbos-0.26.0a14}/dbos/_app_db.py +0 -0
  21. {dbos-0.26.0a11 → dbos-0.26.0a14}/dbos/_classproperty.py +0 -0
  22. {dbos-0.26.0a11 → dbos-0.26.0a14}/dbos/_client.py +0 -0
  23. {dbos-0.26.0a11 → dbos-0.26.0a14}/dbos/_conductor/conductor.py +0 -0
  24. {dbos-0.26.0a11 → dbos-0.26.0a14}/dbos/_conductor/protocol.py +0 -0
  25. {dbos-0.26.0a11 → dbos-0.26.0a14}/dbos/_context.py +0 -0
  26. {dbos-0.26.0a11 → dbos-0.26.0a14}/dbos/_croniter.py +0 -0
  27. {dbos-0.26.0a11 → dbos-0.26.0a14}/dbos/_dbos_config.py +0 -0
  28. {dbos-0.26.0a11 → dbos-0.26.0a14}/dbos/_debug.py +0 -0
  29. {dbos-0.26.0a11 → dbos-0.26.0a14}/dbos/_fastapi.py +0 -0
  30. {dbos-0.26.0a11 → dbos-0.26.0a14}/dbos/_flask.py +0 -0
  31. {dbos-0.26.0a11 → dbos-0.26.0a14}/dbos/_kafka.py +0 -0
  32. {dbos-0.26.0a11 → dbos-0.26.0a14}/dbos/_kafka_message.py +0 -0
  33. {dbos-0.26.0a11 → dbos-0.26.0a14}/dbos/_logger.py +0 -0
  34. {dbos-0.26.0a11 → dbos-0.26.0a14}/dbos/_migrations/env.py +0 -0
  35. {dbos-0.26.0a11 → dbos-0.26.0a14}/dbos/_migrations/script.py.mako +0 -0
  36. {dbos-0.26.0a11 → dbos-0.26.0a14}/dbos/_migrations/versions/04ca4f231047_workflow_queues_executor_id.py +0 -0
  37. {dbos-0.26.0a11 → dbos-0.26.0a14}/dbos/_migrations/versions/50f3227f0b4b_fix_job_queue.py +0 -0
  38. {dbos-0.26.0a11 → dbos-0.26.0a14}/dbos/_migrations/versions/5c361fc04708_added_system_tables.py +0 -0
  39. {dbos-0.26.0a11 → dbos-0.26.0a14}/dbos/_migrations/versions/a3b18ad34abe_added_triggers.py +0 -0
  40. {dbos-0.26.0a11 → dbos-0.26.0a14}/dbos/_migrations/versions/d76646551a6b_job_queue_limiter.py +0 -0
  41. {dbos-0.26.0a11 → dbos-0.26.0a14}/dbos/_migrations/versions/d76646551a6c_workflow_queue.py +0 -0
  42. {dbos-0.26.0a11 → dbos-0.26.0a14}/dbos/_migrations/versions/eab0cc1d9a14_job_queue.py +0 -0
  43. {dbos-0.26.0a11 → dbos-0.26.0a14}/dbos/_migrations/versions/f4b9b32ba814_functionname_childid_op_outputs.py +0 -0
  44. {dbos-0.26.0a11 → dbos-0.26.0a14}/dbos/_outcome.py +0 -0
  45. {dbos-0.26.0a11 → dbos-0.26.0a14}/dbos/_queue.py +0 -0
  46. {dbos-0.26.0a11 → dbos-0.26.0a14}/dbos/_recovery.py +0 -0
  47. {dbos-0.26.0a11 → dbos-0.26.0a14}/dbos/_request.py +0 -0
  48. {dbos-0.26.0a11 → dbos-0.26.0a14}/dbos/_roles.py +0 -0
  49. {dbos-0.26.0a11 → dbos-0.26.0a14}/dbos/_scheduler.py +0 -0
  50. {dbos-0.26.0a11 → dbos-0.26.0a14}/dbos/_schemas/__init__.py +0 -0
  51. {dbos-0.26.0a11 → dbos-0.26.0a14}/dbos/_schemas/application_database.py +0 -0
  52. {dbos-0.26.0a11 → dbos-0.26.0a14}/dbos/_schemas/system_database.py +0 -0
  53. {dbos-0.26.0a11 → dbos-0.26.0a14}/dbos/_serialization.py +0 -0
  54. {dbos-0.26.0a11 → dbos-0.26.0a14}/dbos/_sys_db.py +0 -0
  55. {dbos-0.26.0a11 → dbos-0.26.0a14}/dbos/_templates/dbos-db-starter/README.md +0 -0
  56. {dbos-0.26.0a11 → dbos-0.26.0a14}/dbos/_templates/dbos-db-starter/__package/__init__.py +0 -0
  57. {dbos-0.26.0a11 → dbos-0.26.0a14}/dbos/_templates/dbos-db-starter/__package/main.py +0 -0
  58. {dbos-0.26.0a11 → dbos-0.26.0a14}/dbos/_templates/dbos-db-starter/__package/schema.py +0 -0
  59. {dbos-0.26.0a11 → dbos-0.26.0a14}/dbos/_templates/dbos-db-starter/alembic.ini +0 -0
  60. {dbos-0.26.0a11 → dbos-0.26.0a14}/dbos/_templates/dbos-db-starter/dbos-config.yaml.dbos +0 -0
  61. {dbos-0.26.0a11 → dbos-0.26.0a14}/dbos/_templates/dbos-db-starter/migrations/env.py.dbos +0 -0
  62. {dbos-0.26.0a11 → dbos-0.26.0a14}/dbos/_templates/dbos-db-starter/migrations/script.py.mako +0 -0
  63. {dbos-0.26.0a11 → dbos-0.26.0a14}/dbos/_templates/dbos-db-starter/migrations/versions/2024_07_31_180642_init.py +0 -0
  64. {dbos-0.26.0a11 → dbos-0.26.0a14}/dbos/_templates/dbos-db-starter/start_postgres_docker.py +0 -0
  65. {dbos-0.26.0a11 → dbos-0.26.0a14}/dbos/_tracer.py +0 -0
  66. {dbos-0.26.0a11 → dbos-0.26.0a14}/dbos/_utils.py +0 -0
  67. {dbos-0.26.0a11 → dbos-0.26.0a14}/dbos/_workflow_commands.py +0 -0
  68. {dbos-0.26.0a11 → dbos-0.26.0a14}/dbos/cli/_github_init.py +0 -0
  69. {dbos-0.26.0a11 → dbos-0.26.0a14}/dbos/cli/_template_init.py +0 -0
  70. {dbos-0.26.0a11 → dbos-0.26.0a14}/dbos/cli/cli.py +0 -0
  71. {dbos-0.26.0a11 → dbos-0.26.0a14}/dbos/dbos-config.schema.json +0 -0
  72. {dbos-0.26.0a11 → dbos-0.26.0a14}/dbos/py.typed +0 -0
  73. {dbos-0.26.0a11 → dbos-0.26.0a14}/tests/__init__.py +0 -0
  74. {dbos-0.26.0a11 → dbos-0.26.0a14}/tests/atexit_no_ctor.py +0 -0
  75. {dbos-0.26.0a11 → dbos-0.26.0a14}/tests/atexit_no_launch.py +0 -0
  76. {dbos-0.26.0a11 → dbos-0.26.0a14}/tests/classdefs.py +0 -0
  77. {dbos-0.26.0a11 → dbos-0.26.0a14}/tests/client_collateral.py +0 -0
  78. {dbos-0.26.0a11 → dbos-0.26.0a14}/tests/client_worker.py +0 -0
  79. {dbos-0.26.0a11 → dbos-0.26.0a14}/tests/conftest.py +0 -0
  80. {dbos-0.26.0a11 → dbos-0.26.0a14}/tests/more_classdefs.py +0 -0
  81. {dbos-0.26.0a11 → dbos-0.26.0a14}/tests/queuedworkflow.py +0 -0
  82. {dbos-0.26.0a11 → dbos-0.26.0a14}/tests/test_admin_server.py +0 -0
  83. {dbos-0.26.0a11 → dbos-0.26.0a14}/tests/test_client.py +0 -0
  84. {dbos-0.26.0a11 → dbos-0.26.0a14}/tests/test_concurrency.py +0 -0
  85. {dbos-0.26.0a11 → dbos-0.26.0a14}/tests/test_config.py +0 -0
  86. {dbos-0.26.0a11 → dbos-0.26.0a14}/tests/test_croniter.py +0 -0
  87. {dbos-0.26.0a11 → dbos-0.26.0a14}/tests/test_dbos.py +0 -0
  88. {dbos-0.26.0a11 → dbos-0.26.0a14}/tests/test_debug.py +0 -0
  89. {dbos-0.26.0a11 → dbos-0.26.0a14}/tests/test_docker_secrets.py +0 -0
  90. {dbos-0.26.0a11 → dbos-0.26.0a14}/tests/test_failures.py +0 -0
  91. {dbos-0.26.0a11 → dbos-0.26.0a14}/tests/test_fastapi.py +0 -0
  92. {dbos-0.26.0a11 → dbos-0.26.0a14}/tests/test_fastapi_roles.py +0 -0
  93. {dbos-0.26.0a11 → dbos-0.26.0a14}/tests/test_flask.py +0 -0
  94. {dbos-0.26.0a11 → dbos-0.26.0a14}/tests/test_kafka.py +0 -0
  95. {dbos-0.26.0a11 → dbos-0.26.0a14}/tests/test_outcome.py +0 -0
  96. {dbos-0.26.0a11 → dbos-0.26.0a14}/tests/test_package.py +0 -0
  97. {dbos-0.26.0a11 → dbos-0.26.0a14}/tests/test_queue.py +0 -0
  98. {dbos-0.26.0a11 → dbos-0.26.0a14}/tests/test_scheduler.py +0 -0
  99. {dbos-0.26.0a11 → dbos-0.26.0a14}/tests/test_schema_migration.py +0 -0
  100. {dbos-0.26.0a11 → dbos-0.26.0a14}/tests/test_spans.py +0 -0
  101. {dbos-0.26.0a11 → dbos-0.26.0a14}/tests/test_sqlalchemy.py +0 -0
  102. {dbos-0.26.0a11 → dbos-0.26.0a14}/tests/test_workflow_introspection.py +0 -0
  103. {dbos-0.26.0a11 → dbos-0.26.0a14}/version/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: dbos
3
- Version: 0.26.0a11
3
+ Version: 0.26.0a14
4
4
  Summary: Ultra-lightweight durable execution in Python
5
5
  Author-Email: "DBOS, Inc." <contact@dbos.dev>
6
6
  License: MIT
@@ -365,7 +365,9 @@ def _execute_workflow_wthread(
365
365
  if isinstance(result, Immediate):
366
366
  return cast(Immediate[R], result)()
367
367
  else:
368
- return asyncio.run(cast(Pending[R], result)())
368
+ return dbos._background_event_loop.submit_coroutine(
369
+ cast(Pending[R], result)()
370
+ )
369
371
  except Exception:
370
372
  dbos.logger.error(
371
373
  f"Exception encountered in asynchronous workflow: {traceback.format_exc()}"
@@ -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,7 +30,6 @@ 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
34
  from dbos._utils import INTERNAL_QUEUE_NAME, GlobalParams
37
35
  from dbos._workflow_commands import (
@@ -62,6 +60,7 @@ from ._recovery import recover_pending_workflows, startup_recovery_thread
62
60
  from ._registrations import (
63
61
  DEFAULT_MAX_RECOVERY_ATTEMPTS,
64
62
  DBOSClassInfo,
63
+ _class_fqn,
65
64
  get_or_create_class_info,
66
65
  set_dbos_func_name,
67
66
  set_temp_workflow_type,
@@ -111,6 +110,7 @@ from ._error import (
111
110
  DBOSException,
112
111
  DBOSNonExistentWorkflowError,
113
112
  )
113
+ from ._event_loop import BackgroundEventLoop
114
114
  from ._logger import add_otlp_to_all_loggers, config_logger, dbos_logger, init_logger
115
115
  from ._sys_db import SystemDatabase
116
116
  from ._workflow_commands import WorkflowStatus, get_workflow
@@ -181,15 +181,17 @@ class DBOSRegistry:
181
181
  self.workflow_info_map[name] = wrapped_func
182
182
 
183
183
  def register_class(self, cls: type, ci: DBOSClassInfo) -> None:
184
- class_name = cls.__name__
184
+ class_name = _class_fqn(cls)
185
185
  if class_name in self.class_info_map:
186
186
  if self.class_info_map[class_name] is not cls:
187
187
  raise Exception(f"Duplicate type registration for class '{class_name}'")
188
188
  else:
189
189
  self.class_info_map[class_name] = cls
190
190
 
191
- def create_class_info(self, cls: Type[T]) -> Type[T]:
192
- ci = get_or_create_class_info(cls)
191
+ def create_class_info(
192
+ self, cls: Type[T], class_name: Optional[str] = None
193
+ ) -> Type[T]:
194
+ ci = get_or_create_class_info(cls, class_name)
193
195
  self.register_class(cls, ci)
194
196
  return cls
195
197
 
@@ -204,7 +206,7 @@ class DBOSRegistry:
204
206
 
205
207
  def register_instance(self, inst: object) -> None:
206
208
  config_name = getattr(inst, "config_name")
207
- class_name = inst.__class__.__name__
209
+ class_name = _class_fqn(inst.__class__)
208
210
  fn = f"{class_name}/{config_name}"
209
211
  if fn in self.instance_info_map:
210
212
  if self.instance_info_map[fn] is not inst:
@@ -338,6 +340,7 @@ class DBOS:
338
340
  self.conductor_url: Optional[str] = conductor_url
339
341
  self.conductor_key: Optional[str] = conductor_key
340
342
  self.conductor_websocket: Optional[ConductorWebsocket] = None
343
+ self._background_event_loop: BackgroundEventLoop = BackgroundEventLoop()
341
344
 
342
345
  init_logger()
343
346
 
@@ -448,6 +451,7 @@ class DBOS:
448
451
  dbos_logger.info(f"Executor ID: {GlobalParams.executor_id}")
449
452
  dbos_logger.info(f"Application version: {GlobalParams.app_version}")
450
453
  self._executor_field = ThreadPoolExecutor(max_workers=64)
454
+ self._background_event_loop.start()
451
455
  self._sys_db_field = SystemDatabase(
452
456
  self.config["database"], debug_mode=debug_mode
453
457
  )
@@ -565,6 +569,7 @@ class DBOS:
565
569
  self._initialized = False
566
570
  for event in self.stop_events:
567
571
  event.set()
572
+ self._background_event_loop.stop()
568
573
  if self._sys_db_field is not None:
569
574
  self._sys_db_field.destroy()
570
575
  self._sys_db_field = None
@@ -641,15 +646,22 @@ class DBOS:
641
646
  )
642
647
 
643
648
  @classmethod
644
- 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]]:
645
652
  """
646
653
  Decorate a class that contains DBOS member functions.
647
654
 
648
655
  All DBOS classes must be decorated, as this associates the class with
649
- 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.
650
657
  """
651
658
 
652
- 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
653
665
 
654
666
  @classmethod
655
667
  def default_required_roles(cls, roles: List[str]) -> Callable[[Type[T]], Type[T]]:
@@ -46,7 +46,7 @@ def start_docker_pg() -> None:
46
46
  if has_docker:
47
47
  start_docker_postgres(pool_config)
48
48
  logging.info(
49
- f"Postgres available at postgres://postgres:{pool_config['password']}@{pool_config['host']}:{pool_config['port']}"
49
+ f"Postgres available at postgresql://postgres:{pool_config['password']}@{pool_config['host']}:{pool_config['port']}"
50
50
  )
51
51
  else:
52
52
  logging.warning("Docker not detected locally")
@@ -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
@@ -41,14 +64,9 @@ class DBOSErrorCode(Enum):
41
64
  ConflictingRegistrationError = 25
42
65
 
43
66
 
44
- class DBOSWorkflowConflictIDError(DBOSException):
45
- """Exception raised when a workflow database record already exists."""
46
-
47
- def __init__(self, workflow_id: str):
48
- super().__init__(
49
- f"Conflicting workflow ID {workflow_id}",
50
- dbos_error_code=DBOSErrorCode.ConflictingIDError.value,
51
- )
67
+ #######################################
68
+ ## Exception
69
+ #######################################
52
70
 
53
71
 
54
72
  class DBOSConflictingWorkflowError(DBOSException):
@@ -138,16 +156,6 @@ class DBOSMaxStepRetriesExceeded(DBOSException):
138
156
  return (self.__class__, (self.step_name, self.max_retries))
139
157
 
140
158
 
141
- class DBOSWorkflowCancelledError(DBOSException):
142
- """Exception raised when the workflow has already been cancelled."""
143
-
144
- def __init__(self, msg: str) -> None:
145
- super().__init__(
146
- msg,
147
- dbos_error_code=DBOSErrorCode.WorkflowCancelled.value,
148
- )
149
-
150
-
151
159
  class DBOSConflictingRegistrationError(DBOSException):
152
160
  """Exception raised when conflicting decorators are applied to the same function."""
153
161
 
@@ -168,3 +176,28 @@ class DBOSUnexpectedStepError(DBOSException):
168
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.",
169
177
  dbos_error_code=DBOSErrorCode.UnexpectedStep.value,
170
178
  )
179
+
180
+
181
+ #######################################
182
+ ## BaseException
183
+ #######################################
184
+
185
+
186
+ class DBOSWorkflowCancelledError(DBOSBaseException):
187
+ """BaseException raised when the workflow has already been cancelled."""
188
+
189
+ def __init__(self, msg: str) -> None:
190
+ super().__init__(
191
+ msg,
192
+ dbos_error_code=DBOSErrorCode.WorkflowCancelled.value,
193
+ )
194
+
195
+
196
+ class DBOSWorkflowConflictIDError(DBOSBaseException):
197
+ """BaseException raised when a workflow database record already exists."""
198
+
199
+ def __init__(self, workflow_id: str):
200
+ super().__init__(
201
+ f"Conflicting workflow ID {workflow_id}",
202
+ dbos_error_code=DBOSErrorCode.ConflictingIDError.value,
203
+ )
@@ -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()
@@ -34,6 +34,7 @@ def set_temp_workflow_type(f: Any, name: TempWorkflowType) -> None:
34
34
 
35
35
  @dataclass
36
36
  class DBOSClassInfo:
37
+ registered_name: str
37
38
  def_required_roles: Optional[List[str]] = None
38
39
 
39
40
 
@@ -53,11 +54,17 @@ class DBOSFuncInfo:
53
54
  max_recovery_attempts: int = DEFAULT_MAX_RECOVERY_ATTEMPTS
54
55
 
55
56
 
56
- def get_or_create_class_info(cls: Type[Any]) -> DBOSClassInfo:
57
+ def get_or_create_class_info(
58
+ cls: Type[Any], provided_name: Optional[str] = None
59
+ ) -> DBOSClassInfo:
57
60
  if hasattr(cls, "dbos_class_decorator_info"):
58
61
  ci: DBOSClassInfo = getattr(cls, "dbos_class_decorator_info")
59
62
  return ci
60
- ci = DBOSClassInfo()
63
+ class_name = _class_fqn(cls)
64
+ # Use the provided name instead of the class name if it is not None
65
+ if provided_name is not None:
66
+ class_name = provided_name
67
+ ci = DBOSClassInfo(registered_name=class_name)
61
68
  setattr(cls, "dbos_class_decorator_info", ci)
62
69
 
63
70
  # Tell all DBOS functions about this
@@ -166,16 +173,24 @@ def get_config_name(
166
173
  return None
167
174
 
168
175
 
176
+ def _class_fqn(cls: type) -> str:
177
+ """Returns the registered name of the given class. If the class name was not overridden at registration time, it returns the qualified name of the class."""
178
+ ci = get_class_info(cls)
179
+ if ci is not None:
180
+ return ci.registered_name
181
+ return cls.__qualname__
182
+
183
+
169
184
  def get_dbos_class_name(
170
185
  fi: Optional[DBOSFuncInfo], func: Callable[..., Any], args: Tuple[Any, ...]
171
186
  ) -> Optional[str]:
172
187
  if fi and fi.func_type != DBOSFuncType.Unknown and len(args) > 0:
173
188
  if fi.func_type == DBOSFuncType.Instance:
174
189
  first_arg = args[0]
175
- return str(first_arg.__class__.__name__)
190
+ return _class_fqn(first_arg.__class__)
176
191
  if fi.func_type == DBOSFuncType.Class:
177
192
  first_arg = args[0]
178
- return str(first_arg.__name__)
193
+ return _class_fqn(first_arg)
179
194
  return None
180
195
 
181
196
  # Check for improperly-registered functions
@@ -28,7 +28,7 @@ dependencies = [
28
28
  ]
29
29
  requires-python = ">=3.9"
30
30
  readme = "README.md"
31
- version = "0.26.0a11"
31
+ version = "0.26.0a14"
32
32
 
33
33
  [project.license]
34
34
  text = "MIT"
@@ -0,0 +1,9 @@
1
+ from dbos import DBOS, DBOSConfiguredInstance
2
+
3
+
4
+ @DBOS.dbos_class(class_name="AnotherDBOSTestRegDup")
5
+ class DBOSTestRegDup(DBOSConfiguredInstance):
6
+ """DBOSTestRegDup duplicates the name of a class defined dupname_classdefsa.py"""
7
+
8
+ def __init__(self, instance_name: str) -> None:
9
+ super().__init__(instance_name)
@@ -0,0 +1,9 @@
1
+ from dbos import DBOS, DBOSConfiguredInstance
2
+
3
+
4
+ @DBOS.dbos_class()
5
+ class DBOSTestRegDup(DBOSConfiguredInstance):
6
+ """DBOSTestRegDup duplicates the name of a class defined dupname_classdefs1.py"""
7
+
8
+ def __init__(self, instance_name: str) -> None:
9
+ super().__init__(instance_name)
@@ -7,8 +7,8 @@ import pytest
7
7
  import sqlalchemy as sa
8
8
 
9
9
  # Public API
10
- from dbos import DBOS, SetWorkflowID
11
- from dbos._dbos import WorkflowHandleAsync
10
+ from dbos import DBOS, Queue, SetWorkflowID
11
+ from dbos._dbos import WorkflowHandle, WorkflowHandleAsync
12
12
  from dbos._dbos_config import ConfigFile
13
13
  from dbos._error import DBOSException
14
14
 
@@ -56,6 +56,15 @@ async def test_async_workflow(dbos: DBOS) -> None:
56
56
  assert step_counter == 1
57
57
  assert txn_counter == 1
58
58
 
59
+ # Test DBOS.start_workflow_async
60
+ handle = await DBOS.start_workflow_async(test_workflow, "alice", "bob")
61
+ assert (await handle.get_result()) == "alicetxn21bobstep2"
62
+
63
+ # Test DBOS.start_workflow. Not recommended for async workflows,
64
+ # but needed for backwards compatibility.
65
+ sync_handle = DBOS.start_workflow(test_workflow, "alice", "bob")
66
+ assert sync_handle.get_result() == "alicetxn31bobstep3" # type: ignore
67
+
59
68
 
60
69
  @pytest.mark.asyncio
61
70
  async def test_async_step(dbos: DBOS) -> None:
@@ -160,10 +169,11 @@ async def test_send_recv_async(dbos: DBOS) -> None:
160
169
  none_uuid = str(uuid.uuid4())
161
170
  none_handle = None
162
171
  with SetWorkflowID(none_uuid):
163
- none_handle = dbos.start_workflow(test_recv_timeout, 10.0)
172
+ none_handle = await dbos.start_workflow_async(test_recv_timeout, 10.0)
164
173
  await test_send_none(none_uuid)
165
174
  begin_time = time.time()
166
- assert none_handle.get_result() is None
175
+ result = await none_handle.get_result() # type: ignore
176
+ assert result is None
167
177
  duration = time.time() - begin_time
168
178
  assert duration < 1.0 # None is from the received message, not from the timeout.
169
179
 
@@ -400,3 +410,59 @@ async def test_retrieve_workflow_async(dbos: DBOS) -> None:
400
410
  wfstatus = await handle.get_status()
401
411
  assert wfstatus.status == "SUCCESS"
402
412
  assert wfstatus.workflow_id == wfuuid
413
+
414
+
415
+ def test_unawaited_workflow(dbos: DBOS) -> None:
416
+ input = 5
417
+ child_id = str(uuid.uuid4())
418
+ queue = Queue("test_queue")
419
+
420
+ @DBOS.workflow()
421
+ async def child_workflow(x: int) -> int:
422
+ await asyncio.sleep(0.1)
423
+ return x
424
+
425
+ @DBOS.workflow()
426
+ async def parent_workflow(x: int) -> None:
427
+ with SetWorkflowID(child_id):
428
+ await DBOS.start_workflow_async(child_workflow, x)
429
+
430
+ assert queue.enqueue(parent_workflow, input).get_result() is None
431
+ handle: WorkflowHandle[int] = DBOS.retrieve_workflow(
432
+ child_id, existing_workflow=False
433
+ )
434
+ assert handle.get_result() == 5
435
+
436
+
437
+ def test_unawaited_workflow_exception(dbos: DBOS) -> None:
438
+ child_id = str(uuid.uuid4())
439
+ queue = Queue("test_queue")
440
+
441
+ @DBOS.workflow()
442
+ async def child_workflow(s: str) -> int:
443
+ await asyncio.sleep(0.1)
444
+ raise Exception(s)
445
+
446
+ @DBOS.workflow()
447
+ async def parent_workflow(s: str) -> None:
448
+ with SetWorkflowID(child_id):
449
+ await DBOS.start_workflow_async(child_workflow, s)
450
+
451
+ # Verify the unawaited child properly throws an exception
452
+ input = "alice"
453
+ assert queue.enqueue(parent_workflow, input).get_result() is None
454
+ handle: WorkflowHandle[int] = DBOS.retrieve_workflow(
455
+ child_id, existing_workflow=False
456
+ )
457
+ with pytest.raises(Exception) as exc_info:
458
+ handle.get_result()
459
+ assert input in str(exc_info.value)
460
+
461
+ # Verify it works if run again
462
+ input = "bob"
463
+ child_id = str(uuid.uuid4())
464
+ assert queue.enqueue(parent_workflow, input).get_result() is None
465
+ handle = DBOS.retrieve_workflow(child_id, existing_workflow=False)
466
+ with pytest.raises(Exception) as exc_info:
467
+ handle.get_result()
468
+ assert input in str(exc_info.value)
@@ -316,11 +316,11 @@ def test_simple_workflow_inst(dbos: DBOS) -> None:
316
316
  stati = dbos.get_workflow_status(wfh.get_workflow_id())
317
317
  assert stati
318
318
  assert stati.config_name == "bob"
319
- assert stati.class_name == "DBOSTestClassInst"
319
+ assert stati.class_name and "DBOSTestClassInst" in stati.class_name
320
320
  stat = wfh.get_status()
321
321
  assert stat
322
322
  assert stat.config_name == "bob"
323
- assert stat.class_name == "DBOSTestClassInst"
323
+ assert stat.class_name and "DBOSTestClassInst" in stat.class_name
324
324
 
325
325
  assert wfh.get_result() == "bob1bob"
326
326
  assert inst.txn_counter == 2
@@ -376,17 +376,38 @@ def test_duplicate_reg(dbos: DBOS) -> None:
376
376
  def __init__(self) -> None:
377
377
  super().__init__("bob")
378
378
 
379
- assert "Duplicate type registration for class 'DBOSTestRegDup'" == str(
380
- exc_info.value
379
+ assert (
380
+ "Duplicate type registration for class 'test_duplicate_reg.<locals>.DBOSTestRegDup'"
381
+ == str(exc_info.value)
381
382
  )
382
383
 
383
- # Dupliocate instance registration
384
+ # Duplicate instance registration
384
385
  inst = DBOSTestRegDup()
385
386
  with pytest.raises(Exception) as exc_info:
386
387
  inst = DBOSTestRegDup()
387
388
 
388
389
  assert (
389
- "Duplicate instance registration for class 'DBOSTestRegDup' instance 'bob'"
390
+ "Duplicate instance registration for class 'test_duplicate_reg.<locals>.DBOSTestRegDup' instance 'bob'"
391
+ == str(exc_info.value)
392
+ )
393
+
394
+ # There should be no collision when the duplicate class names are in
395
+ # different modules if they're specified with different names.
396
+ from tests import dupname_classdefs1, dupname_classdefsa
397
+
398
+ # Two instances of the same class may be registered if they have different
399
+ # instance_name.
400
+ # Duplicate instance registration error still occurs with identical class
401
+ # name and instance_name.
402
+ alice = dupname_classdefs1.DBOSTestRegDup("alice")
403
+ bob = dupname_classdefsa.DBOSTestRegDup("bob")
404
+ bob2 = dupname_classdefs1.DBOSTestRegDup("bob")
405
+
406
+ with pytest.raises(Exception) as exc_info:
407
+ bob2 = dupname_classdefs1.DBOSTestRegDup("bob")
408
+
409
+ assert (
410
+ "Duplicate instance registration for class 'AnotherDBOSTestRegDup' instance 'bob'"
390
411
  == str(exc_info.value)
391
412
  )
392
413
 
@@ -451,7 +472,7 @@ def test_inst_recovery(dbos: DBOS) -> None:
451
472
  assert last_inst is None
452
473
 
453
474
  status = DBOS.retrieve_workflow(wfid).get_status()
454
- assert status.class_name == "TestClass"
475
+ assert status.class_name and "TestClass" in status.class_name
455
476
  assert status.config_name == "test_class"
456
477
 
457
478
 
@@ -479,7 +500,7 @@ def test_inst_async_recovery(dbos: DBOS) -> None:
479
500
  orig_handle = DBOS.start_workflow(inst.workflow, input)
480
501
 
481
502
  status = orig_handle.get_status()
482
- assert status.class_name == "TestClass"
503
+ assert status.class_name and "TestClass" in status.class_name
483
504
  assert status.config_name == "test_class"
484
505
 
485
506
  recovery_handle = DBOS._execute_workflow_id(wfid)
@@ -513,7 +534,7 @@ def test_inst_async_step_recovery(dbos: DBOS) -> None:
513
534
  orig_handle = DBOS.start_workflow(inst.step, input)
514
535
 
515
536
  status = orig_handle.get_status()
516
- assert status.class_name == "TestClass"
537
+ assert status.class_name and "TestClass" in status.class_name
517
538
  assert status.config_name == "test_class"
518
539
 
519
540
  recovery_handle = DBOS._execute_workflow_id(wfid)
@@ -558,7 +579,7 @@ def test_step_recovery(dbos: DBOS) -> None:
558
579
  thread_event.wait()
559
580
 
560
581
  status = DBOS.retrieve_workflow(wfid).get_status()
561
- assert status.class_name == "TestClass"
582
+ assert status.class_name and "TestClass" in status.class_name
562
583
  assert status.config_name == "test_class"
563
584
 
564
585
  recovery_handle = DBOS._execute_workflow_id(wfid)
@@ -626,7 +647,7 @@ def test_class_queue_recovery(dbos: DBOS) -> None:
626
647
  result = [i * multiplier for i in range(5)]
627
648
  for h in recovery_handles:
628
649
  status = h.get_status()
629
- assert status.class_name == "TestClass"
650
+ assert status.class_name and "TestClass" in status.class_name
630
651
  assert status.config_name == "test_class"
631
652
  if h.get_workflow_id() == wfid:
632
653
  assert h.get_result() == result
@@ -770,7 +791,7 @@ def test_class_classmethod_queue_recovery(dbos: DBOS) -> None:
770
791
  for h in recovery_handles:
771
792
  status = h.get_status()
772
793
  # Class name is recorded for class methods
773
- assert status.class_name == "TestClass"
794
+ assert status.class_name and "TestClass" in status.class_name
774
795
  assert status.config_name == None
775
796
  if h.get_workflow_id() == wfid:
776
797
  assert h.get_result() == result
@@ -808,13 +829,13 @@ def test_inst_txn(dbos: DBOS) -> None:
808
829
  with SetWorkflowID(wfid):
809
830
  assert inst.transaction(input) == input * multiplier
810
831
  status = DBOS.retrieve_workflow(wfid).get_status()
811
- assert status.class_name == "TestClass"
832
+ assert status.class_name and "TestClass" in status.class_name
812
833
  assert status.config_name == "test_class"
813
834
 
814
835
  handle = DBOS.start_workflow(inst.transaction, input)
815
836
  assert handle.get_result() == input * multiplier
816
837
  status = handle.get_status()
817
- assert status.class_name == "TestClass"
838
+ assert status.class_name and "TestClass" in status.class_name
818
839
  assert status.config_name == "test_class"
819
840
 
820
841
 
@@ -848,13 +869,13 @@ def test_mixed_methods(dbos: DBOS) -> None:
848
869
  handle = DBOS.start_workflow(inst.instance_workflow, input)
849
870
  assert handle.get_result() == input * multiplier
850
871
  status = handle.get_status()
851
- assert status.class_name == "TestClass"
872
+ assert status.class_name and "TestClass" in status.class_name
852
873
  assert status.config_name == "test_class"
853
874
 
854
875
  handle = DBOS.start_workflow(inst.classmethod_workflow, input)
855
876
  assert handle.get_result() == input
856
877
  status = handle.get_status()
857
- assert status.class_name == "TestClass"
878
+ assert status.class_name and "TestClass" in status.class_name
858
879
  assert status.config_name == None
859
880
 
860
881
  handle = DBOS.start_workflow(inst.staticmethod_workflow, input)
@@ -48,7 +48,7 @@ def test_dbos_singleton(cleanup_test_databases: None) -> None:
48
48
  stati = DBOS.get_workflow_status(wh.get_workflow_id())
49
49
  assert stati
50
50
  assert stati.config_name == "myconfig"
51
- assert stati.class_name == "DBOSTestClass"
51
+ assert stati.class_name and "DBOSTestClass" in stati.class_name
52
52
  wfhr: WorkflowHandle[str] = DBOS.retrieve_workflow(wh.get_workflow_id())
53
53
  assert wfhr.workflow_id == wh.get_workflow_id()
54
54
 
@@ -33,7 +33,11 @@ def test_cancel_resume(dbos: DBOS) -> None:
33
33
  step_one()
34
34
  main_thread_event.set()
35
35
  workflow_event.wait()
36
- step_two()
36
+ # A handler like this should not catch DBOSWorkflowCancelledError
37
+ try:
38
+ step_two()
39
+ except Exception:
40
+ raise
37
41
  return x
38
42
 
39
43
  # Start the workflow and cancel it.
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes