dbos 0.24.0a15__tar.gz → 0.25.0__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.

Potentially problematic release.


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

Files changed (105) hide show
  1. {dbos-0.24.0a15 → dbos-0.25.0}/PKG-INFO +2 -1
  2. {dbos-0.24.0a15 → dbos-0.25.0}/dbos/__init__.py +5 -1
  3. {dbos-0.24.0a15 → dbos-0.25.0}/dbos/__main__.py +3 -0
  4. {dbos-0.24.0a15 → dbos-0.25.0}/dbos/_admin_server.py +28 -2
  5. {dbos-0.24.0a15 → dbos-0.25.0}/dbos/_app_db.py +14 -15
  6. dbos-0.25.0/dbos/_client.py +206 -0
  7. {dbos-0.24.0a15 → dbos-0.25.0}/dbos/_conductor/conductor.py +33 -2
  8. {dbos-0.24.0a15 → dbos-0.25.0}/dbos/_conductor/protocol.py +47 -7
  9. {dbos-0.24.0a15 → dbos-0.25.0}/dbos/_context.py +48 -0
  10. {dbos-0.24.0a15 → dbos-0.25.0}/dbos/_core.py +173 -48
  11. {dbos-0.24.0a15 → dbos-0.25.0}/dbos/_db_wizard.py +3 -7
  12. {dbos-0.24.0a15 → dbos-0.25.0}/dbos/_dbos.py +134 -85
  13. {dbos-0.24.0a15 → dbos-0.25.0}/dbos/_dbos_config.py +2 -1
  14. {dbos-0.24.0a15 → dbos-0.25.0}/dbos/_fastapi.py +4 -1
  15. {dbos-0.24.0a15 → dbos-0.25.0}/dbos/_logger.py +14 -0
  16. dbos-0.25.0/dbos/_migrations/versions/f4b9b32ba814_functionname_childid_op_outputs.py +46 -0
  17. {dbos-0.24.0a15 → dbos-0.25.0}/dbos/_outcome.py +6 -2
  18. {dbos-0.24.0a15 → dbos-0.25.0}/dbos/_queue.py +5 -5
  19. {dbos-0.24.0a15 → dbos-0.25.0}/dbos/_schemas/system_database.py +2 -0
  20. {dbos-0.24.0a15 → dbos-0.25.0}/dbos/_sys_db.py +159 -178
  21. {dbos-0.24.0a15 → dbos-0.25.0}/dbos/_templates/dbos-db-starter/__package/main.py +6 -11
  22. {dbos-0.24.0a15 → dbos-0.25.0}/dbos/_templates/dbos-db-starter/dbos-config.yaml.dbos +2 -4
  23. dbos-0.25.0/dbos/_workflow_commands.py +175 -0
  24. {dbos-0.24.0a15 → dbos-0.25.0}/dbos/cli/_template_init.py +8 -3
  25. {dbos-0.24.0a15 → dbos-0.25.0}/dbos/cli/cli.py +22 -6
  26. {dbos-0.24.0a15 → dbos-0.25.0}/pyproject.toml +3 -1
  27. dbos-0.25.0/tests/client_collateral.py +38 -0
  28. dbos-0.25.0/tests/client_worker.py +30 -0
  29. {dbos-0.24.0a15 → dbos-0.25.0}/tests/conftest.py +14 -2
  30. {dbos-0.24.0a15 → dbos-0.25.0}/tests/test_admin_server.py +7 -9
  31. {dbos-0.24.0a15 → dbos-0.25.0}/tests/test_async.py +28 -4
  32. {dbos-0.24.0a15 → dbos-0.25.0}/tests/test_classdecorators.py +5 -6
  33. dbos-0.25.0/tests/test_client.py +399 -0
  34. {dbos-0.24.0a15 → dbos-0.25.0}/tests/test_config.py +1 -2
  35. {dbos-0.24.0a15 → dbos-0.25.0}/tests/test_dbos.py +75 -40
  36. {dbos-0.24.0a15 → dbos-0.25.0}/tests/test_failures.py +93 -45
  37. {dbos-0.24.0a15 → dbos-0.25.0}/tests/test_fastapi.py +1 -2
  38. {dbos-0.24.0a15 → dbos-0.25.0}/tests/test_fastapi_roles.py +3 -2
  39. {dbos-0.24.0a15 → dbos-0.25.0}/tests/test_flask.py +0 -1
  40. {dbos-0.24.0a15 → dbos-0.25.0}/tests/test_package.py +1 -2
  41. {dbos-0.24.0a15 → dbos-0.25.0}/tests/test_queue.py +5 -48
  42. {dbos-0.24.0a15 → dbos-0.25.0}/tests/test_scheduler.py +0 -1
  43. {dbos-0.24.0a15 → dbos-0.25.0}/tests/test_workflow_cancel.py +2 -2
  44. dbos-0.25.0/tests/test_workflow_cmds.py +858 -0
  45. dbos-0.24.0a15/dbos/_workflow_commands.py +0 -148
  46. dbos-0.24.0a15/tests/test_workflow_cmds.py +0 -314
  47. {dbos-0.24.0a15 → dbos-0.25.0}/LICENSE +0 -0
  48. {dbos-0.24.0a15 → dbos-0.25.0}/README.md +0 -0
  49. {dbos-0.24.0a15 → dbos-0.25.0}/dbos/_classproperty.py +0 -0
  50. {dbos-0.24.0a15 → dbos-0.25.0}/dbos/_cloudutils/authentication.py +0 -0
  51. {dbos-0.24.0a15 → dbos-0.25.0}/dbos/_cloudutils/cloudutils.py +0 -0
  52. {dbos-0.24.0a15 → dbos-0.25.0}/dbos/_cloudutils/databases.py +0 -0
  53. {dbos-0.24.0a15 → dbos-0.25.0}/dbos/_croniter.py +0 -0
  54. {dbos-0.24.0a15 → dbos-0.25.0}/dbos/_debug.py +0 -0
  55. {dbos-0.24.0a15 → dbos-0.25.0}/dbos/_error.py +0 -0
  56. {dbos-0.24.0a15 → dbos-0.25.0}/dbos/_flask.py +0 -0
  57. {dbos-0.24.0a15 → dbos-0.25.0}/dbos/_kafka.py +0 -0
  58. {dbos-0.24.0a15 → dbos-0.25.0}/dbos/_kafka_message.py +0 -0
  59. {dbos-0.24.0a15 → dbos-0.25.0}/dbos/_migrations/env.py +0 -0
  60. {dbos-0.24.0a15 → dbos-0.25.0}/dbos/_migrations/script.py.mako +0 -0
  61. {dbos-0.24.0a15 → dbos-0.25.0}/dbos/_migrations/versions/04ca4f231047_workflow_queues_executor_id.py +0 -0
  62. {dbos-0.24.0a15 → dbos-0.25.0}/dbos/_migrations/versions/50f3227f0b4b_fix_job_queue.py +0 -0
  63. {dbos-0.24.0a15 → dbos-0.25.0}/dbos/_migrations/versions/5c361fc04708_added_system_tables.py +0 -0
  64. {dbos-0.24.0a15 → dbos-0.25.0}/dbos/_migrations/versions/a3b18ad34abe_added_triggers.py +0 -0
  65. {dbos-0.24.0a15 → dbos-0.25.0}/dbos/_migrations/versions/d76646551a6b_job_queue_limiter.py +0 -0
  66. {dbos-0.24.0a15 → dbos-0.25.0}/dbos/_migrations/versions/d76646551a6c_workflow_queue.py +0 -0
  67. {dbos-0.24.0a15 → dbos-0.25.0}/dbos/_migrations/versions/eab0cc1d9a14_job_queue.py +0 -0
  68. {dbos-0.24.0a15 → dbos-0.25.0}/dbos/_recovery.py +0 -0
  69. {dbos-0.24.0a15 → dbos-0.25.0}/dbos/_registrations.py +0 -0
  70. {dbos-0.24.0a15 → dbos-0.25.0}/dbos/_request.py +0 -0
  71. {dbos-0.24.0a15 → dbos-0.25.0}/dbos/_roles.py +0 -0
  72. {dbos-0.24.0a15 → dbos-0.25.0}/dbos/_scheduler.py +0 -0
  73. {dbos-0.24.0a15 → dbos-0.25.0}/dbos/_schemas/__init__.py +0 -0
  74. {dbos-0.24.0a15 → dbos-0.25.0}/dbos/_schemas/application_database.py +0 -0
  75. {dbos-0.24.0a15 → dbos-0.25.0}/dbos/_serialization.py +0 -0
  76. {dbos-0.24.0a15 → dbos-0.25.0}/dbos/_templates/dbos-db-starter/README.md +0 -0
  77. {dbos-0.24.0a15 → dbos-0.25.0}/dbos/_templates/dbos-db-starter/__package/__init__.py +0 -0
  78. {dbos-0.24.0a15 → dbos-0.25.0}/dbos/_templates/dbos-db-starter/__package/schema.py +0 -0
  79. {dbos-0.24.0a15 → dbos-0.25.0}/dbos/_templates/dbos-db-starter/alembic.ini +0 -0
  80. {dbos-0.24.0a15 → dbos-0.25.0}/dbos/_templates/dbos-db-starter/migrations/env.py.dbos +0 -0
  81. {dbos-0.24.0a15 → dbos-0.25.0}/dbos/_templates/dbos-db-starter/migrations/script.py.mako +0 -0
  82. {dbos-0.24.0a15 → dbos-0.25.0}/dbos/_templates/dbos-db-starter/migrations/versions/2024_07_31_180642_init.py +0 -0
  83. {dbos-0.24.0a15 → dbos-0.25.0}/dbos/_templates/dbos-db-starter/start_postgres_docker.py +0 -0
  84. {dbos-0.24.0a15 → dbos-0.25.0}/dbos/_tracer.py +0 -0
  85. {dbos-0.24.0a15 → dbos-0.25.0}/dbos/_utils.py +0 -0
  86. {dbos-0.24.0a15 → dbos-0.25.0}/dbos/cli/_github_init.py +0 -0
  87. {dbos-0.24.0a15 → dbos-0.25.0}/dbos/dbos-config.schema.json +0 -0
  88. {dbos-0.24.0a15 → dbos-0.25.0}/dbos/py.typed +0 -0
  89. {dbos-0.24.0a15 → dbos-0.25.0}/tests/__init__.py +0 -0
  90. {dbos-0.24.0a15 → dbos-0.25.0}/tests/atexit_no_ctor.py +0 -0
  91. {dbos-0.24.0a15 → dbos-0.25.0}/tests/atexit_no_launch.py +0 -0
  92. {dbos-0.24.0a15 → dbos-0.25.0}/tests/classdefs.py +0 -0
  93. {dbos-0.24.0a15 → dbos-0.25.0}/tests/more_classdefs.py +0 -0
  94. {dbos-0.24.0a15 → dbos-0.25.0}/tests/queuedworkflow.py +0 -0
  95. {dbos-0.24.0a15 → dbos-0.25.0}/tests/test_concurrency.py +0 -0
  96. {dbos-0.24.0a15 → dbos-0.25.0}/tests/test_croniter.py +0 -0
  97. {dbos-0.24.0a15 → dbos-0.25.0}/tests/test_dbwizard.py +0 -0
  98. {dbos-0.24.0a15 → dbos-0.25.0}/tests/test_debug.py +0 -0
  99. {dbos-0.24.0a15 → dbos-0.25.0}/tests/test_kafka.py +0 -0
  100. {dbos-0.24.0a15 → dbos-0.25.0}/tests/test_outcome.py +0 -0
  101. {dbos-0.24.0a15 → dbos-0.25.0}/tests/test_schema_migration.py +0 -0
  102. {dbos-0.24.0a15 → dbos-0.25.0}/tests/test_singleton.py +0 -0
  103. {dbos-0.24.0a15 → dbos-0.25.0}/tests/test_spans.py +0 -0
  104. {dbos-0.24.0a15 → dbos-0.25.0}/tests/test_sqlalchemy.py +0 -0
  105. {dbos-0.24.0a15 → dbos-0.25.0}/version/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: dbos
3
- Version: 0.24.0a15
3
+ Version: 0.25.0
4
4
  Summary: Ultra-lightweight durable execution in Python
5
5
  Author-Email: "DBOS, Inc." <contact@dbos.dev>
6
6
  License: MIT
@@ -23,6 +23,7 @@ Requires-Dist: cryptography>=43.0.3
23
23
  Requires-Dist: rich>=13.9.4
24
24
  Requires-Dist: pyjwt>=2.10.1
25
25
  Requires-Dist: websockets>=15.0
26
+ Requires-Dist: pyright>=1.1.398
26
27
  Description-Content-Type: text/markdown
27
28
 
28
29
 
@@ -1,18 +1,22 @@
1
1
  from . import _error as error
2
+ from ._client import DBOSClient, EnqueueOptions
2
3
  from ._context import DBOSContextEnsure, DBOSContextSetAuth, SetWorkflowID
3
- from ._dbos import DBOS, DBOSConfiguredInstance, WorkflowHandle, WorkflowStatus
4
+ from ._dbos import DBOS, DBOSConfiguredInstance, WorkflowHandle
4
5
  from ._dbos_config import ConfigFile, DBOSConfig, get_dbos_database_url, load_config
5
6
  from ._kafka_message import KafkaMessage
6
7
  from ._queue import Queue
7
8
  from ._sys_db import GetWorkflowsInput, WorkflowStatusString
9
+ from ._workflow_commands import WorkflowStatus
8
10
 
9
11
  __all__ = [
10
12
  "ConfigFile",
11
13
  "DBOSConfig",
12
14
  "DBOS",
15
+ "DBOSClient",
13
16
  "DBOSConfiguredInstance",
14
17
  "DBOSContextEnsure",
15
18
  "DBOSContextSetAuth",
19
+ "EnqueueOptions",
16
20
  "GetWorkflowsInput",
17
21
  "KafkaMessage",
18
22
  "SetWorkflowID",
@@ -4,6 +4,9 @@ from typing import NoReturn, Optional, Union
4
4
 
5
5
  from dbos.cli.cli import app
6
6
 
7
+ # This is used by the debugger to execute DBOS as a module.
8
+ # Never used otherwise.
9
+
7
10
 
8
11
  def main() -> NoReturn:
9
12
  # Modify sys.argv[0] to remove script or executable extensions
@@ -20,6 +20,7 @@ _workflow_queues_metadata_path = "/dbos-workflow-queues-metadata"
20
20
  # /workflows/:workflow_id/cancel
21
21
  # /workflows/:workflow_id/resume
22
22
  # /workflows/:workflow_id/restart
23
+ # /workflows/:workflow_id/steps
23
24
 
24
25
 
25
26
  class AdminServer:
@@ -86,8 +87,16 @@ class AdminRequestHandler(BaseHTTPRequestHandler):
86
87
  self._end_headers()
87
88
  self.wfile.write(json.dumps(queue_metadata_array).encode("utf-8"))
88
89
  else:
89
- self.send_response(404)
90
- self._end_headers()
90
+ steps_match = re.match(
91
+ r"^/workflows/(?P<workflow_id>[^/]+)/steps$", self.path
92
+ )
93
+
94
+ if steps_match:
95
+ workflow_id = steps_match.group("workflow_id")
96
+ self._handle_steps(workflow_id)
97
+ else:
98
+ self.send_response(404)
99
+ self._end_headers()
91
100
 
92
101
  def do_POST(self) -> None:
93
102
  content_length = int(
@@ -149,6 +158,23 @@ class AdminRequestHandler(BaseHTTPRequestHandler):
149
158
  self.send_response(204)
150
159
  self._end_headers()
151
160
 
161
+ def _handle_steps(self, workflow_id: str) -> None:
162
+ steps = self.dbos._sys_db.get_workflow_steps(workflow_id)
163
+
164
+ updated_steps = [
165
+ {
166
+ **step,
167
+ "output": str(step["output"]) if step["output"] is not None else None,
168
+ "error": str(step["error"]) if step["error"] is not None else None,
169
+ }
170
+ for step in steps
171
+ ]
172
+
173
+ json_steps = json.dumps(updated_steps).encode("utf-8")
174
+ self.send_response(200)
175
+ self._end_headers()
176
+ self.wfile.write(json_steps)
177
+
152
178
 
153
179
  # Be consistent with DBOS-TS response.
154
180
  class PerfUtilization(TypedDict):
@@ -27,19 +27,18 @@ class RecordedResult(TypedDict):
27
27
 
28
28
  class ApplicationDatabase:
29
29
 
30
- def __init__(self, config: ConfigFile, *, debug_mode: bool = False):
31
- self.config = config
30
+ def __init__(self, database: DatabaseConfig, *, debug_mode: bool = False):
32
31
 
33
- app_db_name = config["database"]["app_db_name"]
32
+ app_db_name = database["app_db_name"]
34
33
 
35
34
  # If the application database does not already exist, create it
36
35
  if not debug_mode:
37
36
  postgres_db_url = sa.URL.create(
38
37
  "postgresql+psycopg",
39
- username=config["database"]["username"],
40
- password=config["database"]["password"],
41
- host=config["database"]["hostname"],
42
- port=config["database"]["port"],
38
+ username=database["username"],
39
+ password=database["password"],
40
+ host=database["hostname"],
41
+ port=database["port"],
43
42
  database="postgres",
44
43
  )
45
44
  postgres_db_engine = sa.create_engine(postgres_db_url)
@@ -55,25 +54,25 @@ class ApplicationDatabase:
55
54
  # Create a connection pool for the application database
56
55
  app_db_url = sa.URL.create(
57
56
  "postgresql+psycopg",
58
- username=config["database"]["username"],
59
- password=config["database"]["password"],
60
- host=config["database"]["hostname"],
61
- port=config["database"]["port"],
57
+ username=database["username"],
58
+ password=database["password"],
59
+ host=database["hostname"],
60
+ port=database["port"],
62
61
  database=app_db_name,
63
62
  )
64
63
 
65
64
  connect_args = {}
66
65
  if (
67
- "connectionTimeoutMillis" in config["database"]
68
- and config["database"]["connectionTimeoutMillis"]
66
+ "connectionTimeoutMillis" in database
67
+ and database["connectionTimeoutMillis"]
69
68
  ):
70
69
  connect_args["connect_timeout"] = int(
71
- config["database"]["connectionTimeoutMillis"] / 1000
70
+ database["connectionTimeoutMillis"] / 1000
72
71
  )
73
72
 
74
73
  self.engine = sa.create_engine(
75
74
  app_db_url,
76
- pool_size=config["database"]["app_db_pool_size"],
75
+ pool_size=database["app_db_pool_size"],
77
76
  max_overflow=0,
78
77
  pool_timeout=30,
79
78
  connect_args=connect_args,
@@ -0,0 +1,206 @@
1
+ import asyncio
2
+ import sys
3
+ import uuid
4
+ from typing import Any, Generic, Optional, TypedDict, TypeVar
5
+
6
+ if sys.version_info < (3, 11):
7
+ from typing_extensions import NotRequired
8
+ else:
9
+ from typing import NotRequired
10
+
11
+ from dbos import _serialization
12
+ from dbos._dbos import WorkflowHandle, WorkflowHandleAsync
13
+ from dbos._dbos_config import parse_database_url_to_dbconfig
14
+ from dbos._error import DBOSNonExistentWorkflowError
15
+ from dbos._registrations import DEFAULT_MAX_RECOVERY_ATTEMPTS
16
+ from dbos._serialization import WorkflowInputs
17
+ from dbos._sys_db import SystemDatabase, WorkflowStatusInternal, WorkflowStatusString
18
+ from dbos._workflow_commands import WorkflowStatus, get_workflow
19
+
20
+ R = TypeVar("R", covariant=True) # A generic type for workflow return values
21
+
22
+
23
+ class EnqueueOptions(TypedDict):
24
+ workflow_name: str
25
+ workflow_class_name: NotRequired[str]
26
+ queue_name: str
27
+ app_version: NotRequired[str]
28
+ workflow_id: NotRequired[str]
29
+
30
+
31
+ class WorkflowHandleClientPolling(Generic[R]):
32
+
33
+ def __init__(self, workflow_id: str, sys_db: SystemDatabase):
34
+ self.workflow_id = workflow_id
35
+ self._sys_db = sys_db
36
+
37
+ def get_workflow_id(self) -> str:
38
+ return self.workflow_id
39
+
40
+ def get_result(self) -> R:
41
+ res: R = self._sys_db.await_workflow_result(self.workflow_id)
42
+ return res
43
+
44
+ def get_status(self) -> "WorkflowStatus":
45
+ status = get_workflow(self._sys_db, self.workflow_id, True)
46
+ if status is None:
47
+ raise DBOSNonExistentWorkflowError(self.workflow_id)
48
+ return status
49
+
50
+
51
+ class WorkflowHandleClientAsyncPolling(Generic[R]):
52
+
53
+ def __init__(self, workflow_id: str, sys_db: SystemDatabase):
54
+ self.workflow_id = workflow_id
55
+ self._sys_db = sys_db
56
+
57
+ def get_workflow_id(self) -> str:
58
+ return self.workflow_id
59
+
60
+ async def get_result(self) -> R:
61
+ res: R = await asyncio.to_thread(
62
+ self._sys_db.await_workflow_result, self.workflow_id
63
+ )
64
+ return res
65
+
66
+ async def get_status(self) -> "WorkflowStatus":
67
+ status = await asyncio.to_thread(
68
+ get_workflow, self._sys_db, self.workflow_id, True
69
+ )
70
+ if status is None:
71
+ raise DBOSNonExistentWorkflowError(self.workflow_id)
72
+ return status
73
+
74
+
75
+ class DBOSClient:
76
+ def __init__(self, database_url: str, *, system_database: Optional[str] = None):
77
+ db_config = parse_database_url_to_dbconfig(database_url)
78
+ if system_database is not None:
79
+ db_config["sys_db_name"] = system_database
80
+ self._sys_db = SystemDatabase(db_config)
81
+
82
+ def destroy(self) -> None:
83
+ self._sys_db.destroy()
84
+
85
+ def _enqueue(self, options: EnqueueOptions, *args: Any, **kwargs: Any) -> str:
86
+ workflow_name = options["workflow_name"]
87
+ queue_name = options["queue_name"]
88
+
89
+ workflow_class_name = options.get("workflow_class_name")
90
+ app_version = options.get("app_version")
91
+ max_recovery_attempts = options.get("max_recovery_attempts")
92
+ if max_recovery_attempts is None:
93
+ max_recovery_attempts = DEFAULT_MAX_RECOVERY_ATTEMPTS
94
+ workflow_id = options.get("workflow_id")
95
+ if workflow_id is None:
96
+ workflow_id = str(uuid.uuid4())
97
+
98
+ status: WorkflowStatusInternal = {
99
+ "workflow_uuid": workflow_id,
100
+ "status": WorkflowStatusString.ENQUEUED.value,
101
+ "name": workflow_name,
102
+ "class_name": workflow_class_name,
103
+ "queue_name": queue_name,
104
+ "app_version": app_version,
105
+ "config_name": None,
106
+ "authenticated_user": None,
107
+ "assumed_role": None,
108
+ "authenticated_roles": None,
109
+ "request": None,
110
+ "output": None,
111
+ "error": None,
112
+ "created_at": None,
113
+ "updated_at": None,
114
+ "executor_id": None,
115
+ "recovery_attempts": None,
116
+ "app_id": None,
117
+ }
118
+
119
+ inputs: WorkflowInputs = {
120
+ "args": args,
121
+ "kwargs": kwargs,
122
+ }
123
+
124
+ wf_status = self._sys_db.insert_workflow_status(status)
125
+ self._sys_db.update_workflow_inputs(
126
+ workflow_id, _serialization.serialize_args(inputs)
127
+ )
128
+ if wf_status == WorkflowStatusString.ENQUEUED.value:
129
+ self._sys_db.enqueue(workflow_id, queue_name)
130
+ return workflow_id
131
+
132
+ def enqueue(
133
+ self, options: EnqueueOptions, *args: Any, **kwargs: Any
134
+ ) -> WorkflowHandle[R]:
135
+ workflow_id = self._enqueue(options, *args, **kwargs)
136
+ return WorkflowHandleClientPolling[R](workflow_id, self._sys_db)
137
+
138
+ async def enqueue_async(
139
+ self, options: EnqueueOptions, *args: Any, **kwargs: Any
140
+ ) -> WorkflowHandleAsync[R]:
141
+ workflow_id = await asyncio.to_thread(self._enqueue, options, *args, **kwargs)
142
+ return WorkflowHandleClientAsyncPolling[R](workflow_id, self._sys_db)
143
+
144
+ def retrieve_workflow(self, workflow_id: str) -> WorkflowHandle[R]:
145
+ status = get_workflow(self._sys_db, workflow_id, True)
146
+ if status is None:
147
+ raise DBOSNonExistentWorkflowError(workflow_id)
148
+ return WorkflowHandleClientPolling[R](workflow_id, self._sys_db)
149
+
150
+ async def retrieve_workflow_async(self, workflow_id: str) -> WorkflowHandleAsync[R]:
151
+ status = asyncio.to_thread(get_workflow, self._sys_db, workflow_id, True)
152
+ if status is None:
153
+ raise DBOSNonExistentWorkflowError(workflow_id)
154
+ return WorkflowHandleClientAsyncPolling[R](workflow_id, self._sys_db)
155
+
156
+ def send(
157
+ self,
158
+ destination_id: str,
159
+ message: Any,
160
+ topic: Optional[str] = None,
161
+ idempotency_key: Optional[str] = None,
162
+ ) -> None:
163
+ idempotency_key = idempotency_key if idempotency_key else str(uuid.uuid4())
164
+ status: WorkflowStatusInternal = {
165
+ "workflow_uuid": f"{destination_id}-{idempotency_key}",
166
+ "status": WorkflowStatusString.SUCCESS.value,
167
+ "name": "temp_workflow-send-client",
168
+ "class_name": None,
169
+ "queue_name": None,
170
+ "config_name": None,
171
+ "authenticated_user": None,
172
+ "assumed_role": None,
173
+ "authenticated_roles": None,
174
+ "request": None,
175
+ "output": None,
176
+ "error": None,
177
+ "created_at": None,
178
+ "updated_at": None,
179
+ "executor_id": None,
180
+ "recovery_attempts": None,
181
+ "app_id": None,
182
+ "app_version": None,
183
+ }
184
+ self._sys_db.insert_workflow_status(status)
185
+ self._sys_db.send(status["workflow_uuid"], 0, destination_id, message, topic)
186
+
187
+ async def send_async(
188
+ self,
189
+ destination_id: str,
190
+ message: Any,
191
+ topic: Optional[str] = None,
192
+ idempotency_key: Optional[str] = None,
193
+ ) -> None:
194
+ return await asyncio.to_thread(
195
+ self.send, destination_id, message, topic, idempotency_key
196
+ )
197
+
198
+ def get_event(self, workflow_id: str, key: str, timeout_seconds: float = 60) -> Any:
199
+ return self._sys_db.get_event(workflow_id, key, timeout_seconds)
200
+
201
+ async def get_event_async(
202
+ self, workflow_id: str, key: str, timeout_seconds: float = 60
203
+ ) -> Any:
204
+ return await asyncio.to_thread(
205
+ self.get_event, workflow_id, key, timeout_seconds
206
+ )
@@ -9,7 +9,12 @@ from websockets.sync.client import connect
9
9
  from websockets.sync.connection import Connection
10
10
 
11
11
  from dbos._utils import GlobalParams
12
- from dbos._workflow_commands import get_workflow, list_queued_workflows, list_workflows
12
+ from dbos._workflow_commands import (
13
+ get_workflow,
14
+ list_queued_workflows,
15
+ list_workflow_steps,
16
+ list_workflows,
17
+ )
13
18
 
14
19
  from . import protocol as p
15
20
 
@@ -203,7 +208,7 @@ class ConductorWebsocket(threading.Thread):
203
208
  info = get_workflow(
204
209
  self.dbos._sys_db,
205
210
  get_workflow_message.workflow_id,
206
- getRequest=False,
211
+ get_request=False,
207
212
  )
208
213
  except Exception as e:
209
214
  error_message = f"Exception encountered when getting workflow {get_workflow_message.workflow_id}: {traceback.format_exc()}"
@@ -243,6 +248,32 @@ class ConductorWebsocket(threading.Thread):
243
248
  )
244
249
  )
245
250
  websocket.send(exist_pending_workflows_response.to_json())
251
+ elif msg_type == p.MessageType.LIST_STEPS:
252
+ list_steps_message = p.ListStepsRequest.from_json(message)
253
+ step_info = None
254
+ try:
255
+ step_info = list_workflow_steps(
256
+ self.dbos._sys_db,
257
+ list_steps_message.workflow_id,
258
+ )
259
+ except Exception as e:
260
+ error_message = f"Exception encountered when getting workflow {list_steps_message.workflow_id}: {traceback.format_exc()}"
261
+ self.dbos.logger.error(error_message)
262
+
263
+ list_steps_response = p.ListStepsResponse(
264
+ type=p.MessageType.LIST_STEPS,
265
+ request_id=base_message.request_id,
266
+ output=(
267
+ [
268
+ p.WorkflowSteps.from_step_info(i)
269
+ for i in step_info
270
+ ]
271
+ if step_info is not None
272
+ else None
273
+ ),
274
+ error_message=error_message,
275
+ )
276
+ websocket.send(list_steps_response.to_json())
246
277
  else:
247
278
  self.dbos.logger.warning(
248
279
  f"Unexpected message type: {msg_type}"
@@ -3,7 +3,8 @@ from dataclasses import asdict, dataclass
3
3
  from enum import Enum
4
4
  from typing import List, Optional, Type, TypedDict, TypeVar
5
5
 
6
- from dbos._workflow_commands import WorkflowInformation
6
+ from dbos._sys_db import StepInfo
7
+ from dbos._workflow_commands import WorkflowStatus
7
8
 
8
9
 
9
10
  class MessageType(str, Enum):
@@ -16,6 +17,7 @@ class MessageType(str, Enum):
16
17
  RESTART = "restart"
17
18
  GET_WORKFLOW = "get_workflow"
18
19
  EXIST_PENDING_WORKFLOWS = "exist_pending_workflows"
20
+ LIST_STEPS = "list_steps"
19
21
 
20
22
 
21
23
  T = TypeVar("T", bound="BaseMessage")
@@ -141,27 +143,33 @@ class WorkflowsOutput:
141
143
  ExecutorID: Optional[str]
142
144
 
143
145
  @classmethod
144
- def from_workflow_information(cls, info: WorkflowInformation) -> "WorkflowsOutput":
146
+ def from_workflow_information(cls, info: WorkflowStatus) -> "WorkflowsOutput":
145
147
  # Convert fields to strings as needed
146
148
  created_at_str = str(info.created_at) if info.created_at is not None else None
147
149
  updated_at_str = str(info.updated_at) if info.updated_at is not None else None
148
150
  inputs_str = str(info.input) if info.input is not None else None
149
151
  outputs_str = str(info.output) if info.output is not None else None
152
+ error_str = str(info.error) if info.error is not None else None
150
153
  request_str = str(info.request) if info.request is not None else None
154
+ roles_str = (
155
+ str(info.authenticated_roles)
156
+ if info.authenticated_roles is not None
157
+ else None
158
+ )
151
159
 
152
160
  return cls(
153
161
  WorkflowUUID=info.workflow_id,
154
162
  Status=info.status,
155
- WorkflowName=info.workflow_name,
156
- WorkflowClassName=info.workflow_class_name,
157
- WorkflowConfigName=info.workflow_config_name,
163
+ WorkflowName=info.name,
164
+ WorkflowClassName=info.class_name,
165
+ WorkflowConfigName=info.config_name,
158
166
  AuthenticatedUser=info.authenticated_user,
159
167
  AssumedRole=info.assumed_role,
160
- AuthenticatedRoles=info.authenticated_roles,
168
+ AuthenticatedRoles=roles_str,
161
169
  Input=inputs_str,
162
170
  Output=outputs_str,
163
171
  Request=request_str,
164
- Error=info.error,
172
+ Error=error_str,
165
173
  CreatedAt=created_at_str,
166
174
  UpdatedAt=updated_at_str,
167
175
  QueueName=info.queue_name,
@@ -170,6 +178,27 @@ class WorkflowsOutput:
170
178
  )
171
179
 
172
180
 
181
+ @dataclass
182
+ class WorkflowSteps:
183
+ function_id: int
184
+ function_name: str
185
+ output: Optional[str]
186
+ error: Optional[str]
187
+ child_workflow_id: Optional[str]
188
+
189
+ @classmethod
190
+ def from_step_info(cls, info: StepInfo) -> "WorkflowSteps":
191
+ output_str = str(info["output"]) if info["output"] is not None else None
192
+ error_str = str(info["error"]) if info["error"] is not None else None
193
+ return cls(
194
+ function_id=info["function_id"],
195
+ function_name=info["function_name"],
196
+ output=output_str,
197
+ error=error_str,
198
+ child_workflow_id=info["child_workflow_id"],
199
+ )
200
+
201
+
173
202
  @dataclass
174
203
  class ListWorkflowsRequest(BaseMessage):
175
204
  body: ListWorkflowsBody
@@ -224,3 +253,14 @@ class ExistPendingWorkflowsRequest(BaseMessage):
224
253
  class ExistPendingWorkflowsResponse(BaseMessage):
225
254
  exist: bool
226
255
  error_message: Optional[str] = None
256
+
257
+
258
+ @dataclass
259
+ class ListStepsRequest(BaseMessage):
260
+ workflow_id: str
261
+
262
+
263
+ @dataclass
264
+ class ListStepsResponse(BaseMessage):
265
+ output: Optional[List[WorkflowSteps]]
266
+ error_message: Optional[str] = None
@@ -5,6 +5,7 @@ import os
5
5
  import uuid
6
6
  from contextlib import AbstractContextManager
7
7
  from contextvars import ContextVar
8
+ from dataclasses import dataclass
8
9
  from enum import Enum
9
10
  from types import TracebackType
10
11
  from typing import List, Literal, Optional, Type, TypedDict
@@ -48,6 +49,23 @@ class TracedAttributes(TypedDict, total=False):
48
49
  authenticatedUserAssumedRole: Optional[str]
49
50
 
50
51
 
52
+ @dataclass
53
+ class StepStatus:
54
+ """
55
+ Status of a step execution.
56
+
57
+ Attributes:
58
+ step_id: The unique ID of this step in its workflow.
59
+ current_attempt: For steps with automatic retries, which attempt number (zero-indexed) is currently executing.
60
+ max_attempts: For steps with automatic retries, the maximum number of attempts that will be made before the step fails.
61
+
62
+ """
63
+
64
+ step_id: int
65
+ current_attempt: Optional[int]
66
+ max_attempts: Optional[int]
67
+
68
+
51
69
  class DBOSContext:
52
70
  def __init__(self) -> None:
53
71
  self.executor_id = GlobalParams.executor_id
@@ -73,6 +91,7 @@ class DBOSContext:
73
91
  self.authenticated_user: Optional[str] = None
74
92
  self.authenticated_roles: Optional[List[str]] = None
75
93
  self.assumed_role: Optional[str] = None
94
+ self.step_status: Optional[StepStatus] = None
76
95
 
77
96
  def create_child(self) -> DBOSContext:
78
97
  rv = DBOSContext()
@@ -92,6 +111,9 @@ class DBOSContext:
92
111
  rv.assumed_role = self.assumed_role
93
112
  return rv
94
113
 
114
+ def has_parent(self) -> bool:
115
+ return len(self.parent_workflow_id) > 0
116
+
95
117
  def assign_workflow_id(self) -> str:
96
118
  if len(self.id_assigned_for_next_workflow) > 0:
97
119
  wfid = self.id_assigned_for_next_workflow
@@ -147,10 +169,12 @@ class DBOSContext:
147
169
  attributes: TracedAttributes,
148
170
  ) -> None:
149
171
  self.curr_step_function_id = fid
172
+ self.step_status = StepStatus(fid, None, None)
150
173
  self._start_span(attributes)
151
174
 
152
175
  def end_step(self, exc_value: Optional[BaseException]) -> None:
153
176
  self.curr_step_function_id = -1
177
+ self.step_status = None
154
178
  self._end_span(exc_value)
155
179
 
156
180
  def start_transaction(
@@ -429,6 +453,30 @@ class EnterDBOSStep:
429
453
  return False # Did not handle
430
454
 
431
455
 
456
+ class EnterDBOSStepRetry:
457
+ def __init__(self, current_attempt: int, max_attempts: int) -> None:
458
+ self.current_attempt = current_attempt
459
+ self.max_attempts = max_attempts
460
+
461
+ def __enter__(self) -> None:
462
+ ctx = get_local_dbos_context()
463
+ if ctx is not None and ctx.step_status is not None:
464
+ ctx.step_status.current_attempt = self.current_attempt
465
+ ctx.step_status.max_attempts = self.max_attempts
466
+
467
+ def __exit__(
468
+ self,
469
+ exc_type: Optional[Type[BaseException]],
470
+ exc_value: Optional[BaseException],
471
+ traceback: Optional[TracebackType],
472
+ ) -> Literal[False]:
473
+ ctx = get_local_dbos_context()
474
+ if ctx is not None and ctx.step_status is not None:
475
+ ctx.step_status.current_attempt = None
476
+ ctx.step_status.max_attempts = None
477
+ return False # Did not handle
478
+
479
+
432
480
  class EnterDBOSTransaction:
433
481
  def __init__(self, sqls: Session, attributes: TracedAttributes) -> None:
434
482
  self.sqls = sqls