dbos 1.4.0a1__py3-none-any.whl → 1.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
dbos/_admin_server.py CHANGED
@@ -5,7 +5,9 @@ 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
+
10
+ from dbos._workflow_commands import garbage_collect, global_timeout
9
11
 
10
12
  from ._context import SetWorkflowID
11
13
  from ._error import DBOSException
@@ -20,6 +22,10 @@ _health_check_path = "/dbos-healthz"
20
22
  _workflow_recovery_path = "/dbos-workflow-recovery"
21
23
  _deactivate_path = "/deactivate"
22
24
  _workflow_queues_metadata_path = "/dbos-workflow-queues-metadata"
25
+ _garbage_collect_path = "/dbos-garbage-collect"
26
+ _global_timeout_path = "/dbos-global-timeout"
27
+ _queued_workflows_path = "/queues"
28
+ _workflows_path = "/workflows"
23
29
  # /workflows/:workflow_id/cancel
24
30
  # /workflows/:workflow_id/resume
25
31
  # /workflows/:workflow_id/restart
@@ -100,10 +106,24 @@ class AdminRequestHandler(BaseHTTPRequestHandler):
100
106
  steps_match = re.match(
101
107
  r"^/workflows/(?P<workflow_id>[^/]+)/steps$", self.path
102
108
  )
109
+ workflow_match = re.match(r"^/workflows/(?P<workflow_id>[^/]+)$", self.path)
103
110
 
104
111
  if steps_match:
105
112
  workflow_id = steps_match.group("workflow_id")
106
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)
107
127
  else:
108
128
  self.send_response(404)
109
129
  self._end_headers()
@@ -122,8 +142,50 @@ class AdminRequestHandler(BaseHTTPRequestHandler):
122
142
  self.send_response(200)
123
143
  self._end_headers()
124
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
+ )
171
+ elif self.path == _garbage_collect_path:
172
+ inputs = json.loads(post_data.decode("utf-8"))
173
+ cutoff_epoch_timestamp_ms = inputs.get("cutoff_epoch_timestamp_ms", None)
174
+ rows_threshold = inputs.get("rows_threshold", None)
175
+ garbage_collect(
176
+ self.dbos,
177
+ cutoff_epoch_timestamp_ms=cutoff_epoch_timestamp_ms,
178
+ rows_threshold=rows_threshold,
179
+ )
180
+ self.send_response(204)
181
+ self._end_headers()
182
+ elif self.path == _global_timeout_path:
183
+ inputs = json.loads(post_data.decode("utf-8"))
184
+ cutoff_epoch_timestamp_ms = inputs.get("cutoff_epoch_timestamp_ms", None)
185
+ global_timeout(self.dbos, cutoff_epoch_timestamp_ms)
186
+ self.send_response(204)
187
+ self._end_headers()
125
188
  else:
126
-
127
189
  restart_match = re.match(
128
190
  r"^/workflows/(?P<workflow_id>[^/]+)/restart$", self.path
129
191
  )
@@ -262,6 +324,50 @@ class AdminRequestHandler(BaseHTTPRequestHandler):
262
324
  self._end_headers()
263
325
  self.wfile.write(json_steps)
264
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
+
265
371
 
266
372
  # Be consistent with DBOS-TS response.
267
373
  class PerfUtilization(TypedDict):
dbos/_app_db.py CHANGED
@@ -256,3 +256,21 @@ class ApplicationDatabase:
256
256
  )
257
257
 
258
258
  conn.execute(insert_stmt)
259
+
260
+ def garbage_collect(
261
+ self, cutoff_epoch_timestamp_ms: int, pending_workflow_ids: list[str]
262
+ ) -> None:
263
+ with self.engine.begin() as c:
264
+ delete_query = sa.delete(ApplicationSchema.transaction_outputs).where(
265
+ ApplicationSchema.transaction_outputs.c.created_at
266
+ < cutoff_epoch_timestamp_ms
267
+ )
268
+
269
+ if len(pending_workflow_ids) > 0:
270
+ delete_query = delete_query.where(
271
+ ~ApplicationSchema.transaction_outputs.c.workflow_uuid.in_(
272
+ pending_workflow_ids
273
+ )
274
+ )
275
+
276
+ c.execute(delete_query)
@@ -13,7 +13,9 @@ from websockets.sync.connection import Connection
13
13
  from dbos._context import SetWorkflowID
14
14
  from dbos._utils import GlobalParams
15
15
  from dbos._workflow_commands import (
16
+ garbage_collect,
16
17
  get_workflow,
18
+ global_timeout,
17
19
  list_queued_workflows,
18
20
  list_workflow_steps,
19
21
  list_workflows,
@@ -356,6 +358,41 @@ class ConductorWebsocket(threading.Thread):
356
358
  error_message=error_message,
357
359
  )
358
360
  websocket.send(list_steps_response.to_json())
361
+ elif msg_type == p.MessageType.RETENTION:
362
+ retention_message = p.RetentionRequest.from_json(message)
363
+ success = True
364
+ try:
365
+ garbage_collect(
366
+ self.dbos,
367
+ cutoff_epoch_timestamp_ms=retention_message.body[
368
+ "gc_cutoff_epoch_ms"
369
+ ],
370
+ rows_threshold=retention_message.body[
371
+ "gc_rows_threshold"
372
+ ],
373
+ )
374
+ if (
375
+ retention_message.body["timeout_cutoff_epoch_ms"]
376
+ is not None
377
+ ):
378
+ global_timeout(
379
+ self.dbos,
380
+ retention_message.body[
381
+ "timeout_cutoff_epoch_ms"
382
+ ],
383
+ )
384
+ except Exception as e:
385
+ error_message = f"Exception encountered during enforcing retention policy: {traceback.format_exc()}"
386
+ self.dbos.logger.error(error_message)
387
+ success = False
388
+
389
+ retention_response = p.RetentionResponse(
390
+ type=p.MessageType.RETENTION,
391
+ request_id=base_message.request_id,
392
+ success=success,
393
+ error_message=error_message,
394
+ )
395
+ websocket.send(retention_response.to_json())
359
396
  else:
360
397
  self.dbos.logger.warning(
361
398
  f"Unexpected message type: {msg_type}"
@@ -18,6 +18,7 @@ class MessageType(str, Enum):
18
18
  EXIST_PENDING_WORKFLOWS = "exist_pending_workflows"
19
19
  LIST_STEPS = "list_steps"
20
20
  FORK_WORKFLOW = "fork_workflow"
21
+ RETENTION = "retention"
21
22
 
22
23
 
23
24
  T = TypeVar("T", bound="BaseMessage")
@@ -280,3 +281,20 @@ class ForkWorkflowRequest(BaseMessage):
280
281
  class ForkWorkflowResponse(BaseMessage):
281
282
  new_workflow_id: Optional[str]
282
283
  error_message: Optional[str] = None
284
+
285
+
286
+ class RetentionBody(TypedDict):
287
+ gc_cutoff_epoch_ms: Optional[int]
288
+ gc_rows_threshold: Optional[int]
289
+ timeout_cutoff_epoch_ms: Optional[int]
290
+
291
+
292
+ @dataclass
293
+ class RetentionRequest(BaseMessage):
294
+ body: RetentionBody
295
+
296
+
297
+ @dataclass
298
+ class RetentionResponse(BaseMessage):
299
+ success: bool
300
+ error_message: Optional[str] = None
dbos/_core.py CHANGED
@@ -404,9 +404,9 @@ def _execute_workflow_wthread(
404
404
  return dbos._background_event_loop.submit_coroutine(
405
405
  cast(Pending[R], result)()
406
406
  )
407
- except Exception:
407
+ except Exception as e:
408
408
  dbos.logger.error(
409
- f"Exception encountered in asynchronous workflow: {traceback.format_exc()}"
409
+ f"Exception encountered in asynchronous workflow:", exc_info=e
410
410
  )
411
411
  raise
412
412
 
@@ -430,9 +430,9 @@ async def _execute_workflow_async(
430
430
  _get_wf_invoke_func(dbos, status)
431
431
  )
432
432
  return await result()
433
- except Exception:
433
+ except Exception as e:
434
434
  dbos.logger.error(
435
- f"Exception encountered in asynchronous workflow: {traceback.format_exc()}"
435
+ f"Exception encountered in asynchronous workflow:", exc_info=e
436
436
  )
437
437
  raise
438
438
 
@@ -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 = (
dbos/_dbos.py CHANGED
@@ -521,8 +521,8 @@ class DBOS:
521
521
  handler.flush()
522
522
  add_otlp_to_all_loggers()
523
523
  add_transformer_to_all_loggers()
524
- except Exception:
525
- dbos_logger.error(f"DBOS failed to launch: {traceback.format_exc()}")
524
+ except Exception as e:
525
+ dbos_logger.error(f"DBOS failed to launch:", exc_info=e)
526
526
  raise
527
527
 
528
528
  @classmethod
dbos/_dbos_config.py CHANGED
@@ -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
  ]
dbos/_debug.py CHANGED
@@ -15,11 +15,11 @@ class PythonModule:
15
15
 
16
16
 
17
17
  def debug_workflow(workflow_id: str, entrypoint: Union[str, PythonModule]) -> None:
18
- # include the current directory (represented by empty string) in the search path
19
- # if it not already included
20
- if "" not in sys.path:
21
- sys.path.insert(0, "")
22
18
  if isinstance(entrypoint, str):
19
+ # ensure the entrypoint parent directory is in sys.path
20
+ parent = str(Path(entrypoint).parent)
21
+ if parent not in sys.path:
22
+ sys.path.insert(0, parent)
23
23
  runpy.run_path(entrypoint)
24
24
  elif isinstance(entrypoint, PythonModule):
25
25
  runpy.run_module(entrypoint.module_name)
dbos/_error.py CHANGED
@@ -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):
dbos/_logger.py CHANGED
@@ -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
 
dbos/_outcome.py CHANGED
@@ -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](
dbos/_queue.py CHANGED
@@ -1,5 +1,4 @@
1
1
  import threading
2
- import traceback
3
2
  from typing import TYPE_CHECKING, Any, Callable, Coroutine, Optional, TypedDict
4
3
 
5
4
  from psycopg import errors
dbos/_recovery.py CHANGED
@@ -1,7 +1,5 @@
1
- import os
2
1
  import threading
3
2
  import time
4
- import traceback
5
3
  from typing import TYPE_CHECKING, Any, List
6
4
 
7
5
  from dbos._utils import GlobalParams
@@ -39,9 +37,9 @@ def startup_recovery_thread(
39
37
  time.sleep(1)
40
38
  except Exception as e:
41
39
  dbos.logger.error(
42
- f"Exception encountered when recovering workflows: {traceback.format_exc()}"
40
+ f"Exception encountered when recovering workflows:", exc_info=e
43
41
  )
44
- raise e
42
+ raise
45
43
 
46
44
 
47
45
  def recover_pending_workflows(
@@ -59,9 +57,9 @@ def recover_pending_workflows(
59
57
  workflow_handles.append(handle)
60
58
  except Exception as e:
61
59
  dbos.logger.error(
62
- f"Exception encountered when recovering workflows: {traceback.format_exc()}"
60
+ f"Exception encountered when recovering workflows:", exc_info=e
63
61
  )
64
- raise e
62
+ raise
65
63
  dbos.logger.info(
66
64
  f"Recovering {len(pending_workflows)} workflows for executor {executor_id} from version {GlobalParams.app_version}"
67
65
  )
dbos/_sys_db.py CHANGED
@@ -1852,6 +1852,62 @@ class SystemDatabase:
1852
1852
  dbos_logger.error(f"Error connecting to the DBOS system database: {e}")
1853
1853
  raise
1854
1854
 
1855
+ def garbage_collect(
1856
+ self, cutoff_epoch_timestamp_ms: Optional[int], rows_threshold: Optional[int]
1857
+ ) -> Optional[tuple[int, list[str]]]:
1858
+ if rows_threshold is not None:
1859
+ with self.engine.begin() as c:
1860
+ # Get the created_at timestamp of the rows_threshold newest row
1861
+ result = c.execute(
1862
+ sa.select(SystemSchema.workflow_status.c.created_at)
1863
+ .order_by(SystemSchema.workflow_status.c.created_at.desc())
1864
+ .limit(1)
1865
+ .offset(rows_threshold - 1)
1866
+ ).fetchone()
1867
+
1868
+ if result is not None:
1869
+ rows_based_cutoff = result[0]
1870
+ # Use the more restrictive cutoff (higher timestamp = more recent = more deletion)
1871
+ if (
1872
+ cutoff_epoch_timestamp_ms is None
1873
+ or rows_based_cutoff > cutoff_epoch_timestamp_ms
1874
+ ):
1875
+ cutoff_epoch_timestamp_ms = rows_based_cutoff
1876
+
1877
+ if cutoff_epoch_timestamp_ms is None:
1878
+ return None
1879
+
1880
+ with self.engine.begin() as c:
1881
+ # Delete all workflows older than cutoff that are NOT PENDING or ENQUEUED
1882
+ c.execute(
1883
+ sa.delete(SystemSchema.workflow_status)
1884
+ .where(
1885
+ SystemSchema.workflow_status.c.created_at
1886
+ < cutoff_epoch_timestamp_ms
1887
+ )
1888
+ .where(
1889
+ ~SystemSchema.workflow_status.c.status.in_(
1890
+ [
1891
+ WorkflowStatusString.PENDING.value,
1892
+ WorkflowStatusString.ENQUEUED.value,
1893
+ ]
1894
+ )
1895
+ )
1896
+ )
1897
+
1898
+ # Then, get the IDs of all remaining old workflows
1899
+ pending_enqueued_result = c.execute(
1900
+ sa.select(SystemSchema.workflow_status.c.workflow_uuid).where(
1901
+ SystemSchema.workflow_status.c.created_at
1902
+ < cutoff_epoch_timestamp_ms
1903
+ )
1904
+ ).fetchall()
1905
+
1906
+ # Return the final cutoff and workflow IDs
1907
+ return cutoff_epoch_timestamp_ms, [
1908
+ row[0] for row in pending_enqueued_result
1909
+ ]
1910
+
1855
1911
 
1856
1912
  def reset_system_database(postgres_db_url: sa.URL, sysdb_name: str) -> None:
1857
1913
  try:
dbos/_tracer.py CHANGED
@@ -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:
@@ -1,8 +1,9 @@
1
+ import time
1
2
  import uuid
2
- from typing import List, Optional
3
+ from datetime import datetime
4
+ from typing import TYPE_CHECKING, List, Optional
3
5
 
4
6
  from dbos._context import get_local_dbos_context
5
- from dbos._error import DBOSException
6
7
 
7
8
  from ._app_db import ApplicationDatabase
8
9
  from ._sys_db import (
@@ -11,8 +12,12 @@ from ._sys_db import (
11
12
  StepInfo,
12
13
  SystemDatabase,
13
14
  WorkflowStatus,
15
+ WorkflowStatusString,
14
16
  )
15
17
 
18
+ if TYPE_CHECKING:
19
+ from ._dbos import DBOS
20
+
16
21
 
17
22
  def list_workflows(
18
23
  sys_db: SystemDatabase,
@@ -118,3 +123,31 @@ def fork_workflow(
118
123
  application_version=application_version,
119
124
  )
120
125
  return forked_workflow_id
126
+
127
+
128
+ def garbage_collect(
129
+ dbos: "DBOS",
130
+ cutoff_epoch_timestamp_ms: Optional[int],
131
+ rows_threshold: Optional[int],
132
+ ) -> None:
133
+ if cutoff_epoch_timestamp_ms is None and rows_threshold is None:
134
+ return
135
+ result = dbos._sys_db.garbage_collect(
136
+ cutoff_epoch_timestamp_ms=cutoff_epoch_timestamp_ms,
137
+ rows_threshold=rows_threshold,
138
+ )
139
+ if result is not None:
140
+ cutoff_epoch_timestamp_ms, pending_workflow_ids = result
141
+ dbos._app_db.garbage_collect(cutoff_epoch_timestamp_ms, pending_workflow_ids)
142
+
143
+
144
+ def global_timeout(dbos: "DBOS", cutoff_epoch_timestamp_ms: int) -> None:
145
+ cutoff_iso = datetime.fromtimestamp(cutoff_epoch_timestamp_ms / 1000).isoformat()
146
+ for workflow in dbos.list_workflows(
147
+ status=WorkflowStatusString.PENDING.value, end_time=cutoff_iso
148
+ ):
149
+ dbos.cancel_workflow(workflow.workflow_id)
150
+ for workflow in dbos.list_workflows(
151
+ status=WorkflowStatusString.ENQUEUED.value, end_time=cutoff_iso
152
+ ):
153
+ dbos.cancel_workflow(workflow.workflow_id)
dbos/cli/cli.py CHANGED
@@ -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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: dbos
3
- Version: 1.4.0a1
3
+ Version: 1.5.0
4
4
  Summary: Ultra-lightweight durable execution in Python
5
5
  Author-Email: "DBOS, Inc." <contact@dbos.dev>
6
6
  License: MIT
@@ -1,29 +1,29 @@
1
- dbos-1.4.0a1.dist-info/METADATA,sha256=Snqbd6UknvRQFKSZJUL9tBkcEvfCGnRNXGH--L4nGPc,13267
2
- dbos-1.4.0a1.dist-info/WHEEL,sha256=tSfRZzRHthuv7vxpI4aehrdN9scLjk-dCJkPLzkHxGg,90
3
- dbos-1.4.0a1.dist-info/entry_points.txt,sha256=_QOQ3tVfEjtjBlr1jS4sHqHya9lI2aIEIWkz8dqYp14,58
4
- dbos-1.4.0a1.dist-info/licenses/LICENSE,sha256=VGZit_a5-kdw9WT6fY5jxAWVwGQzgLFyPWrcVVUhVNU,1067
1
+ dbos-1.5.0.dist-info/METADATA,sha256=Dn02XFDJEtFjCEZYBOqsdTHrg-qMgQ3Rd58d6HS7JRk,13265
2
+ dbos-1.5.0.dist-info/WHEEL,sha256=tSfRZzRHthuv7vxpI4aehrdN9scLjk-dCJkPLzkHxGg,90
3
+ dbos-1.5.0.dist-info/entry_points.txt,sha256=_QOQ3tVfEjtjBlr1jS4sHqHya9lI2aIEIWkz8dqYp14,58
4
+ dbos-1.5.0.dist-info/licenses/LICENSE,sha256=VGZit_a5-kdw9WT6fY5jxAWVwGQzgLFyPWrcVVUhVNU,1067
5
5
  dbos/__init__.py,sha256=NssPCubaBxdiKarOWa-wViz1hdJSkmBGcpLX_gQ4NeA,891
6
6
  dbos/__main__.py,sha256=G7Exn-MhGrVJVDbgNlpzhfh8WMX_72t3_oJaFT9Lmt8,653
7
- dbos/_admin_server.py,sha256=TWXi4drrzKFpKkUmEJpJkQBZxAtOalnhtYicEn2nDK0,10618
8
- dbos/_app_db.py,sha256=0PKqpxJ3EbIaak3Wl0lNl3hXvhBfz4EEHaCw1bUOvIM,9937
7
+ dbos/_admin_server.py,sha256=l46ZX4NpvBP9W8cl9gE7OqMNwUCevLMt2VztM7crBv0,15465
8
+ dbos/_app_db.py,sha256=htblDPfqrpb_uZoFcvaud7cgQ-PDyn6Bn-cBidxdCTA,10603
9
9
  dbos/_classproperty.py,sha256=f0X-_BySzn3yFDRKB2JpCbLYQ9tLwt1XftfshvY7CBs,626
10
10
  dbos/_client.py,sha256=cQxw1Nbh_vKZ03lONt0EmUhwXBk3B3NczZrmfXXeefY,14667
11
- dbos/_conductor/conductor.py,sha256=o0IaZjwnZ2TOyHeP2H4iSX6UnXLXQ4uODvWAKD9hHMs,21703
12
- dbos/_conductor/protocol.py,sha256=wgOFZxmS81bv0WCB9dAyg0s6QzldpzVKQDoSPeaX0Ws,6967
11
+ dbos/_conductor/conductor.py,sha256=y_T-8kEHwKWt6W8LtcFMctB_6EvYFWsuGLxiFuuKKBU,23702
12
+ dbos/_conductor/protocol.py,sha256=DOTprPSd7oHDcvwWSyZpnlPds_JfILtcKzHZa-qBsF4,7330
13
13
  dbos/_context.py,sha256=5ajoWAmToAfzzmMLylnJZoL4Ny9rBwZWuG05sXadMIA,24798
14
- dbos/_core.py,sha256=hvHKi31-3LG5yfWa-KhsnoFrXsV_eT-GeKIZFT4chx8,48533
14
+ dbos/_core.py,sha256=eFRhljdi8vjpmBEzu-wt_feC7-uDmKqbiybz92KCRwI,48523
15
15
  dbos/_croniter.py,sha256=XHAyUyibs_59sJQfSNWkP7rqQY6_XrlfuuCxk4jYqek,47559
16
- dbos/_dbos.py,sha256=GVx3NY59tKWW6nlAtH2PvX4Ne_eOHvY012MtXVK_FQA,47265
17
- dbos/_dbos_config.py,sha256=2CC1YR8lP9W-_NsMUMnTnW-v-70KN4XkbJEeNJ78RlQ,20373
18
- dbos/_debug.py,sha256=MNlQVZ6TscGCRQeEEL0VE8Uignvr6dPeDDDefS3xgIE,1823
16
+ dbos/_dbos.py,sha256=HHMo-PLUa6m5jFgdA-YYAwaTx2kyA1Te1VslAyd0hIw,47257
17
+ dbos/_dbos_config.py,sha256=JUG4V1rrP0p1AYESgih4ea80qOH_13UsgoIIm8X84pw,20562
18
+ dbos/_debug.py,sha256=99j2SChWmCPAlZoDmjsJGe77tpU2LEa8E2TtLAnnh7o,1831
19
19
  dbos/_docker_pg_helper.py,sha256=tLJXWqZ4S-ExcaPnxg_i6cVxL6ZxrYlZjaGsklY-s2I,6115
20
- dbos/_error.py,sha256=q0OQJZTbR8FFHV9hEpAGpz9oWBT5L509zUhmyff7FJw,8500
20
+ dbos/_error.py,sha256=nS7KuXJHhuNXZRErxdEUGT38Hb0VPyxNwSyADiVpHcE,8581
21
21
  dbos/_event_loop.py,sha256=cvaFN9-II3MsHEOq8QoICc_8qSKrjikMlLfuhC3Y8Dk,2923
22
22
  dbos/_fastapi.py,sha256=T7YlVY77ASqyTqq0aAPclZ9YzlXdGTT0lEYSwSgt1EE,3151
23
23
  dbos/_flask.py,sha256=Npnakt-a3W5OykONFRkDRnumaDhTQmA0NPdUCGRYKXE,1652
24
24
  dbos/_kafka.py,sha256=pz0xZ9F3X9Ky1k-VSbeF3tfPhP3UPr3lUUhUfE41__U,4198
25
25
  dbos/_kafka_message.py,sha256=NYvOXNG3Qn7bghn1pv3fg4Pbs86ILZGcK4IB-MLUNu0,409
26
- dbos/_logger.py,sha256=Yde-w0MZt9xT2NBmzcKY9G3mn6OJ5aVJuC5Elm3p4p4,4390
26
+ dbos/_logger.py,sha256=Dp6bHZKUtcm5gWwYHj_HA5Wj5OMuJGUrpl2g2i4xDZg,4620
27
27
  dbos/_migrations/env.py,sha256=38SIGVbmn_VV2x2u1aHLcPOoWgZ84eCymf3g_NljmbU,1626
28
28
  dbos/_migrations/script.py.mako,sha256=MEqL-2qATlST9TAOeYgscMn1uy6HUS9NFvDgl93dMj8,635
29
29
  dbos/_migrations/versions/04ca4f231047_workflow_queues_executor_id.py,sha256=ICLPl8CN9tQXMsLDsAj8z1TsL831-Z3F8jSBvrR-wyw,736
@@ -39,9 +39,9 @@ dbos/_migrations/versions/d76646551a6c_workflow_queue.py,sha256=G942nophZ2uC2vc4
39
39
  dbos/_migrations/versions/d994145b47b6_consolidate_inputs.py,sha256=_J0jP247fuo66fzOmLlKFO9FJ_CRBXlqa2lnLrcXugQ,672
40
40
  dbos/_migrations/versions/eab0cc1d9a14_job_queue.py,sha256=uvhFOtqbBreCePhAxZfIT0qCAI7BiZTou9wt6QnbY7c,1412
41
41
  dbos/_migrations/versions/f4b9b32ba814_functionname_childid_op_outputs.py,sha256=m90Lc5YH0ZISSq1MyxND6oq3RZrZKrIqEsZtwJ1jWxA,1049
42
- dbos/_outcome.py,sha256=EXxBg4jXCVJsByDQ1VOCIedmbeq_03S6d-p1vqQrLFU,6810
43
- dbos/_queue.py,sha256=Csp6wrLg6TyZJMeAWkbQAiviIDiucbP-qrBaqp2WJwY,4097
44
- dbos/_recovery.py,sha256=jVMexjfCCNopzyn8gVQzJCmGJaP9G3C1EFaoCQ_Nh7g,2564
42
+ dbos/_outcome.py,sha256=Kz3aL7517q9UEFTx3Cq9zzztjWyWVOx_08fZyHo9dvg,7035
43
+ dbos/_queue.py,sha256=Kq7aldTDLRF7cZtkXmsCy6wV2PR24enkhghEG25NtaU,4080
44
+ dbos/_recovery.py,sha256=TBNjkmSEqBU-g5YXExsLJ9XoCe4iekqtREsskXZECEg,2507
45
45
  dbos/_registrations.py,sha256=CZt1ElqDjCT7hz6iyT-1av76Yu-iuwu_c9lozO87wvM,7303
46
46
  dbos/_roles.py,sha256=iOsgmIAf1XVzxs3gYWdGRe1B880YfOw5fpU7Jwx8_A8,2271
47
47
  dbos/_scheduler.py,sha256=SR1oRZRcVzYsj-JauV2LA8JtwTkt8mru7qf6H1AzQ1U,2027
@@ -49,7 +49,7 @@ dbos/_schemas/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
49
49
  dbos/_schemas/application_database.py,sha256=SypAS9l9EsaBHFn9FR8jmnqt01M74d9AF1AMa4m2hhI,1040
50
50
  dbos/_schemas/system_database.py,sha256=rbFKggONdvvbb45InvGz0TM6a7c-Ux9dcaL-h_7Z7pU,4438
51
51
  dbos/_serialization.py,sha256=bWuwhXSQcGmiazvhJHA5gwhrRWxtmFmcCFQSDJnqqkU,3666
52
- dbos/_sys_db.py,sha256=67z_K0aKH8M_oRs9c13zhp6skpT-sLAw8nYRBa3JM5w,77844
52
+ dbos/_sys_db.py,sha256=now889o6Mlmcdopp8xF5_0LAE67KeVH9Vm-4svIqo5s,80170
53
53
  dbos/_templates/dbos-db-starter/README.md,sha256=GhxhBj42wjTt1fWEtwNriHbJuKb66Vzu89G4pxNHw2g,930
54
54
  dbos/_templates/dbos-db-starter/__package/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
55
55
  dbos/_templates/dbos-db-starter/__package/main.py.dbos,sha256=aQnBPSSQpkB8ERfhf7gB7P9tsU6OPKhZscfeh0yiaD8,2702
@@ -60,13 +60,13 @@ dbos/_templates/dbos-db-starter/migrations/env.py.dbos,sha256=IBB_gz9RjC20HPfOTG
60
60
  dbos/_templates/dbos-db-starter/migrations/script.py.mako,sha256=MEqL-2qATlST9TAOeYgscMn1uy6HUS9NFvDgl93dMj8,635
61
61
  dbos/_templates/dbos-db-starter/migrations/versions/2024_07_31_180642_init.py,sha256=MpS7LGaJS0CpvsjhfDkp9EJqvMvVCjRPfUp4c0aE2ys,941
62
62
  dbos/_templates/dbos-db-starter/start_postgres_docker.py,sha256=lQVLlYO5YkhGPEgPqwGc7Y8uDKse9HsWv5fynJEFJHM,1681
63
- dbos/_tracer.py,sha256=yN6GRDKu_1p-EqtQLNarMocPfga2ZuqpzStzzSPYhzo,2732
63
+ dbos/_tracer.py,sha256=RnlcaOJEx_58hr2J9L9g6E7gjAHAeEtEGugJZmCwNfQ,2963
64
64
  dbos/_utils.py,sha256=uywq1QrjMwy17btjxW4bES49povlQwYwYbvKwMT6C2U,1575
65
- dbos/_workflow_commands.py,sha256=UCpHWvCEXjVZtf5FNanFvtJpgUJDSI1EFBqQP0x_2A0,3346
65
+ dbos/_workflow_commands.py,sha256=Fi-sQxQvFkDkMlCv7EyRJIWxqk3fG6DGlgvvwkjWbS4,4485
66
66
  dbos/cli/_github_init.py,sha256=Y_bDF9gfO2jB1id4FV5h1oIxEJRWyqVjhb7bNEa5nQ0,3224
67
67
  dbos/cli/_template_init.py,sha256=7JBcpMqP1r2mfCnvWatu33z8ctEGHJarlZYKgB83cXE,2972
68
- dbos/cli/cli.py,sha256=EemOMqNpzSU2BQhAxV_e59pBRITDLwt49HF6W3uWBZg,20775
68
+ dbos/cli/cli.py,sha256=IcfaX4rrSrk6f24S2jrlR33snYMyNyEIx_lNQtuVr2E,22081
69
69
  dbos/dbos-config.schema.json,sha256=CjaspeYmOkx6Ip_pcxtmfXJTn_YGdSx_0pcPBF7KZmo,6060
70
70
  dbos/py.typed,sha256=QfzXT1Ktfk3Rj84akygc7_42z0lRpCq0Ilh8OXI6Zas,44
71
71
  version/__init__.py,sha256=L4sNxecRuqdtSFdpUGX3TtBi9KL3k7YsZVIvv-fv9-A,1678
72
- dbos-1.4.0a1.dist-info/RECORD,,
72
+ dbos-1.5.0.dist-info/RECORD,,
File without changes