dbos 0.24.1__py3-none-any.whl → 0.25.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/_sys_db.py CHANGED
@@ -28,11 +28,11 @@ from sqlalchemy.sql import func
28
28
  from dbos._utils import GlobalParams
29
29
 
30
30
  from . import _serialization
31
- from ._dbos_config import ConfigFile
31
+ from ._context import get_local_dbos_context
32
+ from ._dbos_config import ConfigFile, DatabaseConfig
32
33
  from ._error import (
33
34
  DBOSConflictingWorkflowError,
34
35
  DBOSDeadLetterQueueError,
35
- DBOSException,
36
36
  DBOSNonExistentWorkflowError,
37
37
  DBOSWorkflowConflictIDError,
38
38
  )
@@ -89,6 +89,7 @@ class RecordedResult(TypedDict):
89
89
  class OperationResultInternal(TypedDict):
90
90
  workflow_uuid: str
91
91
  function_id: int
92
+ function_name: str
92
93
  output: Optional[str] # JSON (jsonpickle)
93
94
  error: Optional[str] # JSON (jsonpickle)
94
95
 
@@ -114,7 +115,7 @@ class GetWorkflowsInput:
114
115
  self.authenticated_user: Optional[str] = None # The user who ran the workflow.
115
116
  self.start_time: Optional[str] = None # Timestamp in ISO 8601 format
116
117
  self.end_time: Optional[str] = None # Timestamp in ISO 8601 format
117
- self.status: Optional[WorkflowStatuses] = None
118
+ self.status: Optional[str] = None
118
119
  self.application_version: Optional[str] = (
119
120
  None # The application version that ran this workflow. = None
120
121
  )
@@ -151,30 +152,39 @@ class GetPendingWorkflowsOutput:
151
152
  self.queue_name: Optional[str] = queue_name
152
153
 
153
154
 
155
+ class StepInfo(TypedDict):
156
+ # The unique ID of the step in the workflow
157
+ function_id: int
158
+ # The (fully qualified) name of the step
159
+ function_name: str
160
+ # The step's output, if any
161
+ output: Optional[Any]
162
+ # The error the step threw, if any
163
+ error: Optional[Exception]
164
+ # If the step starts or retrieves the result of a workflow, its ID
165
+ child_workflow_id: Optional[str]
166
+
167
+
154
168
  _dbos_null_topic = "__null__topic__"
155
- _buffer_flush_batch_size = 100
156
- _buffer_flush_interval_secs = 1.0
157
169
 
158
170
 
159
171
  class SystemDatabase:
160
172
 
161
- def __init__(self, config: ConfigFile, *, debug_mode: bool = False):
162
- self.config = config
163
-
173
+ def __init__(self, database: DatabaseConfig, *, debug_mode: bool = False):
164
174
  sysdb_name = (
165
- config["database"]["sys_db_name"]
166
- if "sys_db_name" in config["database"] and config["database"]["sys_db_name"]
167
- else config["database"]["app_db_name"] + SystemSchema.sysdb_suffix
175
+ database["sys_db_name"]
176
+ if "sys_db_name" in database and database["sys_db_name"]
177
+ else database["app_db_name"] + SystemSchema.sysdb_suffix
168
178
  )
169
179
 
170
180
  if not debug_mode:
171
181
  # If the system database does not already exist, create it
172
182
  postgres_db_url = sa.URL.create(
173
183
  "postgresql+psycopg",
174
- username=config["database"]["username"],
175
- password=config["database"]["password"],
176
- host=config["database"]["hostname"],
177
- port=config["database"]["port"],
184
+ username=database["username"],
185
+ password=database["password"],
186
+ host=database["hostname"],
187
+ port=database["port"],
178
188
  database="postgres",
179
189
  # fills the "application_name" column in pg_stat_activity
180
190
  query={"application_name": f"dbos_transact_{GlobalParams.executor_id}"},
@@ -191,19 +201,23 @@ class SystemDatabase:
191
201
 
192
202
  system_db_url = sa.URL.create(
193
203
  "postgresql+psycopg",
194
- username=config["database"]["username"],
195
- password=config["database"]["password"],
196
- host=config["database"]["hostname"],
197
- port=config["database"]["port"],
204
+ username=database["username"],
205
+ password=database["password"],
206
+ host=database["hostname"],
207
+ port=database["port"],
198
208
  database=sysdb_name,
199
209
  # fills the "application_name" column in pg_stat_activity
200
210
  query={"application_name": f"dbos_transact_{GlobalParams.executor_id}"},
201
211
  )
202
212
 
203
213
  # Create a connection pool for the system database
214
+ pool_size = database.get("sys_db_pool_size")
215
+ if pool_size is None:
216
+ pool_size = 20
217
+
204
218
  self.engine = sa.create_engine(
205
219
  system_db_url,
206
- pool_size=config["database"]["sys_db_pool_size"],
220
+ pool_size=pool_size,
207
221
  max_overflow=0,
208
222
  pool_timeout=30,
209
223
  connect_args={"connect_timeout": 10},
@@ -250,32 +264,17 @@ class SystemDatabase:
250
264
  self.notifications_map: Dict[str, threading.Condition] = {}
251
265
  self.workflow_events_map: Dict[str, threading.Condition] = {}
252
266
 
253
- # Initialize the workflow status and inputs buffers
254
- self._workflow_status_buffer: Dict[str, WorkflowStatusInternal] = {}
255
- self._workflow_inputs_buffer: Dict[str, str] = {}
256
- # Two sets for tracking which single-transaction workflows have been exported to the status table
257
- self._exported_temp_txn_wf_status: Set[str] = set()
258
- self._temp_txn_wf_ids: Set[str] = set()
259
- self._is_flushing_status_buffer = False
260
-
261
267
  # Now we can run background processes
262
268
  self._run_background_processes = True
263
269
  self._debug_mode = debug_mode
264
270
 
265
271
  # Destroy the pool when finished
266
272
  def destroy(self) -> None:
267
- self.wait_for_buffer_flush()
268
273
  self._run_background_processes = False
269
274
  if self.notification_conn is not None:
270
275
  self.notification_conn.close()
271
276
  self.engine.dispose()
272
277
 
273
- def wait_for_buffer_flush(self) -> None:
274
- # Wait until the buffers are flushed.
275
- while self._is_flushing_status_buffer or not self._is_buffers_empty:
276
- dbos_logger.debug("Waiting for system buffers to be exported")
277
- time.sleep(1)
278
-
279
278
  def insert_workflow_status(
280
279
  self,
281
280
  status: WorkflowStatusInternal,
@@ -426,10 +425,6 @@ class SystemDatabase:
426
425
  with self.engine.begin() as c:
427
426
  c.execute(cmd)
428
427
 
429
- # If this is a single-transaction workflow, record that its status has been exported
430
- if status["workflow_uuid"] in self._temp_txn_wf_ids:
431
- self._exported_temp_txn_wf_status.add(status["workflow_uuid"])
432
-
433
428
  def cancel_workflow(
434
429
  self,
435
430
  workflow_id: str,
@@ -531,31 +526,6 @@ class SystemDatabase:
531
526
  }
532
527
  return status
533
528
 
534
- def get_workflow_status_within_wf(
535
- self, workflow_uuid: str, calling_wf: str, calling_wf_fn: int
536
- ) -> Optional[WorkflowStatusInternal]:
537
- res = self.check_operation_execution(calling_wf, calling_wf_fn)
538
- if res is not None:
539
- if res["output"]:
540
- resstat: WorkflowStatusInternal = _serialization.deserialize(
541
- res["output"]
542
- )
543
- return resstat
544
- else:
545
- raise DBOSException(
546
- "Workflow status record not found. This should not happen! \033[1m Hint: Check if your workflow is deterministic.\033[0m"
547
- )
548
- stat = self.get_workflow_status(workflow_uuid)
549
- self.record_operation_result(
550
- {
551
- "workflow_uuid": calling_wf,
552
- "function_id": calling_wf_fn,
553
- "output": _serialization.serialize(stat),
554
- "error": None,
555
- }
556
- )
557
- return stat
558
-
559
529
  def await_workflow_result_internal(self, workflow_uuid: str) -> dict[str, Any]:
560
530
  polling_interval_secs: float = 1.000
561
531
 
@@ -632,10 +602,7 @@ class SystemDatabase:
632
602
  f"Workflow {workflow_uuid} has been called multiple times with different inputs"
633
603
  )
634
604
  # TODO: actually changing the input
635
- if workflow_uuid in self._temp_txn_wf_ids:
636
- # Clean up the single-transaction tracking sets
637
- self._exported_temp_txn_wf_status.discard(workflow_uuid)
638
- self._temp_txn_wf_ids.discard(workflow_uuid)
605
+
639
606
  return
640
607
 
641
608
  def get_workflow_inputs(
@@ -771,6 +738,36 @@ class SystemDatabase:
771
738
  for row in rows
772
739
  ]
773
740
 
741
+ def get_workflow_steps(self, workflow_id: str) -> List[StepInfo]:
742
+ with self.engine.begin() as c:
743
+ rows = c.execute(
744
+ sa.select(
745
+ SystemSchema.operation_outputs.c.function_id,
746
+ SystemSchema.operation_outputs.c.function_name,
747
+ SystemSchema.operation_outputs.c.output,
748
+ SystemSchema.operation_outputs.c.error,
749
+ SystemSchema.operation_outputs.c.child_workflow_id,
750
+ ).where(SystemSchema.operation_outputs.c.workflow_uuid == workflow_id)
751
+ ).fetchall()
752
+ return [
753
+ StepInfo(
754
+ function_id=row[0],
755
+ function_name=row[1],
756
+ output=(
757
+ _serialization.deserialize(row[2])
758
+ if row[2] is not None
759
+ else row[2]
760
+ ),
761
+ error=(
762
+ _serialization.deserialize_exception(row[3])
763
+ if row[3] is not None
764
+ else row[3]
765
+ ),
766
+ child_workflow_id=row[4],
767
+ )
768
+ for row in rows
769
+ ]
770
+
774
771
  def record_operation_result(
775
772
  self, result: OperationResultInternal, conn: Optional[sa.Connection] = None
776
773
  ) -> None:
@@ -782,6 +779,7 @@ class SystemDatabase:
782
779
  sql = pg.insert(SystemSchema.operation_outputs).values(
783
780
  workflow_uuid=result["workflow_uuid"],
784
781
  function_id=result["function_id"],
782
+ function_name=result["function_name"],
785
783
  output=output,
786
784
  error=error,
787
785
  )
@@ -796,6 +794,55 @@ class SystemDatabase:
796
794
  raise DBOSWorkflowConflictIDError(result["workflow_uuid"])
797
795
  raise
798
796
 
797
+ def record_get_result(
798
+ self, result_workflow_id: str, output: Optional[str], error: Optional[str]
799
+ ) -> None:
800
+ ctx = get_local_dbos_context()
801
+ # Only record get_result called in workflow functions
802
+ if ctx is None or not ctx.is_workflow():
803
+ return
804
+ ctx.function_id += 1 # Record the get_result as a step
805
+ # Because there's no corresponding check, we do nothing on conflict
806
+ # and do not raise a DBOSWorkflowConflictIDError
807
+ sql = (
808
+ pg.insert(SystemSchema.operation_outputs)
809
+ .values(
810
+ workflow_uuid=ctx.workflow_id,
811
+ function_id=ctx.function_id,
812
+ function_name="DBOS.getResult",
813
+ output=output,
814
+ error=error,
815
+ child_workflow_id=result_workflow_id,
816
+ )
817
+ .on_conflict_do_nothing()
818
+ )
819
+ with self.engine.begin() as c:
820
+ c.execute(sql)
821
+
822
+ def record_child_workflow(
823
+ self,
824
+ parentUUID: str,
825
+ childUUID: str,
826
+ functionID: int,
827
+ functionName: str,
828
+ ) -> None:
829
+ if self._debug_mode:
830
+ raise Exception("called record_child_workflow in debug mode")
831
+
832
+ sql = pg.insert(SystemSchema.operation_outputs).values(
833
+ workflow_uuid=parentUUID,
834
+ function_id=functionID,
835
+ function_name=functionName,
836
+ child_workflow_id=childUUID,
837
+ )
838
+ try:
839
+ with self.engine.begin() as c:
840
+ c.execute(sql)
841
+ except DBAPIError as dbapi_error:
842
+ if dbapi_error.orig.sqlstate == "23505": # type: ignore
843
+ raise DBOSWorkflowConflictIDError(parentUUID)
844
+ raise
845
+
799
846
  def check_operation_execution(
800
847
  self, workflow_uuid: str, function_id: int, conn: Optional[sa.Connection] = None
801
848
  ) -> Optional[RecordedResult]:
@@ -822,6 +869,23 @@ class SystemDatabase:
822
869
  }
823
870
  return result
824
871
 
872
+ def check_child_workflow(
873
+ self, workflow_uuid: str, function_id: int
874
+ ) -> Optional[str]:
875
+ sql = sa.select(SystemSchema.operation_outputs.c.child_workflow_id).where(
876
+ SystemSchema.operation_outputs.c.workflow_uuid == workflow_uuid,
877
+ SystemSchema.operation_outputs.c.function_id == function_id,
878
+ )
879
+
880
+ # If in a transaction, use the provided connection
881
+ row: Any
882
+ with self.engine.begin() as c:
883
+ row = c.execute(sql).fetchone()
884
+
885
+ if row is None:
886
+ return None
887
+ return str(row[0])
888
+
825
889
  def send(
826
890
  self,
827
891
  workflow_uuid: str,
@@ -866,6 +930,7 @@ class SystemDatabase:
866
930
  output: OperationResultInternal = {
867
931
  "workflow_uuid": workflow_uuid,
868
932
  "function_id": function_id,
933
+ "function_name": "DBOS.send",
869
934
  "output": None,
870
935
  "error": None,
871
936
  }
@@ -959,6 +1024,7 @@ class SystemDatabase:
959
1024
  {
960
1025
  "workflow_uuid": workflow_uuid,
961
1026
  "function_id": function_id,
1027
+ "function_name": "DBOS.recv",
962
1028
  "output": _serialization.serialize(
963
1029
  message
964
1030
  ), # None will be serialized to 'null'
@@ -1049,6 +1115,7 @@ class SystemDatabase:
1049
1115
  {
1050
1116
  "workflow_uuid": workflow_uuid,
1051
1117
  "function_id": function_id,
1118
+ "function_name": "DBOS.sleep",
1052
1119
  "output": _serialization.serialize(end_time),
1053
1120
  "error": None,
1054
1121
  }
@@ -1096,6 +1163,7 @@ class SystemDatabase:
1096
1163
  output: OperationResultInternal = {
1097
1164
  "workflow_uuid": workflow_uuid,
1098
1165
  "function_id": function_id,
1166
+ "function_name": "DBOS.setEvent",
1099
1167
  "output": None,
1100
1168
  "error": None,
1101
1169
  }
@@ -1176,6 +1244,7 @@ class SystemDatabase:
1176
1244
  {
1177
1245
  "workflow_uuid": caller_ctx["workflow_uuid"],
1178
1246
  "function_id": caller_ctx["function_id"],
1247
+ "function_name": "DBOS.getEvent",
1179
1248
  "output": _serialization.serialize(
1180
1249
  value
1181
1250
  ), # None will be serialized to 'null'
@@ -1184,106 +1253,6 @@ class SystemDatabase:
1184
1253
  )
1185
1254
  return value
1186
1255
 
1187
- def _flush_workflow_status_buffer(self) -> None:
1188
- if self._debug_mode:
1189
- raise Exception("called _flush_workflow_status_buffer in debug mode")
1190
-
1191
- """Export the workflow status buffer to the database, up to the batch size."""
1192
- if len(self._workflow_status_buffer) == 0:
1193
- return
1194
-
1195
- # Record the exported status so far, and add them back on errors.
1196
- exported_status: Dict[str, WorkflowStatusInternal] = {}
1197
- with self.engine.begin() as c:
1198
- exported = 0
1199
- status_iter = iter(list(self._workflow_status_buffer))
1200
- wf_id: Optional[str] = None
1201
- while (
1202
- exported < _buffer_flush_batch_size
1203
- and (wf_id := next(status_iter, None)) is not None
1204
- ):
1205
- # Pop the first key in the buffer (FIFO)
1206
- status = self._workflow_status_buffer.pop(wf_id, None)
1207
- if status is None:
1208
- continue
1209
- exported_status[wf_id] = status
1210
- try:
1211
- self.update_workflow_status(status, conn=c)
1212
- exported += 1
1213
- except Exception as e:
1214
- dbos_logger.error(f"Error while flushing status buffer: {e}")
1215
- c.rollback()
1216
- # Add the exported status back to the buffer, so they can be retried next time
1217
- self._workflow_status_buffer.update(exported_status)
1218
- break
1219
-
1220
- def _flush_workflow_inputs_buffer(self) -> None:
1221
- if self._debug_mode:
1222
- raise Exception("called _flush_workflow_inputs_buffer in debug mode")
1223
-
1224
- """Export the workflow inputs buffer to the database, up to the batch size."""
1225
- if len(self._workflow_inputs_buffer) == 0:
1226
- return
1227
-
1228
- # Record exported inputs so far, and add them back on errors.
1229
- exported_inputs: Dict[str, str] = {}
1230
- with self.engine.begin() as c:
1231
- exported = 0
1232
- input_iter = iter(list(self._workflow_inputs_buffer))
1233
- wf_id: Optional[str] = None
1234
- while (
1235
- exported < _buffer_flush_batch_size
1236
- and (wf_id := next(input_iter, None)) is not None
1237
- ):
1238
- if wf_id not in self._exported_temp_txn_wf_status:
1239
- # Skip exporting inputs if the status has not been exported yet
1240
- continue
1241
- inputs = self._workflow_inputs_buffer.pop(wf_id, None)
1242
- if inputs is None:
1243
- continue
1244
- exported_inputs[wf_id] = inputs
1245
- try:
1246
- self.update_workflow_inputs(wf_id, inputs, conn=c)
1247
- exported += 1
1248
- except Exception as e:
1249
- dbos_logger.error(f"Error while flushing inputs buffer: {e}")
1250
- c.rollback()
1251
- # Add the exported inputs back to the buffer, so they can be retried next time
1252
- self._workflow_inputs_buffer.update(exported_inputs)
1253
- break
1254
-
1255
- def flush_workflow_buffers(self) -> None:
1256
- """Flush the workflow status and inputs buffers periodically, via a background thread."""
1257
- while self._run_background_processes:
1258
- try:
1259
- self._is_flushing_status_buffer = True
1260
- # Must flush the status buffer first, as the inputs table has a foreign key constraint on the status table.
1261
- self._flush_workflow_status_buffer()
1262
- self._flush_workflow_inputs_buffer()
1263
- self._is_flushing_status_buffer = False
1264
- if self._is_buffers_empty:
1265
- # Only sleep if both buffers are empty
1266
- time.sleep(_buffer_flush_interval_secs)
1267
- except Exception as e:
1268
- dbos_logger.error(f"Error while flushing buffers: {e}")
1269
- time.sleep(_buffer_flush_interval_secs)
1270
- # Will retry next time
1271
-
1272
- def buffer_workflow_status(self, status: WorkflowStatusInternal) -> None:
1273
- self._workflow_status_buffer[status["workflow_uuid"]] = status
1274
-
1275
- def buffer_workflow_inputs(self, workflow_id: str, inputs: str) -> None:
1276
- # inputs is a serialized WorkflowInputs string
1277
- self._workflow_inputs_buffer[workflow_id] = inputs
1278
- self._temp_txn_wf_ids.add(workflow_id)
1279
-
1280
- @property
1281
- def _is_buffers_empty(self) -> bool:
1282
- return (
1283
- len(self._workflow_status_buffer) == 0
1284
- and len(self._workflow_inputs_buffer) == 0
1285
- )
1286
-
1287
1256
  def enqueue(self, workflow_id: str, queue_name: str) -> None:
1288
1257
  if self._debug_mode:
1289
1258
  raise Exception("called enqueue in debug mode")
@@ -1297,7 +1266,9 @@ class SystemDatabase:
1297
1266
  .on_conflict_do_nothing()
1298
1267
  )
1299
1268
 
1300
- def start_queued_workflows(self, queue: "Queue", executor_id: str) -> List[str]:
1269
+ def start_queued_workflows(
1270
+ self, queue: "Queue", executor_id: str, app_version: str
1271
+ ) -> List[str]:
1301
1272
  if self._debug_mode:
1302
1273
  return []
1303
1274
 
@@ -1412,26 +1383,36 @@ class SystemDatabase:
1412
1383
  break
1413
1384
 
1414
1385
  # To start a function, first set its status to PENDING and update its executor ID
1415
- c.execute(
1386
+ res = c.execute(
1416
1387
  SystemSchema.workflow_status.update()
1417
1388
  .where(SystemSchema.workflow_status.c.workflow_uuid == id)
1418
1389
  .where(
1419
1390
  SystemSchema.workflow_status.c.status
1420
1391
  == WorkflowStatusString.ENQUEUED.value
1421
1392
  )
1393
+ .where(
1394
+ sa.or_(
1395
+ SystemSchema.workflow_status.c.application_version
1396
+ == app_version,
1397
+ SystemSchema.workflow_status.c.application_version.is_(
1398
+ None
1399
+ ),
1400
+ )
1401
+ )
1422
1402
  .values(
1423
1403
  status=WorkflowStatusString.PENDING.value,
1404
+ application_version=app_version,
1424
1405
  executor_id=executor_id,
1425
1406
  )
1426
1407
  )
1427
-
1428
- # Then give it a start time and assign the executor ID
1429
- c.execute(
1430
- SystemSchema.workflow_queue.update()
1431
- .where(SystemSchema.workflow_queue.c.workflow_uuid == id)
1432
- .values(started_at_epoch_ms=start_time_ms)
1433
- )
1434
- ret_ids.append(id)
1408
+ if res.rowcount > 0:
1409
+ # Then give it a start time and assign the executor ID
1410
+ c.execute(
1411
+ SystemSchema.workflow_queue.update()
1412
+ .where(SystemSchema.workflow_queue.c.workflow_uuid == id)
1413
+ .values(started_at_epoch_ms=start_time_ms)
1414
+ )
1415
+ ret_ids.append(id)
1435
1416
 
1436
1417
  # If we have a limiter, garbage-collect all completed functions started
1437
1418
  # before the period. If there's no limiter, there's no need--they were
@@ -6,6 +6,7 @@
6
6
 
7
7
  # First, let's do imports, create a FastAPI app, and initialize DBOS.
8
8
 
9
+ import uvicorn
9
10
  from fastapi import FastAPI
10
11
  from fastapi.responses import HTMLResponse
11
12
 
@@ -37,7 +38,7 @@ def example_transaction(name: str) -> str:
37
38
  return greeting
38
39
 
39
40
 
40
- # Finally, let's use FastAPI to serve an HTML + CSS readme
41
+ # Now, let's use FastAPI to serve an HTML + CSS readme
41
42
  # from the root path.
42
43
 
43
44
 
@@ -66,14 +67,8 @@ def readme() -> HTMLResponse:
66
67
  return HTMLResponse(readme)
67
68
 
68
69
 
69
- # To deploy this app to DBOS Cloud:
70
- # - "npm i -g @dbos-inc/dbos-cloud@latest" to install the Cloud CLI (requires Node)
71
- # - "dbos-cloud app deploy" to deploy your app
72
- # - Deploy outputs a URL--visit it to see your app!
70
+ # Finally, we'll launch DBOS then start the FastAPI server.
73
71
 
74
-
75
- # To run this app locally:
76
- # - Make sure you have a Postgres database to connect to
77
- # - "dbos migrate" to set up your database tables
78
- # - "dbos start" to start the app
79
- # - Visit localhost:8000 to see your app!
72
+ if __name__ == "__main__":
73
+ DBOS.launch()
74
+ uvicorn.run(app, host="0.0.0.0", port=8000)
@@ -7,8 +7,6 @@ name: ${project_name}
7
7
  language: python
8
8
  runtimeConfig:
9
9
  start:
10
- - "fastapi run ${package_name}/main.py"
10
+ - "${start_command}"
11
11
  database_url: ${DBOS_DATABASE_URL}
12
- database:
13
- migrate:
14
- - ${migration_command}
12
+ ${migration_section}