dbos 0.24.0a15__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/_core.py CHANGED
@@ -81,13 +81,12 @@ from ._sys_db import (
81
81
  if TYPE_CHECKING:
82
82
  from ._dbos import (
83
83
  DBOS,
84
- Workflow,
85
84
  WorkflowHandle,
86
85
  WorkflowHandleAsync,
87
- WorkflowStatus,
88
86
  DBOSRegistry,
89
87
  IsolationLevel,
90
88
  )
89
+ from ._workflow_commands import WorkflowStatus
91
90
 
92
91
  from sqlalchemy.exc import DBAPIError, InvalidRequestError
93
92
 
@@ -109,7 +108,15 @@ class WorkflowHandleFuture(Generic[R]):
109
108
  return self.workflow_id
110
109
 
111
110
  def get_result(self) -> R:
112
- return self.future.result()
111
+ try:
112
+ r = self.future.result()
113
+ except Exception as e:
114
+ serialized_e = _serialization.serialize_exception(e)
115
+ self.dbos._sys_db.record_get_result(self.workflow_id, None, serialized_e)
116
+ raise
117
+ serialized_r = _serialization.serialize(r)
118
+ self.dbos._sys_db.record_get_result(self.workflow_id, serialized_r, None)
119
+ return r
113
120
 
114
121
  def get_status(self) -> "WorkflowStatus":
115
122
  stat = self.dbos.get_workflow_status(self.workflow_id)
@@ -128,8 +135,15 @@ class WorkflowHandlePolling(Generic[R]):
128
135
  return self.workflow_id
129
136
 
130
137
  def get_result(self) -> R:
131
- res: R = self.dbos._sys_db.await_workflow_result(self.workflow_id)
132
- return res
138
+ try:
139
+ r: R = self.dbos._sys_db.await_workflow_result(self.workflow_id)
140
+ except Exception as e:
141
+ serialized_e = _serialization.serialize_exception(e)
142
+ self.dbos._sys_db.record_get_result(self.workflow_id, None, serialized_e)
143
+ raise
144
+ serialized_r = _serialization.serialize(r)
145
+ self.dbos._sys_db.record_get_result(self.workflow_id, serialized_r, None)
146
+ return r
133
147
 
134
148
  def get_status(self) -> "WorkflowStatus":
135
149
  stat = self.dbos.get_workflow_status(self.workflow_id)
@@ -149,7 +163,22 @@ class WorkflowHandleAsyncTask(Generic[R]):
149
163
  return self.workflow_id
150
164
 
151
165
  async def get_result(self) -> R:
152
- return await self.task
166
+ try:
167
+ r = await self.task
168
+ except Exception as e:
169
+ serialized_e = _serialization.serialize_exception(e)
170
+ await asyncio.to_thread(
171
+ self.dbos._sys_db.record_get_result,
172
+ self.workflow_id,
173
+ None,
174
+ serialized_e,
175
+ )
176
+ raise
177
+ serialized_r = _serialization.serialize(r)
178
+ await asyncio.to_thread(
179
+ self.dbos._sys_db.record_get_result, self.workflow_id, serialized_r, None
180
+ )
181
+ return r
153
182
 
154
183
  async def get_status(self) -> "WorkflowStatus":
155
184
  stat = await asyncio.to_thread(self.dbos.get_workflow_status, self.workflow_id)
@@ -168,10 +197,24 @@ class WorkflowHandleAsyncPolling(Generic[R]):
168
197
  return self.workflow_id
169
198
 
170
199
  async def get_result(self) -> R:
171
- res: R = await asyncio.to_thread(
172
- self.dbos._sys_db.await_workflow_result, self.workflow_id
200
+ try:
201
+ r: R = await asyncio.to_thread(
202
+ self.dbos._sys_db.await_workflow_result, self.workflow_id
203
+ )
204
+ except Exception as e:
205
+ serialized_e = _serialization.serialize_exception(e)
206
+ await asyncio.to_thread(
207
+ self.dbos._sys_db.record_get_result,
208
+ self.workflow_id,
209
+ None,
210
+ serialized_e,
211
+ )
212
+ raise
213
+ serialized_r = _serialization.serialize(r)
214
+ await asyncio.to_thread(
215
+ self.dbos._sys_db.record_get_result, self.workflow_id, serialized_r, None
173
216
  )
174
- return res
217
+ return r
175
218
 
176
219
  async def get_status(self) -> "WorkflowStatus":
177
220
  stat = await asyncio.to_thread(self.dbos.get_workflow_status, self.workflow_id)
@@ -236,22 +279,14 @@ def _init_workflow(
236
279
  raise DBOSNonExistentWorkflowError(wfid)
237
280
  wf_status = get_status_result["status"]
238
281
  else:
239
- if temp_wf_type != "transaction" or queue is not None:
240
- # Synchronously record the status and inputs for workflows and single-step workflows
241
- # We also have to do this for single-step workflows because of the foreign key constraint on the operation outputs table
242
- # TODO: Make this transactional (and with the queue step below)
243
- wf_status = dbos._sys_db.insert_workflow_status(
244
- status, max_recovery_attempts=max_recovery_attempts
245
- )
246
- # TODO: Modify the inputs if they were changed by `update_workflow_inputs`
247
- dbos._sys_db.update_workflow_inputs(
248
- wfid, _serialization.serialize_args(inputs)
249
- )
250
- else:
251
- # Buffer the inputs for single-transaction workflows, but don't buffer the status
252
- dbos._sys_db.buffer_workflow_inputs(
253
- wfid, _serialization.serialize_args(inputs)
254
- )
282
+ # Synchronously record the status and inputs for workflows
283
+ # TODO: Make this transactional (and with the queue step below)
284
+ wf_status = dbos._sys_db.insert_workflow_status(
285
+ status, max_recovery_attempts=max_recovery_attempts
286
+ )
287
+
288
+ # TODO: Modify the inputs if they were changed by `update_workflow_inputs`
289
+ dbos._sys_db.update_workflow_inputs(wfid, _serialization.serialize_args(inputs))
255
290
 
256
291
  if queue is not None and wf_status == WorkflowStatusString.ENQUEUED.value:
257
292
  dbos._sys_db.enqueue(wfid, queue)
@@ -265,6 +300,18 @@ def _get_wf_invoke_func(
265
300
  status: WorkflowStatusInternal,
266
301
  ) -> Callable[[Callable[[], R]], R]:
267
302
  def persist(func: Callable[[], R]) -> R:
303
+ if not dbos.debug_mode and (
304
+ status["status"] == WorkflowStatusString.ERROR.value
305
+ or status["status"] == WorkflowStatusString.SUCCESS.value
306
+ ):
307
+ dbos.logger.debug(
308
+ f"Workflow {status['workflow_uuid']} is already completed with status {status['status']}"
309
+ )
310
+ # Directly return the result if the workflow is already completed
311
+ recorded_result: R = dbos._sys_db.await_workflow_result(
312
+ status["workflow_uuid"]
313
+ )
314
+ return recorded_result
268
315
  try:
269
316
  output = func()
270
317
  status["status"] = "SUCCESS"
@@ -273,16 +320,12 @@ def _get_wf_invoke_func(
273
320
  if status["queue_name"] is not None:
274
321
  queue = dbos._registry.queue_info_map[status["queue_name"]]
275
322
  dbos._sys_db.remove_from_queue(status["workflow_uuid"], queue)
276
- dbos._sys_db.buffer_workflow_status(status)
323
+ dbos._sys_db.update_workflow_status(status)
277
324
  return output
278
325
  except DBOSWorkflowConflictIDError:
279
- # Retrieve the workflow handle and wait for the result.
280
- # Must use existing_workflow=False because workflow status might not be set yet for single transaction workflows.
281
- wf_handle: "WorkflowHandle[R]" = dbos.retrieve_workflow(
282
- status["workflow_uuid"], existing_workflow=False
283
- )
284
- output = wf_handle.get_result()
285
- return output
326
+ # Await the workflow result
327
+ r: R = dbos._sys_db.await_workflow_result(status["workflow_uuid"])
328
+ return r
286
329
  except DBOSWorkflowCancelledError as error:
287
330
  raise
288
331
  except Exception as error:
@@ -301,7 +344,7 @@ def _get_wf_invoke_func(
301
344
  def _execute_workflow_wthread(
302
345
  dbos: "DBOS",
303
346
  status: WorkflowStatusInternal,
304
- func: "Workflow[P, R]",
347
+ func: "Callable[P, R]",
305
348
  ctx: DBOSContext,
306
349
  *args: Any,
307
350
  **kwargs: Any,
@@ -332,7 +375,7 @@ def _execute_workflow_wthread(
332
375
  async def _execute_workflow_async(
333
376
  dbos: "DBOS",
334
377
  status: WorkflowStatusInternal,
335
- func: "Workflow[P, Coroutine[Any, Any, R]]",
378
+ func: "Callable[P, Coroutine[Any, Any, R]]",
336
379
  ctx: DBOSContext,
337
380
  *args: Any,
338
381
  **kwargs: Any,
@@ -446,7 +489,7 @@ def _get_new_wf() -> tuple[str, DBOSContext]:
446
489
 
447
490
  def start_workflow(
448
491
  dbos: "DBOS",
449
- func: "Workflow[P, Union[R, Coroutine[Any, Any, R]]]",
492
+ func: "Callable[P, Union[R, Coroutine[Any, Any, R]]]",
450
493
  queue_name: Optional[str],
451
494
  execute_workflow: bool,
452
495
  *args: P.args,
@@ -475,6 +518,15 @@ def start_workflow(
475
518
 
476
519
  new_wf_id, new_wf_ctx = _get_new_wf()
477
520
 
521
+ ctx = new_wf_ctx
522
+ new_child_workflow_id = ctx.id_assigned_for_next_workflow
523
+ if ctx.has_parent():
524
+ child_workflow_id = dbos._sys_db.check_child_workflow(
525
+ ctx.parent_workflow_id, ctx.parent_workflow_fid
526
+ )
527
+ if child_workflow_id is not None:
528
+ return WorkflowHandlePolling(child_workflow_id, dbos)
529
+
478
530
  status = _init_workflow(
479
531
  dbos,
480
532
  new_wf_ctx,
@@ -488,6 +540,13 @@ def start_workflow(
488
540
  )
489
541
 
490
542
  wf_status = status["status"]
543
+ if ctx.has_parent():
544
+ dbos._sys_db.record_child_workflow(
545
+ ctx.parent_workflow_id,
546
+ new_child_workflow_id,
547
+ ctx.parent_workflow_fid,
548
+ get_dbos_func_name(func),
549
+ )
491
550
 
492
551
  if not execute_workflow or (
493
552
  not dbos.debug_mode
@@ -496,9 +555,6 @@ def start_workflow(
496
555
  or wf_status == WorkflowStatusString.SUCCESS.value
497
556
  )
498
557
  ):
499
- dbos.logger.debug(
500
- f"Workflow {new_wf_id} already completed with status {wf_status}. Directly returning a workflow handle."
501
- )
502
558
  return WorkflowHandlePolling(new_wf_id, dbos)
503
559
 
504
560
  future = dbos._executor.submit(
@@ -515,7 +571,7 @@ def start_workflow(
515
571
 
516
572
  async def start_workflow_async(
517
573
  dbos: "DBOS",
518
- func: "Workflow[P, Coroutine[Any, Any, R]]",
574
+ func: "Callable[P, Coroutine[Any, Any, R]]",
519
575
  queue_name: Optional[str],
520
576
  execute_workflow: bool,
521
577
  *args: P.args,
@@ -544,6 +600,17 @@ async def start_workflow_async(
544
600
 
545
601
  new_wf_id, new_wf_ctx = _get_new_wf()
546
602
 
603
+ ctx = new_wf_ctx
604
+ new_child_workflow_id = ctx.id_assigned_for_next_workflow
605
+ if ctx.has_parent():
606
+ child_workflow_id = await asyncio.to_thread(
607
+ dbos._sys_db.check_child_workflow,
608
+ ctx.parent_workflow_id,
609
+ ctx.parent_workflow_fid,
610
+ )
611
+ if child_workflow_id is not None:
612
+ return WorkflowHandleAsyncPolling(child_workflow_id, dbos)
613
+
547
614
  status = await asyncio.to_thread(
548
615
  _init_workflow,
549
616
  dbos,
@@ -557,6 +624,15 @@ async def start_workflow_async(
557
624
  max_recovery_attempts=fi.max_recovery_attempts,
558
625
  )
559
626
 
627
+ if ctx.has_parent():
628
+ await asyncio.to_thread(
629
+ dbos._sys_db.record_child_workflow,
630
+ ctx.parent_workflow_id,
631
+ new_child_workflow_id,
632
+ ctx.parent_workflow_fid,
633
+ get_dbos_func_name(func),
634
+ )
635
+
560
636
  wf_status = status["status"]
561
637
 
562
638
  if not execute_workflow or (
@@ -566,9 +642,6 @@ async def start_workflow_async(
566
642
  or wf_status == WorkflowStatusString.SUCCESS.value
567
643
  )
568
644
  ):
569
- dbos.logger.debug(
570
- f"Workflow {new_wf_id} already completed with status {wf_status}. Directly returning a workflow handle."
571
- )
572
645
  return WorkflowHandleAsyncPolling(new_wf_id, dbos)
573
646
 
574
647
  coro = _execute_workflow_async(dbos, status, func, new_wf_ctx, *args, **kwargs)
@@ -628,8 +701,30 @@ def workflow_wrapper(
628
701
 
629
702
  wfOutcome = Outcome[R].make(functools.partial(func, *args, **kwargs))
630
703
 
704
+ workflow_id = None
705
+
631
706
  def init_wf() -> Callable[[Callable[[], R]], R]:
707
+
708
+ def recorded_result(
709
+ c_wfid: str, dbos: "DBOS"
710
+ ) -> Callable[[Callable[[], R]], R]:
711
+ def recorded_result_inner(func: Callable[[], R]) -> R:
712
+ r: R = dbos._sys_db.await_workflow_result(c_wfid)
713
+ return r
714
+
715
+ return recorded_result_inner
716
+
632
717
  ctx = assert_current_dbos_context() # Now the child ctx
718
+ nonlocal workflow_id
719
+ workflow_id = ctx.workflow_id
720
+
721
+ if ctx.has_parent():
722
+ child_workflow_id = dbos._sys_db.check_child_workflow(
723
+ ctx.parent_workflow_id, ctx.parent_workflow_fid
724
+ )
725
+ if child_workflow_id is not None:
726
+ return recorded_result(child_workflow_id, dbos)
727
+
633
728
  status = _init_workflow(
634
729
  dbos,
635
730
  ctx,
@@ -640,17 +735,44 @@ def workflow_wrapper(
640
735
  temp_wf_type=get_temp_workflow_type(func),
641
736
  max_recovery_attempts=max_recovery_attempts,
642
737
  )
738
+
643
739
  # TODO: maybe modify the parameters if they've been changed by `_init_workflow`
644
740
  dbos.logger.debug(
645
741
  f"Running workflow, id: {ctx.workflow_id}, name: {get_dbos_func_name(func)}"
646
742
  )
647
743
 
744
+ if ctx.has_parent():
745
+ dbos._sys_db.record_child_workflow(
746
+ ctx.parent_workflow_id,
747
+ ctx.workflow_id,
748
+ ctx.parent_workflow_fid,
749
+ get_dbos_func_name(func),
750
+ )
751
+
648
752
  return _get_wf_invoke_func(dbos, status)
649
753
 
754
+ def record_get_result(func: Callable[[], R]) -> R:
755
+ """
756
+ If a child workflow is invoked synchronously, this records the implicit "getResult" where the
757
+ parent retrieves the child's output. It executes in the CALLER'S context, not the workflow's.
758
+ """
759
+ try:
760
+ r = func()
761
+ except Exception as e:
762
+ serialized_e = _serialization.serialize_exception(e)
763
+ assert workflow_id is not None
764
+ dbos._sys_db.record_get_result(workflow_id, None, serialized_e)
765
+ raise
766
+ serialized_r = _serialization.serialize(r)
767
+ assert workflow_id is not None
768
+ dbos._sys_db.record_get_result(workflow_id, serialized_r, None)
769
+ return r
770
+
650
771
  outcome = (
651
772
  wfOutcome.wrap(init_wf)
652
773
  .also(DBOSAssumeRole(rr))
653
774
  .also(enterWorkflowCtxMgr(attributes))
775
+ .then(record_get_result)
654
776
  )
655
777
  return outcome() # type: ignore
656
778
 
@@ -853,6 +975,8 @@ def decorate_step(
853
975
  ) -> Callable[[Callable[P, R]], Callable[P, R]]:
854
976
  def decorator(func: Callable[P, R]) -> Callable[P, R]:
855
977
 
978
+ stepName = func.__qualname__
979
+
856
980
  def invoke_step(*args: Any, **kwargs: Any) -> Any:
857
981
  if dbosreg.dbos is None:
858
982
  raise DBOSException(
@@ -877,7 +1001,7 @@ def decorate_step(
877
1001
 
878
1002
  def on_exception(attempt: int, error: BaseException) -> float:
879
1003
  dbos.logger.warning(
880
- f"Step being automatically retried. (attempt {attempt} of {attempts}). {traceback.format_exc()}"
1004
+ f"Step being automatically retried. (attempt {attempt + 1} of {attempts}). {traceback.format_exc()}"
881
1005
  )
882
1006
  ctx = assert_current_dbos_context()
883
1007
  ctx.get_current_span().add_event(
@@ -897,19 +1021,20 @@ def decorate_step(
897
1021
  step_output: OperationResultInternal = {
898
1022
  "workflow_uuid": ctx.workflow_id,
899
1023
  "function_id": ctx.function_id,
1024
+ "function_name": stepName,
900
1025
  "output": None,
901
1026
  "error": None,
902
1027
  }
903
1028
 
904
1029
  try:
905
1030
  output = func()
906
- step_output["output"] = _serialization.serialize(output)
907
- return output
908
1031
  except Exception as error:
909
1032
  step_output["error"] = _serialization.serialize_exception(error)
910
- raise
911
- finally:
912
1033
  dbos._sys_db.record_operation_result(step_output)
1034
+ raise
1035
+ step_output["output"] = _serialization.serialize(output)
1036
+ dbos._sys_db.record_operation_result(step_output)
1037
+ return output
913
1038
 
914
1039
  def check_existing_result() -> Union[NoResult, R]:
915
1040
  ctx = assert_current_dbos_context()
dbos/_db_wizard.py CHANGED
@@ -49,6 +49,7 @@ def db_wizard(config: "ConfigFile") -> "ConfigFile":
49
49
 
50
50
  # 2. If the error is due to password authentication or the configuration is non-default, surface the error and exit.
51
51
  error_str = str(db_connection_error)
52
+ dbos_logger.debug(f"Error connecting to Postgres: {error_str}")
52
53
  if (
53
54
  "password authentication failed" in error_str
54
55
  or "28P01" in error_str
@@ -182,17 +183,12 @@ def _check_db_connectivity(config: "ConfigFile") -> Optional[Exception]:
182
183
  host=config["database"]["hostname"],
183
184
  port=config["database"]["port"],
184
185
  database="postgres",
185
- query={"connect_timeout": "1"},
186
+ query={"connect_timeout": "2"},
186
187
  )
187
188
  postgres_db_engine = create_engine(postgres_db_url)
188
189
  try:
189
190
  with postgres_db_engine.connect() as conn:
190
- val = conn.execute(text("SELECT 1")).scalar()
191
- if val != 1:
192
- dbos_logger.error(
193
- f"Unexpected value returned from database: expected 1, received {val}"
194
- )
195
- return Exception()
191
+ conn.execute(text("SELECT 1")).scalar()
196
192
  except Exception as e:
197
193
  return e
198
194
  finally: