dbos 1.5.0a4__tar.gz → 1.5.0a10__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 (108) hide show
  1. {dbos-1.5.0a4 → dbos-1.5.0a10}/PKG-INFO +1 -1
  2. {dbos-1.5.0a4 → dbos-1.5.0a10}/dbos/_admin_server.py +87 -2
  3. {dbos-1.5.0a4 → dbos-1.5.0a10}/dbos/_core.py +1 -1
  4. {dbos-1.5.0a4 → dbos-1.5.0a10}/dbos/_dbos_config.py +5 -2
  5. {dbos-1.5.0a4 → dbos-1.5.0a10}/dbos/_error.py +5 -2
  6. {dbos-1.5.0a4 → dbos-1.5.0a10}/dbos/_logger.py +5 -2
  7. {dbos-1.5.0a4 → dbos-1.5.0a10}/dbos/_outcome.py +11 -7
  8. {dbos-1.5.0a4 → dbos-1.5.0a10}/dbos/_tracer.py +5 -0
  9. {dbos-1.5.0a4 → dbos-1.5.0a10}/dbos/cli/cli.py +31 -2
  10. {dbos-1.5.0a4 → dbos-1.5.0a10}/pyproject.toml +1 -1
  11. {dbos-1.5.0a4 → dbos-1.5.0a10}/tests/test_admin_server.py +157 -1
  12. {dbos-1.5.0a4 → dbos-1.5.0a10}/tests/test_config.py +21 -10
  13. {dbos-1.5.0a4 → dbos-1.5.0a10}/tests/test_failures.py +7 -1
  14. {dbos-1.5.0a4 → dbos-1.5.0a10}/tests/test_outcome.py +2 -2
  15. {dbos-1.5.0a4 → dbos-1.5.0a10}/tests/test_queue.py +54 -1
  16. {dbos-1.5.0a4 → dbos-1.5.0a10}/tests/test_spans.py +7 -2
  17. {dbos-1.5.0a4 → dbos-1.5.0a10}/LICENSE +0 -0
  18. {dbos-1.5.0a4 → dbos-1.5.0a10}/README.md +0 -0
  19. {dbos-1.5.0a4 → dbos-1.5.0a10}/dbos/__init__.py +0 -0
  20. {dbos-1.5.0a4 → dbos-1.5.0a10}/dbos/__main__.py +0 -0
  21. {dbos-1.5.0a4 → dbos-1.5.0a10}/dbos/_app_db.py +0 -0
  22. {dbos-1.5.0a4 → dbos-1.5.0a10}/dbos/_classproperty.py +0 -0
  23. {dbos-1.5.0a4 → dbos-1.5.0a10}/dbos/_client.py +0 -0
  24. {dbos-1.5.0a4 → dbos-1.5.0a10}/dbos/_conductor/conductor.py +0 -0
  25. {dbos-1.5.0a4 → dbos-1.5.0a10}/dbos/_conductor/protocol.py +0 -0
  26. {dbos-1.5.0a4 → dbos-1.5.0a10}/dbos/_context.py +0 -0
  27. {dbos-1.5.0a4 → dbos-1.5.0a10}/dbos/_croniter.py +0 -0
  28. {dbos-1.5.0a4 → dbos-1.5.0a10}/dbos/_dbos.py +0 -0
  29. {dbos-1.5.0a4 → dbos-1.5.0a10}/dbos/_debug.py +0 -0
  30. {dbos-1.5.0a4 → dbos-1.5.0a10}/dbos/_docker_pg_helper.py +0 -0
  31. {dbos-1.5.0a4 → dbos-1.5.0a10}/dbos/_event_loop.py +0 -0
  32. {dbos-1.5.0a4 → dbos-1.5.0a10}/dbos/_fastapi.py +0 -0
  33. {dbos-1.5.0a4 → dbos-1.5.0a10}/dbos/_flask.py +0 -0
  34. {dbos-1.5.0a4 → dbos-1.5.0a10}/dbos/_kafka.py +0 -0
  35. {dbos-1.5.0a4 → dbos-1.5.0a10}/dbos/_kafka_message.py +0 -0
  36. {dbos-1.5.0a4 → dbos-1.5.0a10}/dbos/_migrations/env.py +0 -0
  37. {dbos-1.5.0a4 → dbos-1.5.0a10}/dbos/_migrations/script.py.mako +0 -0
  38. {dbos-1.5.0a4 → dbos-1.5.0a10}/dbos/_migrations/versions/04ca4f231047_workflow_queues_executor_id.py +0 -0
  39. {dbos-1.5.0a4 → dbos-1.5.0a10}/dbos/_migrations/versions/27ac6900c6ad_add_queue_dedup.py +0 -0
  40. {dbos-1.5.0a4 → dbos-1.5.0a10}/dbos/_migrations/versions/50f3227f0b4b_fix_job_queue.py +0 -0
  41. {dbos-1.5.0a4 → dbos-1.5.0a10}/dbos/_migrations/versions/5c361fc04708_added_system_tables.py +0 -0
  42. {dbos-1.5.0a4 → dbos-1.5.0a10}/dbos/_migrations/versions/66478e1b95e5_consolidate_queues.py +0 -0
  43. {dbos-1.5.0a4 → dbos-1.5.0a10}/dbos/_migrations/versions/83f3732ae8e7_workflow_timeout.py +0 -0
  44. {dbos-1.5.0a4 → dbos-1.5.0a10}/dbos/_migrations/versions/933e86bdac6a_add_queue_priority.py +0 -0
  45. {dbos-1.5.0a4 → dbos-1.5.0a10}/dbos/_migrations/versions/a3b18ad34abe_added_triggers.py +0 -0
  46. {dbos-1.5.0a4 → dbos-1.5.0a10}/dbos/_migrations/versions/d76646551a6b_job_queue_limiter.py +0 -0
  47. {dbos-1.5.0a4 → dbos-1.5.0a10}/dbos/_migrations/versions/d76646551a6c_workflow_queue.py +0 -0
  48. {dbos-1.5.0a4 → dbos-1.5.0a10}/dbos/_migrations/versions/d994145b47b6_consolidate_inputs.py +0 -0
  49. {dbos-1.5.0a4 → dbos-1.5.0a10}/dbos/_migrations/versions/eab0cc1d9a14_job_queue.py +0 -0
  50. {dbos-1.5.0a4 → dbos-1.5.0a10}/dbos/_migrations/versions/f4b9b32ba814_functionname_childid_op_outputs.py +0 -0
  51. {dbos-1.5.0a4 → dbos-1.5.0a10}/dbos/_queue.py +0 -0
  52. {dbos-1.5.0a4 → dbos-1.5.0a10}/dbos/_recovery.py +0 -0
  53. {dbos-1.5.0a4 → dbos-1.5.0a10}/dbos/_registrations.py +0 -0
  54. {dbos-1.5.0a4 → dbos-1.5.0a10}/dbos/_roles.py +0 -0
  55. {dbos-1.5.0a4 → dbos-1.5.0a10}/dbos/_scheduler.py +0 -0
  56. {dbos-1.5.0a4 → dbos-1.5.0a10}/dbos/_schemas/__init__.py +0 -0
  57. {dbos-1.5.0a4 → dbos-1.5.0a10}/dbos/_schemas/application_database.py +0 -0
  58. {dbos-1.5.0a4 → dbos-1.5.0a10}/dbos/_schemas/system_database.py +0 -0
  59. {dbos-1.5.0a4 → dbos-1.5.0a10}/dbos/_serialization.py +0 -0
  60. {dbos-1.5.0a4 → dbos-1.5.0a10}/dbos/_sys_db.py +0 -0
  61. {dbos-1.5.0a4 → dbos-1.5.0a10}/dbos/_templates/dbos-db-starter/README.md +0 -0
  62. {dbos-1.5.0a4 → dbos-1.5.0a10}/dbos/_templates/dbos-db-starter/__package/__init__.py +0 -0
  63. {dbos-1.5.0a4 → dbos-1.5.0a10}/dbos/_templates/dbos-db-starter/__package/main.py.dbos +0 -0
  64. {dbos-1.5.0a4 → dbos-1.5.0a10}/dbos/_templates/dbos-db-starter/__package/schema.py +0 -0
  65. {dbos-1.5.0a4 → dbos-1.5.0a10}/dbos/_templates/dbos-db-starter/alembic.ini +0 -0
  66. {dbos-1.5.0a4 → dbos-1.5.0a10}/dbos/_templates/dbos-db-starter/dbos-config.yaml.dbos +0 -0
  67. {dbos-1.5.0a4 → dbos-1.5.0a10}/dbos/_templates/dbos-db-starter/migrations/env.py.dbos +0 -0
  68. {dbos-1.5.0a4 → dbos-1.5.0a10}/dbos/_templates/dbos-db-starter/migrations/script.py.mako +0 -0
  69. {dbos-1.5.0a4 → dbos-1.5.0a10}/dbos/_templates/dbos-db-starter/migrations/versions/2024_07_31_180642_init.py +0 -0
  70. {dbos-1.5.0a4 → dbos-1.5.0a10}/dbos/_templates/dbos-db-starter/start_postgres_docker.py +0 -0
  71. {dbos-1.5.0a4 → dbos-1.5.0a10}/dbos/_utils.py +0 -0
  72. {dbos-1.5.0a4 → dbos-1.5.0a10}/dbos/_workflow_commands.py +0 -0
  73. {dbos-1.5.0a4 → dbos-1.5.0a10}/dbos/cli/_github_init.py +0 -0
  74. {dbos-1.5.0a4 → dbos-1.5.0a10}/dbos/cli/_template_init.py +0 -0
  75. {dbos-1.5.0a4 → dbos-1.5.0a10}/dbos/dbos-config.schema.json +0 -0
  76. {dbos-1.5.0a4 → dbos-1.5.0a10}/dbos/py.typed +0 -0
  77. {dbos-1.5.0a4 → dbos-1.5.0a10}/tests/__init__.py +0 -0
  78. {dbos-1.5.0a4 → dbos-1.5.0a10}/tests/atexit_no_ctor.py +0 -0
  79. {dbos-1.5.0a4 → dbos-1.5.0a10}/tests/atexit_no_launch.py +0 -0
  80. {dbos-1.5.0a4 → dbos-1.5.0a10}/tests/classdefs.py +0 -0
  81. {dbos-1.5.0a4 → dbos-1.5.0a10}/tests/client_collateral.py +0 -0
  82. {dbos-1.5.0a4 → dbos-1.5.0a10}/tests/client_worker.py +0 -0
  83. {dbos-1.5.0a4 → dbos-1.5.0a10}/tests/conftest.py +0 -0
  84. {dbos-1.5.0a4 → dbos-1.5.0a10}/tests/dupname_classdefs1.py +0 -0
  85. {dbos-1.5.0a4 → dbos-1.5.0a10}/tests/dupname_classdefsa.py +0 -0
  86. {dbos-1.5.0a4 → dbos-1.5.0a10}/tests/more_classdefs.py +0 -0
  87. {dbos-1.5.0a4 → dbos-1.5.0a10}/tests/queuedworkflow.py +0 -0
  88. {dbos-1.5.0a4 → dbos-1.5.0a10}/tests/test_async.py +0 -0
  89. {dbos-1.5.0a4 → dbos-1.5.0a10}/tests/test_classdecorators.py +0 -0
  90. {dbos-1.5.0a4 → dbos-1.5.0a10}/tests/test_cli.py +0 -0
  91. {dbos-1.5.0a4 → dbos-1.5.0a10}/tests/test_client.py +0 -0
  92. {dbos-1.5.0a4 → dbos-1.5.0a10}/tests/test_concurrency.py +0 -0
  93. {dbos-1.5.0a4 → dbos-1.5.0a10}/tests/test_croniter.py +0 -0
  94. {dbos-1.5.0a4 → dbos-1.5.0a10}/tests/test_dbos.py +0 -0
  95. {dbos-1.5.0a4 → dbos-1.5.0a10}/tests/test_debug.py +0 -0
  96. {dbos-1.5.0a4 → dbos-1.5.0a10}/tests/test_docker_secrets.py +0 -0
  97. {dbos-1.5.0a4 → dbos-1.5.0a10}/tests/test_fastapi.py +0 -0
  98. {dbos-1.5.0a4 → dbos-1.5.0a10}/tests/test_fastapi_roles.py +0 -0
  99. {dbos-1.5.0a4 → dbos-1.5.0a10}/tests/test_flask.py +0 -0
  100. {dbos-1.5.0a4 → dbos-1.5.0a10}/tests/test_kafka.py +0 -0
  101. {dbos-1.5.0a4 → dbos-1.5.0a10}/tests/test_package.py +0 -0
  102. {dbos-1.5.0a4 → dbos-1.5.0a10}/tests/test_scheduler.py +0 -0
  103. {dbos-1.5.0a4 → dbos-1.5.0a10}/tests/test_schema_migration.py +0 -0
  104. {dbos-1.5.0a4 → dbos-1.5.0a10}/tests/test_singleton.py +0 -0
  105. {dbos-1.5.0a4 → dbos-1.5.0a10}/tests/test_sqlalchemy.py +0 -0
  106. {dbos-1.5.0a4 → dbos-1.5.0a10}/tests/test_workflow_introspection.py +0 -0
  107. {dbos-1.5.0a4 → dbos-1.5.0a10}/tests/test_workflow_management.py +0 -0
  108. {dbos-1.5.0a4 → dbos-1.5.0a10}/version/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: dbos
3
- Version: 1.5.0a4
3
+ Version: 1.5.0a10
4
4
  Summary: Ultra-lightweight durable execution in Python
5
5
  Author-Email: "DBOS, Inc." <contact@dbos.dev>
6
6
  License: MIT
@@ -5,7 +5,7 @@ import re
5
5
  import threading
6
6
  from functools import partial
7
7
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
8
- from typing import TYPE_CHECKING, Any, List, Optional, TypedDict
8
+ from typing import TYPE_CHECKING, Any, Dict, List, Optional, TypedDict
9
9
 
10
10
  from dbos._workflow_commands import garbage_collect, global_timeout
11
11
 
@@ -24,6 +24,8 @@ _deactivate_path = "/deactivate"
24
24
  _workflow_queues_metadata_path = "/dbos-workflow-queues-metadata"
25
25
  _garbage_collect_path = "/dbos-garbage-collect"
26
26
  _global_timeout_path = "/dbos-global-timeout"
27
+ _queued_workflows_path = "/queues"
28
+ _workflows_path = "/workflows"
27
29
  # /workflows/:workflow_id/cancel
28
30
  # /workflows/:workflow_id/resume
29
31
  # /workflows/:workflow_id/restart
@@ -104,10 +106,24 @@ class AdminRequestHandler(BaseHTTPRequestHandler):
104
106
  steps_match = re.match(
105
107
  r"^/workflows/(?P<workflow_id>[^/]+)/steps$", self.path
106
108
  )
109
+ workflow_match = re.match(r"^/workflows/(?P<workflow_id>[^/]+)$", self.path)
107
110
 
108
111
  if steps_match:
109
112
  workflow_id = steps_match.group("workflow_id")
110
113
  self._handle_steps(workflow_id)
114
+ elif workflow_match:
115
+ workflow_id = workflow_match.group("workflow_id")
116
+ workflows = self.dbos.list_workflows(workflow_ids=[workflow_id])
117
+ if not workflows:
118
+ self.send_response(404)
119
+ self._end_headers()
120
+ return
121
+ response_body = json.dumps(workflows[0].__dict__).encode("utf-8")
122
+ self.send_response(200)
123
+ self.send_header("Content-Type", "application/json")
124
+ self.send_header("Content-Length", str(len(response_body)))
125
+ self._end_headers()
126
+ self.wfile.write(response_body)
111
127
  else:
112
128
  self.send_response(404)
113
129
  self._end_headers()
@@ -126,6 +142,32 @@ class AdminRequestHandler(BaseHTTPRequestHandler):
126
142
  self.send_response(200)
127
143
  self._end_headers()
128
144
  self.wfile.write(json.dumps(workflow_ids).encode("utf-8"))
145
+ elif self.path == _workflows_path:
146
+ try:
147
+ filters = json.loads(post_data.decode("utf-8")) if post_data else {}
148
+ self._handle_workflows(filters)
149
+ except (json.JSONDecodeError, AttributeError) as e:
150
+ self.send_response(400)
151
+ self.send_header("Content-Type", "application/json")
152
+ self.end_headers()
153
+ self.wfile.write(
154
+ json.dumps({"error": f"Invalid JSON input: {str(e)}"}).encode(
155
+ "utf-8"
156
+ )
157
+ )
158
+ elif self.path == _queued_workflows_path:
159
+ try:
160
+ filters = json.loads(post_data.decode("utf-8")) if post_data else {}
161
+ self._handle_queued_workflows(filters)
162
+ except (json.JSONDecodeError, AttributeError) as e:
163
+ self.send_response(400)
164
+ self.send_header("Content-Type", "application/json")
165
+ self.end_headers()
166
+ self.wfile.write(
167
+ json.dumps({"error": f"Invalid JSON input: {str(e)}"}).encode(
168
+ "utf-8"
169
+ )
170
+ )
129
171
  elif self.path == _garbage_collect_path:
130
172
  inputs = json.loads(post_data.decode("utf-8"))
131
173
  cutoff_epoch_timestamp_ms = inputs.get("cutoff_epoch_timestamp_ms", None)
@@ -144,7 +186,6 @@ class AdminRequestHandler(BaseHTTPRequestHandler):
144
186
  self.send_response(204)
145
187
  self._end_headers()
146
188
  else:
147
-
148
189
  restart_match = re.match(
149
190
  r"^/workflows/(?P<workflow_id>[^/]+)/restart$", self.path
150
191
  )
@@ -283,6 +324,50 @@ class AdminRequestHandler(BaseHTTPRequestHandler):
283
324
  self._end_headers()
284
325
  self.wfile.write(json_steps)
285
326
 
327
+ def _handle_workflows(self, filters: Dict[str, Any]) -> None:
328
+ workflows = self.dbos.list_workflows(
329
+ workflow_ids=filters.get("workflow_ids"),
330
+ name=filters.get("name"),
331
+ start_time=filters.get("start_time"),
332
+ end_time=filters.get("end_time"),
333
+ status=filters.get("status"),
334
+ app_version=filters.get("application_version"),
335
+ limit=filters.get("limit"),
336
+ offset=filters.get("offset"),
337
+ sort_desc=filters.get("sort_desc", False),
338
+ workflow_id_prefix=filters.get("workflow_id_prefix"),
339
+ )
340
+
341
+ response_body = json.dumps(
342
+ [workflow.__dict__ for workflow in workflows]
343
+ ).encode("utf-8")
344
+ self.send_response(200)
345
+ self.send_header("Content-Type", "application/json")
346
+ self.send_header("Content-Length", str(len(response_body)))
347
+ self._end_headers()
348
+ self.wfile.write(response_body)
349
+
350
+ def _handle_queued_workflows(self, filters: Dict[str, Any]) -> None:
351
+ workflows = self.dbos.list_queued_workflows(
352
+ queue_name=filters.get("queue_name"),
353
+ name=filters.get("name"),
354
+ start_time=filters.get("start_time"),
355
+ end_time=filters.get("end_time"),
356
+ status=filters.get("status"),
357
+ limit=filters.get("limit"),
358
+ offset=filters.get("offset"),
359
+ sort_desc=filters.get("sort_desc", False),
360
+ )
361
+
362
+ response_body = json.dumps(
363
+ [workflow.__dict__ for workflow in workflows]
364
+ ).encode("utf-8")
365
+ self.send_response(200)
366
+ self.send_header("Content-Type", "application/json")
367
+ self.send_header("Content-Length", str(len(response_body)))
368
+ self._end_headers()
369
+ self.wfile.write(response_body)
370
+
286
371
 
287
372
  # Be consistent with DBOS-TS response.
288
373
  class PerfUtilization(TypedDict):
@@ -1123,7 +1123,7 @@ def decorate_step(
1123
1123
  stepOutcome = stepOutcome.retry(
1124
1124
  max_attempts,
1125
1125
  on_exception,
1126
- lambda i: DBOSMaxStepRetriesExceeded(func.__name__, i),
1126
+ lambda i, e: DBOSMaxStepRetriesExceeded(func.__name__, i, e),
1127
1127
  )
1128
1128
 
1129
1129
  outcome = (
@@ -31,6 +31,7 @@ class DBOSConfig(TypedDict, total=False):
31
31
  otlp_logs_endpoints: List[str]: OTLP logs endpoints
32
32
  admin_port (int): Admin port
33
33
  run_admin_server (bool): Whether to run the DBOS admin server
34
+ otlp_attributes (dict[str, str]): A set of custom attributes to apply OTLP-exported logs and traces
34
35
  """
35
36
 
36
37
  name: str
@@ -43,6 +44,7 @@ class DBOSConfig(TypedDict, total=False):
43
44
  otlp_logs_endpoints: Optional[List[str]]
44
45
  admin_port: Optional[int]
45
46
  run_admin_server: Optional[bool]
47
+ otlp_attributes: Optional[dict[str, str]]
46
48
 
47
49
 
48
50
  class RuntimeConfig(TypedDict, total=False):
@@ -84,6 +86,7 @@ class LoggerConfig(TypedDict, total=False):
84
86
  class TelemetryConfig(TypedDict, total=False):
85
87
  logs: Optional[LoggerConfig]
86
88
  OTLPExporter: Optional[OTLPExporterConfig]
89
+ otlp_attributes: Optional[dict[str, str]]
87
90
 
88
91
 
89
92
  class ConfigFile(TypedDict, total=False):
@@ -145,7 +148,8 @@ def translate_dbos_config_to_config_file(config: DBOSConfig) -> ConfigFile:
145
148
 
146
149
  # Telemetry config
147
150
  telemetry: TelemetryConfig = {
148
- "OTLPExporter": {"tracesEndpoint": [], "logsEndpoint": []}
151
+ "OTLPExporter": {"tracesEndpoint": [], "logsEndpoint": []},
152
+ "otlp_attributes": config.get("otlp_attributes", {}),
149
153
  }
150
154
  # For mypy
151
155
  assert telemetry["OTLPExporter"] is not None
@@ -431,7 +435,6 @@ def is_valid_database_url(database_url: str) -> bool:
431
435
  url = make_url(database_url)
432
436
  required_fields = [
433
437
  ("username", "Username must be specified in the connection URL"),
434
- ("password", "Password must be specified in the connection URL"),
435
438
  ("host", "Host must be specified in the connection URL"),
436
439
  ("database", "Database name must be specified in the connection URL"),
437
440
  ]
@@ -150,9 +150,12 @@ class DBOSNotAuthorizedError(DBOSException):
150
150
  class DBOSMaxStepRetriesExceeded(DBOSException):
151
151
  """Exception raised when a step was retried the maximimum number of times without success."""
152
152
 
153
- def __init__(self, step_name: str, max_retries: int) -> None:
153
+ def __init__(
154
+ self, step_name: str, max_retries: int, errors: list[Exception]
155
+ ) -> None:
154
156
  self.step_name = step_name
155
157
  self.max_retries = max_retries
158
+ self.errors = errors
156
159
  super().__init__(
157
160
  f"Step {step_name} has exceeded its maximum of {max_retries} retries",
158
161
  dbos_error_code=DBOSErrorCode.MaxStepRetriesExceeded.value,
@@ -160,7 +163,7 @@ class DBOSMaxStepRetriesExceeded(DBOSException):
160
163
 
161
164
  def __reduce__(self) -> Any:
162
165
  # Tell jsonpickle how to reconstruct this object
163
- return (self.__class__, (self.step_name, self.max_retries))
166
+ return (self.__class__, (self.step_name, self.max_retries, self.errors))
164
167
 
165
168
 
166
169
  class DBOSConflictingRegistrationError(DBOSException):
@@ -20,14 +20,17 @@ _otlp_handler, _dbos_log_transformer = None, None
20
20
 
21
21
 
22
22
  class DBOSLogTransformer(logging.Filter):
23
- def __init__(self) -> None:
23
+ def __init__(self, config: "ConfigFile") -> None:
24
24
  super().__init__()
25
25
  self.app_id = os.environ.get("DBOS__APPID", "")
26
+ self.otlp_attributes: dict[str, str] = config.get("telemetry", {}).get("otlp_attributes", {}) # type: ignore
26
27
 
27
28
  def filter(self, record: Any) -> bool:
28
29
  record.applicationID = self.app_id
29
30
  record.applicationVersion = GlobalParams.app_version
30
31
  record.executorID = GlobalParams.executor_id
32
+ for k, v in self.otlp_attributes.items():
33
+ setattr(record, k, v)
31
34
 
32
35
  # If available, decorate the log entry with Workflow ID and Trace ID
33
36
  from dbos._context import get_local_dbos_context
@@ -98,7 +101,7 @@ def config_logger(config: "ConfigFile") -> None:
98
101
 
99
102
  # Attach DBOS-specific attributes to all log entries.
100
103
  global _dbos_log_transformer
101
- _dbos_log_transformer = DBOSLogTransformer()
104
+ _dbos_log_transformer = DBOSLogTransformer(config)
102
105
  dbos_logger.addFilter(_dbos_log_transformer)
103
106
 
104
107
 
@@ -37,7 +37,7 @@ class Outcome(Protocol[T]):
37
37
  self,
38
38
  attempts: int,
39
39
  on_exception: Callable[[int, BaseException], float],
40
- exceeded_retries: Callable[[int], BaseException],
40
+ exceeded_retries: Callable[[int, list[Exception]], Exception],
41
41
  ) -> "Outcome[T]": ...
42
42
 
43
43
  def intercept(
@@ -96,23 +96,25 @@ class Immediate(Outcome[T]):
96
96
  func: Callable[[], T],
97
97
  attempts: int,
98
98
  on_exception: Callable[[int, BaseException], float],
99
- exceeded_retries: Callable[[int], BaseException],
99
+ exceeded_retries: Callable[[int, list[Exception]], Exception],
100
100
  ) -> T:
101
+ errors: list[Exception] = []
101
102
  for i in range(attempts):
102
103
  try:
103
104
  with EnterDBOSStepRetry(i, attempts):
104
105
  return func()
105
106
  except Exception as exp:
107
+ errors.append(exp)
106
108
  wait_time = on_exception(i, exp)
107
109
  time.sleep(wait_time)
108
110
 
109
- raise exceeded_retries(attempts)
111
+ raise exceeded_retries(attempts, errors)
110
112
 
111
113
  def retry(
112
114
  self,
113
115
  attempts: int,
114
116
  on_exception: Callable[[int, BaseException], float],
115
- exceeded_retries: Callable[[int], BaseException],
117
+ exceeded_retries: Callable[[int, list[Exception]], Exception],
116
118
  ) -> "Immediate[T]":
117
119
  assert attempts > 0
118
120
  return Immediate[T](
@@ -183,23 +185,25 @@ class Pending(Outcome[T]):
183
185
  func: Callable[[], Coroutine[Any, Any, T]],
184
186
  attempts: int,
185
187
  on_exception: Callable[[int, BaseException], float],
186
- exceeded_retries: Callable[[int], BaseException],
188
+ exceeded_retries: Callable[[int, list[Exception]], Exception],
187
189
  ) -> T:
190
+ errors: list[Exception] = []
188
191
  for i in range(attempts):
189
192
  try:
190
193
  with EnterDBOSStepRetry(i, attempts):
191
194
  return await func()
192
195
  except Exception as exp:
196
+ errors.append(exp)
193
197
  wait_time = on_exception(i, exp)
194
198
  await asyncio.sleep(wait_time)
195
199
 
196
- raise exceeded_retries(attempts)
200
+ raise exceeded_retries(attempts, errors)
197
201
 
198
202
  def retry(
199
203
  self,
200
204
  attempts: int,
201
205
  on_exception: Callable[[int, BaseException], float],
202
- exceeded_retries: Callable[[int], BaseException],
206
+ exceeded_retries: Callable[[int, list[Exception]], Exception],
203
207
  ) -> "Pending[T]":
204
208
  assert attempts > 0
205
209
  return Pending[T](
@@ -19,11 +19,14 @@ if TYPE_CHECKING:
19
19
 
20
20
  class DBOSTracer:
21
21
 
22
+ otlp_attributes: dict[str, str] = {}
23
+
22
24
  def __init__(self) -> None:
23
25
  self.app_id = os.environ.get("DBOS__APPID", None)
24
26
  self.provider: Optional[TracerProvider] = None
25
27
 
26
28
  def config(self, config: ConfigFile) -> None:
29
+ self.otlp_attributes = config.get("telemetry", {}).get("otlp_attributes", {}) # type: ignore
27
30
  if not isinstance(trace.get_tracer_provider(), TracerProvider):
28
31
  resource = Resource(
29
32
  attributes={
@@ -63,6 +66,8 @@ class DBOSTracer:
63
66
  for k, v in attributes.items():
64
67
  if k != "name" and v is not None and isinstance(v, (str, bool, int, float)):
65
68
  span.set_attribute(k, v)
69
+ for k, v in self.otlp_attributes.items():
70
+ span.set_attribute(k, v)
66
71
  return span
67
72
 
68
73
  def end_span(self, span: Span) -> None:
@@ -18,7 +18,12 @@ from dbos._debug import debug_workflow, parse_start_command
18
18
 
19
19
  from .._app_db import ApplicationDatabase
20
20
  from .._client import DBOSClient
21
- from .._dbos_config import _is_valid_app_name, is_valid_database_url, load_config
21
+ from .._dbos_config import (
22
+ _app_name_to_db_name,
23
+ _is_valid_app_name,
24
+ is_valid_database_url,
25
+ load_config,
26
+ )
22
27
  from .._docker_pg_helper import start_docker_pg, stop_docker_pg
23
28
  from .._schemas.system_database import SystemSchema
24
29
  from .._sys_db import SystemDatabase, reset_system_database
@@ -28,12 +33,36 @@ from ._template_init import copy_template, get_project_name, get_templates_direc
28
33
 
29
34
 
30
35
  def _get_db_url(db_url: Optional[str]) -> str:
36
+ """
37
+ Get the database URL to use for the DBOS application.
38
+ Order of precedence:
39
+ - If the `db_url` argument is provided, use it.
40
+ - If the `dbos-config.yaml` file is present, use the `database_url` from it.
41
+ - If the `DBOS_DATABASE_URL` environment variable is set, use it.
42
+
43
+ Otherwise fallback to the same default Postgres URL than the DBOS library.
44
+ Note that for the latter to be possible, a configuration file must have been found, with an application name set.
45
+ """
31
46
  database_url = db_url
47
+ _app_db_name = None
48
+ if database_url is None:
49
+ # Load from config file if present
50
+ try:
51
+ config = load_config(run_process_config=False, silent=True)
52
+ database_url = config.get("database_url")
53
+ _app_db_name = _app_name_to_db_name(config["name"])
54
+ except (FileNotFoundError, OSError):
55
+ # Config file doesn't exist, continue with other fallbacks
56
+ pass
32
57
  if database_url is None:
33
58
  database_url = os.getenv("DBOS_DATABASE_URL")
59
+ if database_url is None and _app_db_name is not None:
60
+ # Fallback on the same defaults than the DBOS library
61
+ _password = os.environ.get("PGPASSWORD", "dbos")
62
+ database_url = f"postgres://postgres:{_password}@localhost:5432/{_app_db_name}?connect_timeout=10&sslmode=prefer"
34
63
  if database_url is None:
35
64
  raise ValueError(
36
- "Missing database URL: please set it using the --db-url flag or the DBOS_DATABASE_URL environment variable."
65
+ "Missing database URL: please set it using the --db-url flag, the DBOS_DATABASE_URL environment variable, or in your dbos-config.yaml file."
37
66
  )
38
67
  assert is_valid_database_url(database_url)
39
68
  return database_url
@@ -27,7 +27,7 @@ dependencies = [
27
27
  ]
28
28
  requires-python = ">=3.9"
29
29
  readme = "README.md"
30
- version = "1.5.0a4"
30
+ version = "1.5.0a10"
31
31
 
32
32
  [project.license]
33
33
  text = "MIT"
@@ -3,7 +3,7 @@ import socket
3
3
  import threading
4
4
  import time
5
5
  import uuid
6
- from datetime import datetime
6
+ from datetime import datetime, timezone
7
7
 
8
8
  import pytest
9
9
  import requests
@@ -455,6 +455,109 @@ def test_admin_workflow_fork(dbos: DBOS, sys_db: SystemDatabase) -> None:
455
455
  assert worked, "Workflow did not finish successfully"
456
456
 
457
457
 
458
+ def test_list_workflows(dbos: DBOS) -> None:
459
+ # Create workflows for testing
460
+ @DBOS.workflow()
461
+ def test_workflow_1() -> None:
462
+ pass
463
+
464
+ @DBOS.workflow()
465
+ def test_workflow_2() -> None:
466
+ pass
467
+
468
+ # Start workflows
469
+ handle_1 = DBOS.start_workflow(test_workflow_1)
470
+ time.sleep(2) # Sleep for 2 seconds between workflows
471
+ handle_2 = DBOS.start_workflow(test_workflow_2)
472
+
473
+ # Wait for workflows to complete
474
+ handle_1.get_result()
475
+ handle_2.get_result()
476
+
477
+ # List workflows and dynamically set the filter "name"
478
+ workflows_list = DBOS.list_workflows()
479
+ assert (
480
+ len(workflows_list) >= 2
481
+ ), f"Expected at least 2 workflows, but got {len(workflows_list)}"
482
+
483
+ workflow_ids = [w.workflow_id for w in workflows_list]
484
+
485
+ # Convert created_at to ISO 8601 format
486
+ created_at_second_workflow = workflows_list[1].created_at
487
+ assert (
488
+ created_at_second_workflow is not None
489
+ ), "created_at for the second workflow is None"
490
+ start_time_filter = datetime.fromtimestamp(
491
+ created_at_second_workflow / 1000, tz=timezone.utc
492
+ ).isoformat()
493
+
494
+ # Test POST /workflows with filters
495
+ filters = {
496
+ "workflow_ids": workflow_ids,
497
+ "start_time": start_time_filter,
498
+ }
499
+ response = requests.post("http://localhost:3001/workflows", json=filters, timeout=5)
500
+ assert response.status_code == 200
501
+
502
+ workflows = response.json()
503
+ assert len(workflows) == 1, f"Expected 1 workflows, but got {len(workflows)}"
504
+ assert workflows[0]["workflow_id"] == handle_2.workflow_id, "Workflow ID mismatch"
505
+
506
+ # Test POST /workflows without filters
507
+ response = requests.post("http://localhost:3001/workflows", json={}, timeout=5)
508
+ assert response.status_code == 200
509
+
510
+ workflows = response.json()
511
+ assert len(workflows) == len(
512
+ workflows_list
513
+ ), f"Expected {len(workflows_list)} workflows, but got {len(workflows)}"
514
+ for workflow in workflows:
515
+ assert workflow["workflow_id"] in workflow_ids, "Workflow ID mismatch"
516
+
517
+
518
+ def test_get_workflow_by_id(dbos: DBOS) -> None:
519
+ # Create workflows for testing
520
+ @DBOS.workflow()
521
+ def test_workflow_1() -> None:
522
+ pass
523
+
524
+ @DBOS.workflow()
525
+ def test_workflow_2() -> None:
526
+ pass
527
+
528
+ # Start workflows
529
+ handle_1 = DBOS.start_workflow(test_workflow_1)
530
+ handle_2 = DBOS.start_workflow(test_workflow_2)
531
+
532
+ # Wait for workflows to complete
533
+ handle_1.get_result()
534
+ handle_2.get_result()
535
+
536
+ # Get the workflow ID of the second workflow
537
+ workflow_id = handle_2.workflow_id
538
+
539
+ # Test GET /workflows/:workflow_id for an existing workflow
540
+ response = requests.get(f"http://localhost:3001/workflows/{workflow_id}", timeout=5)
541
+ assert (
542
+ response.status_code == 200
543
+ ), f"Expected status code 200, but got {response.status_code}"
544
+
545
+ workflow_data = response.json()
546
+ assert workflow_data["workflow_id"] == workflow_id, "Workflow ID mismatch"
547
+ assert (
548
+ workflow_data["status"] == "SUCCESS"
549
+ ), "Expected workflow status to be SUCCESS"
550
+
551
+ # Test GET /workflows/:workflow_id for a non-existing workflow
552
+ non_existing_workflow_id = "non-existing-id"
553
+ response = requests.get(
554
+ f"http://localhost:3001/workflows/{non_existing_workflow_id}", timeout=5
555
+ )
556
+ assert (
557
+ response.status_code == 404
558
+ ), f"Expected status code 404, but got {response.status_code}"
559
+
560
+
458
561
  def test_admin_garbage_collect(dbos: DBOS) -> None:
459
562
 
460
563
  @DBOS.workflow()
@@ -493,3 +596,56 @@ def test_admin_global_timeout(dbos: DBOS) -> None:
493
596
  response.raise_for_status()
494
597
  with pytest.raises(DBOSWorkflowCancelledError):
495
598
  handle.get_result()
599
+
600
+
601
+ def test_queued_workflows_endpoint(dbos: DBOS) -> None:
602
+ """Test the /queues endpoint with various filters and scenarios."""
603
+
604
+ # Set up a queue for testing
605
+ test_queue1 = Queue("test-queue-1", concurrency=1)
606
+ test_queue2 = Queue("test-queue-2", concurrency=1)
607
+
608
+ @DBOS.workflow()
609
+ def blocking_workflow() -> str:
610
+ while True:
611
+ time.sleep(0.1)
612
+
613
+ # Enqueue some workflows to create queued entries
614
+ handles = []
615
+ handles.append(test_queue1.enqueue(blocking_workflow))
616
+ handles.append(test_queue1.enqueue(blocking_workflow))
617
+ handles.append(test_queue2.enqueue(blocking_workflow))
618
+
619
+ # Test basic queued workflows endpoint
620
+ response = requests.post("http://localhost:3001/queues", json={}, timeout=5)
621
+ assert (
622
+ response.status_code == 200
623
+ ), f"Expected status 200, got {response.status_code}"
624
+
625
+ queued_workflows = response.json()
626
+ assert isinstance(queued_workflows, list), "Response should be a list"
627
+ assert (
628
+ len(queued_workflows) == 3
629
+ ), f"Expected 3 queued workflows, got {len(queued_workflows)}"
630
+
631
+ # Test with filters
632
+ filters = {"queue_name": "test-queue-1", "limit": 1}
633
+ response = requests.post("http://localhost:3001/queues", json=filters, timeout=5)
634
+ assert response.status_code == 200
635
+
636
+ filtered_workflows = response.json()
637
+ assert isinstance(filtered_workflows, list), "Response should be a list"
638
+ assert (
639
+ len(filtered_workflows) == 1
640
+ ), f"Expected 1 workflow, got {len(filtered_workflows)}"
641
+
642
+ # Test with non-existent queue name
643
+ filters = {"queue_name": "non-existent-queue"}
644
+ response = requests.post("http://localhost:3001/queues", json=filters, timeout=5)
645
+ assert response.status_code == 200
646
+
647
+ empty_result = response.json()
648
+ assert isinstance(
649
+ empty_result, list
650
+ ), "Response should be a list even for non-existent queue"
651
+ assert len(empty_result) == 0, "Expected no workflows for non-existent queue"
@@ -10,7 +10,7 @@ from sqlalchemy import event
10
10
  from sqlalchemy.exc import OperationalError
11
11
 
12
12
  # Public API
13
- from dbos import DBOS
13
+ from dbos import DBOS, DBOSClient
14
14
  from dbos._dbos_config import (
15
15
  ConfigFile,
16
16
  DBOSConfig,
@@ -598,15 +598,6 @@ def test_process_config_with_wrong_db_url():
598
598
  process_config(data=config)
599
599
  assert "Username must be specified in the connection URL" in str(exc_info.value)
600
600
 
601
- # Missing password
602
- config: ConfigFile = {
603
- "name": "some-app",
604
- "database_url": "postgres://user:@h:1234/dbname",
605
- }
606
- with pytest.raises(DBOSInitializationError) as exc_info:
607
- process_config(data=config)
608
- assert "Password must be specified in the connection URL" in str(exc_info.value)
609
-
610
601
  # Missing host
611
602
  config: ConfigFile = {
612
603
  "name": "some-app",
@@ -628,6 +619,26 @@ def test_process_config_with_wrong_db_url():
628
619
  )
629
620
 
630
621
 
622
+ def test_database_url_no_password():
623
+ """Test that the database URL can be provided without a password."""
624
+ expected_url = "postgresql://postgres@localhost:5432/dbostestpy?sslmode=disable"
625
+ config: DBOSConfig = {
626
+ "name": "some-app",
627
+ "database_url": expected_url,
628
+ }
629
+ processed_config = translate_dbos_config_to_config_file(config)
630
+ assert processed_config["name"] == "some-app"
631
+ assert processed_config["database_url"] == expected_url
632
+
633
+ # Make sure we can use it to construct a DBOS Client and connect to the database without a password
634
+ client = DBOSClient(expected_url)
635
+ try:
636
+ res = client.list_queued_workflows()
637
+ assert res is not None
638
+ finally:
639
+ client.destroy()
640
+
641
+
631
642
  ####################
632
643
  # TRANSLATE DBOSConfig to ConfigFile
633
644
  ####################
@@ -332,6 +332,11 @@ def test_step_retries(dbos: DBOS) -> None:
332
332
  failing_step()
333
333
  assert error_message in str(excinfo.value)
334
334
  assert step_counter == max_attempts
335
+ assert len(excinfo.value.errors) == max_attempts
336
+ for error in excinfo.value.errors:
337
+ assert isinstance(error, Exception)
338
+ assert error
339
+ assert "fail" in str(error)
335
340
 
336
341
  # Test calling the workflow
337
342
  step_counter = 0
@@ -448,10 +453,11 @@ def test_error_serialization() -> None:
448
453
  # Verify that each exception that can be thrown in a workflow
449
454
  # is serializable and deserializable
450
455
  # DBOSMaxStepRetriesExceeded
451
- e: Exception = DBOSMaxStepRetriesExceeded("step", 1)
456
+ e: Exception = DBOSMaxStepRetriesExceeded("step", 1, [Exception()])
452
457
  d = deserialize_exception(serialize_exception(e))
453
458
  assert isinstance(d, DBOSMaxStepRetriesExceeded)
454
459
  assert str(d) == str(e)
460
+ assert isinstance(d.errors[0], Exception)
455
461
  # DBOSNotAuthorizedError
456
462
  e = DBOSNotAuthorizedError("no")
457
463
  d = deserialize_exception(serialize_exception(e))
@@ -56,7 +56,7 @@ def test_immediate_retry() -> None:
56
56
  raise Exception("Error")
57
57
 
58
58
  o1 = Outcome[int].make(raiser)
59
- o2 = o1.retry(3, lambda i, e: 0.1, lambda i: ExceededRetries())
59
+ o2 = o1.retry(3, lambda i, e: 0.1, lambda i, e: ExceededRetries())
60
60
 
61
61
  assert isinstance(o2, Immediate)
62
62
  with pytest.raises(ExceededRetries):
@@ -105,7 +105,7 @@ async def test_pending_retry() -> None:
105
105
  raise Exception("Error")
106
106
 
107
107
  o1 = Outcome[int].make(raiser)
108
- o2 = o1.retry(3, lambda i, e: 0.1, lambda i: ExceededRetries())
108
+ o2 = o1.retry(3, lambda i, e: 0.1, lambda i, e: ExceededRetries())
109
109
 
110
110
  assert isinstance(o2, Pending)
111
111
  with pytest.raises(ExceededRetries):
@@ -7,11 +7,12 @@ import subprocess
7
7
  import threading
8
8
  import time
9
9
  import uuid
10
- from typing import List
10
+ from typing import Any, List
11
11
  from urllib.parse import quote
12
12
 
13
13
  import pytest
14
14
  import sqlalchemy as sa
15
+ from pydantic import BaseModel
15
16
 
16
17
  from dbos import (
17
18
  DBOS,
@@ -1462,3 +1463,55 @@ def test_queue_executor_id(dbos: DBOS) -> None:
1462
1463
  handle = queue.enqueue(example_workflow)
1463
1464
  assert handle.get_result() == wfid
1464
1465
  assert handle.get_status().executor_id == original_executor_id
1466
+
1467
+
1468
+ # Non-basic types must be declared in an importable scope (so not inside a function)
1469
+ # to be serializable and deserializable
1470
+ class InnerType(BaseModel):
1471
+ one: str
1472
+ two: int
1473
+
1474
+
1475
+ class OuterType(BaseModel):
1476
+ inner: InnerType
1477
+
1478
+
1479
+ def test_complex_type(dbos: DBOS) -> None:
1480
+ queue = Queue("test_queue")
1481
+
1482
+ @DBOS.workflow()
1483
+ def workflow(input: OuterType) -> OuterType:
1484
+ return input
1485
+
1486
+ # Verify a workflow with non-basic inputs and outputs can be enqueued
1487
+ inner = InnerType(one="one", two=2)
1488
+ outer = OuterType(inner=inner)
1489
+
1490
+ handle = queue.enqueue(workflow, outer)
1491
+ result = handle.get_result()
1492
+
1493
+ def check(result: Any) -> None:
1494
+ assert isinstance(result, OuterType)
1495
+ assert isinstance(result.inner, InnerType)
1496
+ assert result.inner.one == outer.inner.one
1497
+ assert result.inner.two == outer.inner.two
1498
+
1499
+ check(result)
1500
+
1501
+ # Verify a workflow with non-basic inputs and outputs can be recovered
1502
+ start_event = threading.Event()
1503
+ event = threading.Event()
1504
+
1505
+ @DBOS.workflow()
1506
+ def blocked_workflow(input: OuterType) -> OuterType:
1507
+ start_event.set()
1508
+ event.wait()
1509
+ return input
1510
+
1511
+ handle = queue.enqueue(blocked_workflow, outer)
1512
+
1513
+ start_event.wait()
1514
+ recovery_handle = DBOS._recover_pending_workflows()[0]
1515
+ event.set()
1516
+ check(handle.get_result())
1517
+ check(recovery_handle.get_result())
@@ -7,12 +7,16 @@ from opentelemetry.sdk import trace as tracesdk
7
7
  from opentelemetry.sdk.trace.export import SimpleSpanProcessor
8
8
  from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
9
9
 
10
- from dbos import DBOS
10
+ from dbos import DBOS, DBOSConfig
11
11
  from dbos._tracer import dbos_tracer
12
12
  from dbos._utils import GlobalParams
13
13
 
14
14
 
15
- def test_spans(dbos: DBOS) -> None:
15
+ def test_spans(config: DBOSConfig) -> None:
16
+ DBOS.destroy(destroy_registry=True)
17
+ config["otlp_attributes"] = {"foo": "bar"}
18
+ DBOS(config=config)
19
+ DBOS.launch()
16
20
 
17
21
  @DBOS.workflow()
18
22
  def test_workflow() -> None:
@@ -44,6 +48,7 @@ def test_spans(dbos: DBOS) -> None:
44
48
  assert span.attributes["applicationVersion"] == GlobalParams.app_version
45
49
  assert span.attributes["executorID"] == GlobalParams.executor_id
46
50
  assert span.context is not None
51
+ assert span.attributes["foo"] == "bar"
47
52
 
48
53
  assert spans[0].name == test_step.__name__
49
54
  assert spans[1].name == "a new span"
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
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes