dbos 0.26.0a21__py3-none-any.whl → 0.26.0a23__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/_client.py CHANGED
@@ -1,5 +1,6 @@
1
1
  import asyncio
2
2
  import sys
3
+ import time
3
4
  import uuid
4
5
  from typing import Any, Generic, List, Optional, TypedDict, TypeVar
5
6
 
@@ -39,6 +40,7 @@ class EnqueueOptions(TypedDict):
39
40
  queue_name: str
40
41
  workflow_id: NotRequired[str]
41
42
  app_version: NotRequired[str]
43
+ workflow_timeout: NotRequired[float]
42
44
 
43
45
 
44
46
  class WorkflowHandleClientPolling(Generic[R]):
@@ -107,6 +109,7 @@ class DBOSClient:
107
109
  workflow_id = options.get("workflow_id")
108
110
  if workflow_id is None:
109
111
  workflow_id = str(uuid.uuid4())
112
+ workflow_timeout = options.get("workflow_timeout", None)
110
113
 
111
114
  status: WorkflowStatusInternal = {
112
115
  "workflow_uuid": workflow_id,
@@ -127,6 +130,10 @@ class DBOSClient:
127
130
  "executor_id": None,
128
131
  "recovery_attempts": None,
129
132
  "app_id": None,
133
+ "workflow_timeout_ms": (
134
+ int(workflow_timeout * 1000) if workflow_timeout is not None else None
135
+ ),
136
+ "workflow_deadline_epoch_ms": None,
130
137
  }
131
138
 
132
139
  inputs: WorkflowInputs = {
@@ -190,6 +197,8 @@ class DBOSClient:
190
197
  "recovery_attempts": None,
191
198
  "app_id": None,
192
199
  "app_version": None,
200
+ "workflow_timeout_ms": None,
201
+ "workflow_deadline_epoch_ms": None,
193
202
  }
194
203
  with self._sys_db.engine.begin() as conn:
195
204
  self._sys_db.insert_workflow_status(
dbos/_context.py CHANGED
@@ -93,6 +93,11 @@ class DBOSContext:
93
93
  self.assumed_role: Optional[str] = None
94
94
  self.step_status: Optional[StepStatus] = None
95
95
 
96
+ # A user-specified workflow timeout. Takes priority over a propagated deadline.
97
+ self.workflow_timeout_ms: Optional[int] = None
98
+ # A propagated workflow deadline.
99
+ self.workflow_deadline_epoch_ms: Optional[int] = None
100
+
96
101
  def create_child(self) -> DBOSContext:
97
102
  rv = DBOSContext()
98
103
  rv.logger = self.logger
@@ -360,11 +365,60 @@ class SetWorkflowID:
360
365
  return False # Did not handle
361
366
 
362
367
 
368
+ class SetWorkflowTimeout:
369
+ """
370
+ Set the workflow timeout (in seconds) to be used for the enclosed workflow invocations.
371
+
372
+ Typical Usage
373
+ ```
374
+ with SetWorkflowTimeout(<timeout in seconds>):
375
+ result = workflow_function(...)
376
+ ```
377
+ """
378
+
379
+ def __init__(self, workflow_timeout_sec: Optional[float]) -> None:
380
+ if workflow_timeout_sec and not workflow_timeout_sec > 0:
381
+ raise Exception(
382
+ f"Invalid workflow timeout {workflow_timeout_sec}. Timeouts must be positive."
383
+ )
384
+ self.created_ctx = False
385
+ self.workflow_timeout_ms = (
386
+ int(workflow_timeout_sec * 1000)
387
+ if workflow_timeout_sec is not None
388
+ else None
389
+ )
390
+ self.saved_workflow_timeout: Optional[int] = None
391
+
392
+ def __enter__(self) -> SetWorkflowTimeout:
393
+ # Code to create a basic context
394
+ ctx = get_local_dbos_context()
395
+ if ctx is None:
396
+ self.created_ctx = True
397
+ _set_local_dbos_context(DBOSContext())
398
+ ctx = assert_current_dbos_context()
399
+ self.saved_workflow_timeout = ctx.workflow_timeout_ms
400
+ ctx.workflow_timeout_ms = self.workflow_timeout_ms
401
+ return self
402
+
403
+ def __exit__(
404
+ self,
405
+ exc_type: Optional[Type[BaseException]],
406
+ exc_value: Optional[BaseException],
407
+ traceback: Optional[TracebackType],
408
+ ) -> Literal[False]:
409
+ assert_current_dbos_context().workflow_timeout_ms = self.saved_workflow_timeout
410
+ # Code to clean up the basic context if we created it
411
+ if self.created_ctx:
412
+ _clear_local_dbos_context()
413
+ return False # Did not handle
414
+
415
+
363
416
  class EnterDBOSWorkflow(AbstractContextManager[DBOSContext, Literal[False]]):
364
417
  def __init__(self, attributes: TracedAttributes) -> None:
365
418
  self.created_ctx = False
366
419
  self.attributes = attributes
367
420
  self.is_temp_workflow = attributes["name"] == "temp_wf"
421
+ self.saved_workflow_timeout: Optional[int] = None
368
422
 
369
423
  def __enter__(self) -> DBOSContext:
370
424
  # Code to create a basic context
@@ -374,6 +428,10 @@ class EnterDBOSWorkflow(AbstractContextManager[DBOSContext, Literal[False]]):
374
428
  ctx = DBOSContext()
375
429
  _set_local_dbos_context(ctx)
376
430
  assert not ctx.is_within_workflow()
431
+ # Unset the workflow_timeout_ms context var so it is not applied to this
432
+ # workflow's children (instead we propagate the deadline)
433
+ self.saved_workflow_timeout = ctx.workflow_timeout_ms
434
+ ctx.workflow_timeout_ms = None
377
435
  ctx.start_workflow(
378
436
  None, self.attributes, self.is_temp_workflow
379
437
  ) # Will get from the context's next workflow ID
@@ -388,6 +446,10 @@ class EnterDBOSWorkflow(AbstractContextManager[DBOSContext, Literal[False]]):
388
446
  ctx = assert_current_dbos_context()
389
447
  assert ctx.is_within_workflow()
390
448
  ctx.end_workflow(exc_value, self.is_temp_workflow)
449
+ # Restore the saved workflow timeout
450
+ ctx.workflow_timeout_ms = self.saved_workflow_timeout
451
+ # Clear any propagating timeout
452
+ ctx.workflow_deadline_epoch_ms = None
391
453
  # Code to clean up the basic context if we created it
392
454
  if self.created_ctx:
393
455
  _clear_local_dbos_context()
dbos/_core.py CHANGED
@@ -3,6 +3,7 @@ import functools
3
3
  import inspect
4
4
  import json
5
5
  import sys
6
+ import threading
6
7
  import time
7
8
  import traceback
8
9
  from concurrent.futures import Future
@@ -14,11 +15,9 @@ from typing import (
14
15
  Coroutine,
15
16
  Generic,
16
17
  Optional,
17
- Tuple,
18
18
  TypeVar,
19
19
  Union,
20
20
  cast,
21
- overload,
22
21
  )
23
22
 
24
23
  from dbos._outcome import Immediate, NoResult, Outcome, Pending
@@ -59,7 +58,6 @@ from ._error import (
59
58
  )
60
59
  from ._registrations import (
61
60
  DEFAULT_MAX_RECOVERY_ATTEMPTS,
62
- DBOSFuncInfo,
63
61
  get_config_name,
64
62
  get_dbos_class_name,
65
63
  get_dbos_func_name,
@@ -227,12 +225,14 @@ class WorkflowHandleAsyncPolling(Generic[R]):
227
225
  def _init_workflow(
228
226
  dbos: "DBOS",
229
227
  ctx: DBOSContext,
228
+ *,
230
229
  inputs: WorkflowInputs,
231
230
  wf_name: str,
232
231
  class_name: Optional[str],
233
232
  config_name: Optional[str],
234
- temp_wf_type: Optional[str],
235
233
  queue: Optional[str],
234
+ workflow_timeout_ms: Optional[int],
235
+ workflow_deadline_epoch_ms: Optional[int],
236
236
  max_recovery_attempts: Optional[int],
237
237
  ) -> WorkflowStatusInternal:
238
238
  wfid = (
@@ -240,6 +240,15 @@ def _init_workflow(
240
240
  if len(ctx.workflow_id) > 0
241
241
  else ctx.id_assigned_for_next_workflow
242
242
  )
243
+
244
+ # In debug mode, just return the existing status
245
+ if dbos.debug_mode:
246
+ get_status_result = dbos._sys_db.get_workflow_status(wfid)
247
+ if get_status_result is None:
248
+ raise DBOSNonExistentWorkflowError(wfid)
249
+ return get_status_result
250
+
251
+ # Initialize a workflow status object from the context
243
252
  status: WorkflowStatusInternal = {
244
253
  "workflow_uuid": wfid,
245
254
  "status": (
@@ -267,25 +276,47 @@ def _init_workflow(
267
276
  "queue_name": queue,
268
277
  "created_at": None,
269
278
  "updated_at": None,
279
+ "workflow_timeout_ms": workflow_timeout_ms,
280
+ "workflow_deadline_epoch_ms": workflow_deadline_epoch_ms,
270
281
  }
271
282
 
272
283
  # If we have a class name, the first arg is the instance and do not serialize
273
284
  if class_name is not None:
274
285
  inputs = {"args": inputs["args"][1:], "kwargs": inputs["kwargs"]}
275
286
 
276
- wf_status = status["status"]
277
- if dbos.debug_mode:
278
- get_status_result = dbos._sys_db.get_workflow_status(wfid)
279
- if get_status_result is None:
280
- raise DBOSNonExistentWorkflowError(wfid)
281
- wf_status = get_status_result["status"]
282
- else:
283
- wf_status = dbos._sys_db.init_workflow(
284
- status,
285
- _serialization.serialize_args(inputs),
286
- max_recovery_attempts=max_recovery_attempts,
287
- )
287
+ # Synchronously record the status and inputs for workflows
288
+ wf_status, workflow_deadline_epoch_ms = dbos._sys_db.init_workflow(
289
+ status,
290
+ _serialization.serialize_args(inputs),
291
+ max_recovery_attempts=max_recovery_attempts,
292
+ )
288
293
 
294
+ if workflow_deadline_epoch_ms is not None:
295
+ evt = threading.Event()
296
+ dbos.stop_events.append(evt)
297
+
298
+ def timeout_func() -> None:
299
+ try:
300
+ assert workflow_deadline_epoch_ms is not None
301
+ time_to_wait_sec = (
302
+ workflow_deadline_epoch_ms - (time.time() * 1000)
303
+ ) / 1000
304
+ if time_to_wait_sec > 0:
305
+ was_stopped = evt.wait(time_to_wait_sec)
306
+ if was_stopped:
307
+ return
308
+ dbos._sys_db.cancel_workflow(wfid)
309
+ except Exception as e:
310
+ dbos.logger.warning(
311
+ f"Exception in timeout thread for workflow {wfid}: {e}"
312
+ )
313
+
314
+ timeout_thread = threading.Thread(target=timeout_func, daemon=True)
315
+ timeout_thread.start()
316
+ dbos._background_threads.append(timeout_thread)
317
+
318
+ ctx.workflow_deadline_epoch_ms = workflow_deadline_epoch_ms
319
+ status["workflow_deadline_epoch_ms"] = workflow_deadline_epoch_ms
289
320
  status["status"] = wf_status
290
321
  return status
291
322
 
@@ -501,6 +532,13 @@ def start_workflow(
501
532
  "kwargs": kwargs,
502
533
  }
503
534
 
535
+ local_ctx = get_local_dbos_context()
536
+ workflow_timeout_ms, workflow_deadline_epoch_ms = _get_timeout_deadline(
537
+ local_ctx, queue_name
538
+ )
539
+ workflow_timeout_ms = (
540
+ local_ctx.workflow_timeout_ms if local_ctx is not None else None
541
+ )
504
542
  new_wf_id, new_wf_ctx = _get_new_wf()
505
543
 
506
544
  ctx = new_wf_ctx
@@ -519,8 +557,9 @@ def start_workflow(
519
557
  wf_name=get_dbos_func_name(func),
520
558
  class_name=get_dbos_class_name(fi, func, args),
521
559
  config_name=get_config_name(fi, func, args),
522
- temp_wf_type=get_temp_workflow_type(func),
523
560
  queue=queue_name,
561
+ workflow_timeout_ms=workflow_timeout_ms,
562
+ workflow_deadline_epoch_ms=workflow_deadline_epoch_ms,
524
563
  max_recovery_attempts=fi.max_recovery_attempts,
525
564
  )
526
565
 
@@ -583,6 +622,10 @@ async def start_workflow_async(
583
622
  "kwargs": kwargs,
584
623
  }
585
624
 
625
+ local_ctx = get_local_dbos_context()
626
+ workflow_timeout_ms, workflow_deadline_epoch_ms = _get_timeout_deadline(
627
+ local_ctx, queue_name
628
+ )
586
629
  new_wf_id, new_wf_ctx = _get_new_wf()
587
630
 
588
631
  ctx = new_wf_ctx
@@ -604,8 +647,9 @@ async def start_workflow_async(
604
647
  wf_name=get_dbos_func_name(func),
605
648
  class_name=get_dbos_class_name(fi, func, args),
606
649
  config_name=get_config_name(fi, func, args),
607
- temp_wf_type=get_temp_workflow_type(func),
608
650
  queue=queue_name,
651
+ workflow_timeout_ms=workflow_timeout_ms,
652
+ workflow_deadline_epoch_ms=workflow_deadline_epoch_ms,
609
653
  max_recovery_attempts=fi.max_recovery_attempts,
610
654
  )
611
655
 
@@ -680,6 +724,9 @@ def workflow_wrapper(
680
724
  "kwargs": kwargs,
681
725
  }
682
726
  ctx = get_local_dbos_context()
727
+ workflow_timeout_ms, workflow_deadline_epoch_ms = _get_timeout_deadline(
728
+ ctx, queue=None
729
+ )
683
730
  enterWorkflowCtxMgr = (
684
731
  EnterDBOSChildWorkflow if ctx and ctx.is_workflow() else EnterDBOSWorkflow
685
732
  )
@@ -717,8 +764,9 @@ def workflow_wrapper(
717
764
  wf_name=get_dbos_func_name(func),
718
765
  class_name=get_dbos_class_name(fi, func, args),
719
766
  config_name=get_config_name(fi, func, args),
720
- temp_wf_type=get_temp_workflow_type(func),
721
767
  queue=None,
768
+ workflow_timeout_ms=workflow_timeout_ms,
769
+ workflow_deadline_epoch_ms=workflow_deadline_epoch_ms,
722
770
  max_recovery_attempts=max_recovery_attempts,
723
771
  )
724
772
 
@@ -1212,3 +1260,24 @@ def get_event(
1212
1260
  else:
1213
1261
  # Directly call it outside of a workflow
1214
1262
  return dbos._sys_db.get_event(workflow_id, key, timeout_seconds)
1263
+
1264
+
1265
+ def _get_timeout_deadline(
1266
+ ctx: Optional[DBOSContext], queue: Optional[str]
1267
+ ) -> tuple[Optional[int], Optional[int]]:
1268
+ if ctx is None:
1269
+ return None, None
1270
+ # If a timeout is explicitly specified, use it over any propagated deadline
1271
+ if ctx.workflow_timeout_ms:
1272
+ if queue:
1273
+ # Queued workflows are assigned a deadline on dequeue
1274
+ return ctx.workflow_timeout_ms, None
1275
+ else:
1276
+ # Otherwise, compute the deadline immediately
1277
+ return (
1278
+ ctx.workflow_timeout_ms,
1279
+ int(time.time() * 1000) + ctx.workflow_timeout_ms,
1280
+ )
1281
+ # Otherwise, return the propagated deadline, if any
1282
+ else:
1283
+ return None, ctx.workflow_deadline_epoch_ms
@@ -0,0 +1,44 @@
1
+ """workflow_timeout
2
+
3
+ Revision ID: 83f3732ae8e7
4
+ Revises: f4b9b32ba814
5
+ Create Date: 2025-04-16 17:05:36.642395
6
+
7
+ """
8
+
9
+ from typing import Sequence, Union
10
+
11
+ import sqlalchemy as sa
12
+ from alembic import op
13
+
14
+ # revision identifiers, used by Alembic.
15
+ revision: str = "83f3732ae8e7"
16
+ down_revision: Union[str, None] = "f4b9b32ba814"
17
+ branch_labels: Union[str, Sequence[str], None] = None
18
+ depends_on: Union[str, Sequence[str], None] = None
19
+
20
+
21
+ def upgrade() -> None:
22
+ op.add_column(
23
+ "workflow_status",
24
+ sa.Column(
25
+ "workflow_timeout_ms",
26
+ sa.BigInteger(),
27
+ nullable=True,
28
+ ),
29
+ schema="dbos",
30
+ )
31
+ op.add_column(
32
+ "workflow_status",
33
+ sa.Column(
34
+ "workflow_deadline_epoch_ms",
35
+ sa.BigInteger(),
36
+ nullable=True,
37
+ ),
38
+ schema="dbos",
39
+ )
40
+
41
+
42
+ def downgrade() -> None:
43
+ op.drop_column("workflow_status", "workflow_deadline_epoch_ms", schema="dbos")
44
+ op.drop_column("workflow_status", "workflow_timeout_ms", schema="dbos")
@@ -54,7 +54,9 @@ class SystemSchema:
54
54
  nullable=True,
55
55
  server_default=text("'0'::bigint"),
56
56
  ),
57
- Column("queue_name", Text),
57
+ Column("queue_name", Text, nullable=True),
58
+ Column("workflow_timeout_ms", BigInteger, nullable=True),
59
+ Column("workflow_deadline_epoch_ms", BigInteger, nullable=True),
58
60
  Index("workflow_status_created_at_index", "created_at"),
59
61
  Index("workflow_status_executor_id_index", "executor_id"),
60
62
  )
dbos/_sys_db.py CHANGED
@@ -128,6 +128,11 @@ class WorkflowStatusInternal(TypedDict):
128
128
  app_version: Optional[str]
129
129
  app_id: Optional[str]
130
130
  recovery_attempts: Optional[int]
131
+ # The start-to-close timeout of the workflow in ms
132
+ workflow_timeout_ms: Optional[int]
133
+ # The deadline of a workflow, computed by adding its timeout to its start time.
134
+ # Deadlines propagate to children. When the deadline is reached, the workflow is cancelled.
135
+ workflow_deadline_epoch_ms: Optional[int]
131
136
 
132
137
 
133
138
  class RecordedResult(TypedDict):
@@ -328,10 +333,11 @@ class SystemDatabase:
328
333
  conn: sa.Connection,
329
334
  *,
330
335
  max_recovery_attempts: Optional[int],
331
- ) -> WorkflowStatuses:
336
+ ) -> tuple[WorkflowStatuses, Optional[int]]:
332
337
  if self._debug_mode:
333
338
  raise Exception("called insert_workflow_status in debug mode")
334
339
  wf_status: WorkflowStatuses = status["status"]
340
+ workflow_deadline_epoch_ms: Optional[int] = status["workflow_deadline_epoch_ms"]
335
341
 
336
342
  cmd = (
337
343
  pg.insert(SystemSchema.workflow_status)
@@ -354,6 +360,8 @@ class SystemDatabase:
354
360
  recovery_attempts=(
355
361
  1 if wf_status != WorkflowStatusString.ENQUEUED.value else 0
356
362
  ),
363
+ workflow_timeout_ms=status["workflow_timeout_ms"],
364
+ workflow_deadline_epoch_ms=status["workflow_deadline_epoch_ms"],
357
365
  )
358
366
  .on_conflict_do_update(
359
367
  index_elements=["workflow_uuid"],
@@ -367,7 +375,7 @@ class SystemDatabase:
367
375
  )
368
376
  )
369
377
 
370
- cmd = cmd.returning(SystemSchema.workflow_status.c.recovery_attempts, SystemSchema.workflow_status.c.status, SystemSchema.workflow_status.c.name, SystemSchema.workflow_status.c.class_name, SystemSchema.workflow_status.c.config_name, SystemSchema.workflow_status.c.queue_name) # type: ignore
378
+ cmd = cmd.returning(SystemSchema.workflow_status.c.recovery_attempts, SystemSchema.workflow_status.c.status, SystemSchema.workflow_status.c.workflow_deadline_epoch_ms, SystemSchema.workflow_status.c.name, SystemSchema.workflow_status.c.class_name, SystemSchema.workflow_status.c.config_name, SystemSchema.workflow_status.c.queue_name) # type: ignore
371
379
 
372
380
  results = conn.execute(cmd)
373
381
 
@@ -377,17 +385,18 @@ class SystemDatabase:
377
385
  # A mismatch indicates a workflow starting with the same UUID but different functions, which would throw an exception.
378
386
  recovery_attempts: int = row[0]
379
387
  wf_status = row[1]
388
+ workflow_deadline_epoch_ms = row[2]
380
389
  err_msg: Optional[str] = None
381
- if row[2] != status["name"]:
382
- err_msg = f"Workflow already exists with a different function name: {row[2]}, but the provided function name is: {status['name']}"
383
- elif row[3] != status["class_name"]:
384
- err_msg = f"Workflow already exists with a different class name: {row[3]}, but the provided class name is: {status['class_name']}"
385
- elif row[4] != status["config_name"]:
386
- err_msg = f"Workflow already exists with a different config name: {row[4]}, but the provided config name is: {status['config_name']}"
387
- elif row[5] != status["queue_name"]:
390
+ if row[3] != status["name"]:
391
+ err_msg = f"Workflow already exists with a different function name: {row[3]}, but the provided function name is: {status['name']}"
392
+ elif row[4] != status["class_name"]:
393
+ err_msg = f"Workflow already exists with a different class name: {row[4]}, but the provided class name is: {status['class_name']}"
394
+ elif row[5] != status["config_name"]:
395
+ err_msg = f"Workflow already exists with a different config name: {row[5]}, but the provided config name is: {status['config_name']}"
396
+ elif row[6] != status["queue_name"]:
388
397
  # This is a warning because a different queue name is not necessarily an error.
389
398
  dbos_logger.warning(
390
- f"Workflow already exists in queue: {row[5]}, but the provided queue name is: {status['queue_name']}. The queue is not updated."
399
+ f"Workflow already exists in queue: {row[6]}, but the provided queue name is: {status['queue_name']}. The queue is not updated."
391
400
  )
392
401
  if err_msg is not None:
393
402
  raise DBOSConflictingWorkflowError(status["workflow_uuid"], err_msg)
@@ -427,7 +436,7 @@ class SystemDatabase:
427
436
  status["workflow_uuid"], max_recovery_attempts
428
437
  )
429
438
 
430
- return wf_status
439
+ return wf_status, workflow_deadline_epoch_ms
431
440
 
432
441
  def update_workflow_status(
433
442
  self,
@@ -485,6 +494,18 @@ class SystemDatabase:
485
494
  if self._debug_mode:
486
495
  raise Exception("called cancel_workflow in debug mode")
487
496
  with self.engine.begin() as c:
497
+ # Check the status of the workflow. If it is complete, do nothing.
498
+ row = c.execute(
499
+ sa.select(
500
+ SystemSchema.workflow_status.c.status,
501
+ ).where(SystemSchema.workflow_status.c.workflow_uuid == workflow_id)
502
+ ).fetchone()
503
+ if (
504
+ row is None
505
+ or row[0] == WorkflowStatusString.SUCCESS.value
506
+ or row[0] == WorkflowStatusString.ERROR.value
507
+ ):
508
+ return
488
509
  # Remove the workflow from the queues table so it does not block the table
489
510
  c.execute(
490
511
  sa.delete(SystemSchema.workflow_queue).where(
@@ -531,11 +552,15 @@ class SystemDatabase:
531
552
  queue_name=INTERNAL_QUEUE_NAME,
532
553
  )
533
554
  )
534
- # Set the workflow's status to ENQUEUED and clear its recovery attempts.
555
+ # Set the workflow's status to ENQUEUED and clear its recovery attempts and deadline.
535
556
  c.execute(
536
557
  sa.update(SystemSchema.workflow_status)
537
558
  .where(SystemSchema.workflow_status.c.workflow_uuid == workflow_id)
538
- .values(status=WorkflowStatusString.ENQUEUED.value, recovery_attempts=0)
559
+ .values(
560
+ status=WorkflowStatusString.ENQUEUED.value,
561
+ recovery_attempts=0,
562
+ workflow_deadline_epoch_ms=None,
563
+ )
539
564
  )
540
565
 
541
566
  def get_max_function_id(self, workflow_uuid: str) -> Optional[int]:
@@ -648,6 +673,8 @@ class SystemDatabase:
648
673
  SystemSchema.workflow_status.c.updated_at,
649
674
  SystemSchema.workflow_status.c.application_version,
650
675
  SystemSchema.workflow_status.c.application_id,
676
+ SystemSchema.workflow_status.c.workflow_deadline_epoch_ms,
677
+ SystemSchema.workflow_status.c.workflow_timeout_ms,
651
678
  ).where(SystemSchema.workflow_status.c.workflow_uuid == workflow_uuid)
652
679
  ).fetchone()
653
680
  if row is None:
@@ -671,6 +698,8 @@ class SystemDatabase:
671
698
  "updated_at": row[12],
672
699
  "app_version": row[13],
673
700
  "app_id": row[14],
701
+ "workflow_deadline_epoch_ms": row[15],
702
+ "workflow_timeout_ms": row[16],
674
703
  }
675
704
  return status
676
705
 
@@ -1100,50 +1129,56 @@ class SystemDatabase:
1100
1129
  *,
1101
1130
  conn: Optional[sa.Connection] = None,
1102
1131
  ) -> Optional[RecordedResult]:
1103
- # Retrieve the status of the workflow. Additionally, if this step
1104
- # has run before, retrieve its name, output, and error.
1105
- sql = (
1106
- sa.select(
1107
- SystemSchema.workflow_status.c.status,
1108
- SystemSchema.operation_outputs.c.output,
1109
- SystemSchema.operation_outputs.c.error,
1110
- SystemSchema.operation_outputs.c.function_name,
1111
- )
1112
- .select_from(
1113
- SystemSchema.workflow_status.outerjoin(
1114
- SystemSchema.operation_outputs,
1115
- (
1116
- SystemSchema.workflow_status.c.workflow_uuid
1117
- == SystemSchema.operation_outputs.c.workflow_uuid
1118
- )
1119
- & (SystemSchema.operation_outputs.c.function_id == function_id),
1120
- )
1121
- )
1122
- .where(SystemSchema.workflow_status.c.workflow_uuid == workflow_id)
1132
+ # First query: Retrieve the workflow status
1133
+ workflow_status_sql = sa.select(
1134
+ SystemSchema.workflow_status.c.status,
1135
+ ).where(SystemSchema.workflow_status.c.workflow_uuid == workflow_id)
1136
+
1137
+ # Second query: Retrieve operation outputs if they exist
1138
+ operation_output_sql = sa.select(
1139
+ SystemSchema.operation_outputs.c.output,
1140
+ SystemSchema.operation_outputs.c.error,
1141
+ SystemSchema.operation_outputs.c.function_name,
1142
+ ).where(
1143
+ (SystemSchema.operation_outputs.c.workflow_uuid == workflow_id)
1144
+ & (SystemSchema.operation_outputs.c.function_id == function_id)
1123
1145
  )
1124
- # If in a transaction, use the provided connection
1125
- rows: Sequence[Any]
1146
+
1147
+ # Execute both queries
1126
1148
  if conn is not None:
1127
- rows = conn.execute(sql).all()
1149
+ workflow_status_rows = conn.execute(workflow_status_sql).all()
1150
+ operation_output_rows = conn.execute(operation_output_sql).all()
1128
1151
  else:
1129
1152
  with self.engine.begin() as c:
1130
- rows = c.execute(sql).all()
1131
- assert len(rows) > 0, f"Error: Workflow {workflow_id} does not exist"
1132
- workflow_status, output, error, recorded_function_name = (
1133
- rows[0][0],
1134
- rows[0][1],
1135
- rows[0][2],
1136
- rows[0][3],
1137
- )
1153
+ workflow_status_rows = c.execute(workflow_status_sql).all()
1154
+ operation_output_rows = c.execute(operation_output_sql).all()
1155
+
1156
+ # Check if the workflow exists
1157
+ assert (
1158
+ len(workflow_status_rows) > 0
1159
+ ), f"Error: Workflow {workflow_id} does not exist"
1160
+
1161
+ # Get workflow status
1162
+ workflow_status = workflow_status_rows[0][0]
1163
+
1138
1164
  # If the workflow is cancelled, raise the exception
1139
1165
  if workflow_status == WorkflowStatusString.CANCELLED.value:
1140
1166
  raise DBOSWorkflowCancelledError(
1141
1167
  f"Workflow {workflow_id} is cancelled. Aborting function."
1142
1168
  )
1143
- # If there is no row for the function, return None
1144
- if recorded_function_name is None:
1169
+
1170
+ # If there are no operation outputs, return None
1171
+ if not operation_output_rows:
1145
1172
  return None
1146
- # If the provided and recorded function name are different, throw an exception.
1173
+
1174
+ # Extract operation output data
1175
+ output, error, recorded_function_name = (
1176
+ operation_output_rows[0][0],
1177
+ operation_output_rows[0][1],
1178
+ operation_output_rows[0][2],
1179
+ )
1180
+
1181
+ # If the provided and recorded function name are different, throw an exception
1147
1182
  if function_name != recorded_function_name:
1148
1183
  raise DBOSUnexpectedStepError(
1149
1184
  workflow_id=workflow_id,
@@ -1151,6 +1186,7 @@ class SystemDatabase:
1151
1186
  expected_name=function_name,
1152
1187
  recorded_name=recorded_function_name,
1153
1188
  )
1189
+
1154
1190
  result: RecordedResult = {
1155
1191
  "output": output,
1156
1192
  "error": error,
@@ -1699,6 +1735,17 @@ class SystemDatabase:
1699
1735
  status=WorkflowStatusString.PENDING.value,
1700
1736
  application_version=app_version,
1701
1737
  executor_id=executor_id,
1738
+ # If a timeout is set, set the deadline on dequeue
1739
+ workflow_deadline_epoch_ms=sa.case(
1740
+ (
1741
+ SystemSchema.workflow_status.c.workflow_timeout_ms.isnot(
1742
+ None
1743
+ ),
1744
+ sa.func.extract("epoch", sa.func.now()) * 1000
1745
+ + SystemSchema.workflow_status.c.workflow_timeout_ms,
1746
+ ),
1747
+ else_=SystemSchema.workflow_status.c.workflow_deadline_epoch_ms,
1748
+ ),
1702
1749
  )
1703
1750
  )
1704
1751
  if res.rowcount > 0:
@@ -1821,12 +1868,12 @@ class SystemDatabase:
1821
1868
  inputs: str,
1822
1869
  *,
1823
1870
  max_recovery_attempts: Optional[int],
1824
- ) -> WorkflowStatuses:
1871
+ ) -> tuple[WorkflowStatuses, Optional[int]]:
1825
1872
  """
1826
1873
  Synchronously record the status and inputs for workflows in a single transaction
1827
1874
  """
1828
1875
  with self.engine.begin() as conn:
1829
- wf_status = self.insert_workflow_status(
1876
+ wf_status, workflow_deadline_epoch_ms = self.insert_workflow_status(
1830
1877
  status, conn, max_recovery_attempts=max_recovery_attempts
1831
1878
  )
1832
1879
  # TODO: Modify the inputs if they were changed by `update_workflow_inputs`
@@ -1837,7 +1884,7 @@ class SystemDatabase:
1837
1884
  and wf_status == WorkflowStatusString.ENQUEUED.value
1838
1885
  ):
1839
1886
  self.enqueue(status["workflow_uuid"], status["queue_name"], conn)
1840
- return wf_status
1887
+ return wf_status, workflow_deadline_epoch_ms
1841
1888
 
1842
1889
 
1843
1890
  def reset_system_database(config: ConfigFile) -> None:
@@ -24,43 +24,51 @@
24
24
  "additionalProperties": false,
25
25
  "properties": {
26
26
  "hostname": {
27
- "type": "string",
28
- "description": "The hostname or IP address of the application database"
27
+ "type": ["string", "null"],
28
+ "description": "The hostname or IP address of the application database",
29
+ "deprecated": true
29
30
  },
30
31
  "port": {
31
- "type": "number",
32
- "description": "The port number of the application database"
32
+ "type": ["number", "null"],
33
+ "description": "The port number of the application database",
34
+ "deprecated": true
33
35
  },
34
36
  "username": {
35
- "type": "string",
37
+ "type": ["string", "null"],
36
38
  "description": "The username to use when connecting to the application database",
37
39
  "not": {
38
40
  "enum": ["dbos"]
39
- }
41
+ },
42
+ "deprecated": true
40
43
  },
41
44
  "password": {
42
45
  "type": ["string", "null"],
43
- "description": "The password to use when connecting to the application database. Developers are strongly encouraged to use environment variable substitution (${VAR_NAME}) or Docker secrets (${DOCKER_SECRET:SECRET_NAME}) to avoid storing secrets in source."
46
+ "description": "The password to use when connecting to the application database. Developers are strongly encouraged to use environment variable substitution (${VAR_NAME}) or Docker secrets (${DOCKER_SECRET:SECRET_NAME}) to avoid storing secrets in source.",
47
+ "deprecated": true
44
48
  },
45
49
  "connectionTimeoutMillis": {
46
- "type": "number",
47
- "description": "The number of milliseconds the system waits before timing out when connecting to the application database"
50
+ "type": ["number", "null"],
51
+ "description": "The number of milliseconds the system waits before timing out when connecting to the application database",
52
+ "deprecated": true
48
53
  },
49
54
  "app_db_name": {
50
- "type": "string",
51
- "description": "The name of the application database"
55
+ "type": ["string", "null"],
56
+ "description": "The name of the application database",
57
+ "deprecated": true
52
58
  },
53
59
  "sys_db_name": {
54
60
  "type": "string",
55
61
  "description": "The name of the system database"
56
62
  },
57
63
  "ssl": {
58
- "type": "boolean",
59
- "description": "Use SSL/TLS to securely connect to the database (default: true)"
64
+ "type": ["boolean", "null"],
65
+ "description": "Use SSL/TLS to securely connect to the database (default: true)",
66
+ "deprecated": true
60
67
  },
61
68
  "ssl_ca": {
62
- "type": "string",
63
- "description": "If using SSL/TLS to securely connect to a database, path to an SSL root certificate file"
69
+ "type": ["string", "null"],
70
+ "description": "If using SSL/TLS to securely connect to a database, path to an SSL root certificate file",
71
+ "deprecated": true
64
72
  },
65
73
  "app_db_client": {
66
74
  "type": "string",
@@ -78,7 +86,8 @@
78
86
  },
79
87
  "rollback": {
80
88
  "type": "array",
81
- "description": "Specify a list of user DB rollback commands to run"
89
+ "description": "Specify a list of user DB rollback commands to run",
90
+ "deprecated": true
82
91
  }
83
92
  }
84
93
  },
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: dbos
3
- Version: 0.26.0a21
3
+ Version: 0.26.0a23
4
4
  Summary: Ultra-lightweight durable execution in Python
5
5
  Author-Email: "DBOS, Inc." <contact@dbos.dev>
6
6
  License: MIT
@@ -1,17 +1,17 @@
1
- dbos-0.26.0a21.dist-info/METADATA,sha256=6JPLTUn5uCaKpHI_sEis2zJWOrNqsRUIQ2_4h7ZzWfw,5554
2
- dbos-0.26.0a21.dist-info/WHEEL,sha256=tSfRZzRHthuv7vxpI4aehrdN9scLjk-dCJkPLzkHxGg,90
3
- dbos-0.26.0a21.dist-info/entry_points.txt,sha256=_QOQ3tVfEjtjBlr1jS4sHqHya9lI2aIEIWkz8dqYp14,58
4
- dbos-0.26.0a21.dist-info/licenses/LICENSE,sha256=VGZit_a5-kdw9WT6fY5jxAWVwGQzgLFyPWrcVVUhVNU,1067
1
+ dbos-0.26.0a23.dist-info/METADATA,sha256=jXiNGE_gmy2H6gw4CoiC3fczU-7acoz-QxJ1EWUnnjQ,5554
2
+ dbos-0.26.0a23.dist-info/WHEEL,sha256=tSfRZzRHthuv7vxpI4aehrdN9scLjk-dCJkPLzkHxGg,90
3
+ dbos-0.26.0a23.dist-info/entry_points.txt,sha256=_QOQ3tVfEjtjBlr1jS4sHqHya9lI2aIEIWkz8dqYp14,58
4
+ dbos-0.26.0a23.dist-info/licenses/LICENSE,sha256=VGZit_a5-kdw9WT6fY5jxAWVwGQzgLFyPWrcVVUhVNU,1067
5
5
  dbos/__init__.py,sha256=VoGS7H9GVtNAnD2S4zseIEioS1dNIJXRovQ4oHlg8og,842
6
6
  dbos/__main__.py,sha256=G7Exn-MhGrVJVDbgNlpzhfh8WMX_72t3_oJaFT9Lmt8,653
7
7
  dbos/_admin_server.py,sha256=RrbABfR1D3p9c_QLrCSrgFuYce6FKi0fjMRIYLjO_Y8,9038
8
8
  dbos/_app_db.py,sha256=obNlgC9IZ20y8tqQeA1q4TjceG3jBFalxz70ieDOWCA,11332
9
9
  dbos/_classproperty.py,sha256=f0X-_BySzn3yFDRKB2JpCbLYQ9tLwt1XftfshvY7CBs,626
10
- dbos/_client.py,sha256=S3tejQ7xAJ9wjo1PhQ0P3UYuloDOdZqXsQwE4YjAQ8s,12124
10
+ dbos/_client.py,sha256=f1n5bbtVO-Mf5dDvI3sNlozxHSUfstWtgPirSqv1kpE,12518
11
11
  dbos/_conductor/conductor.py,sha256=HYzVL29IMMrs2Mnms_7cHJynCnmmEN5SDQOMjzn3UoU,16840
12
12
  dbos/_conductor/protocol.py,sha256=zEKIuOQdIaSduNqfZKpo8PSD9_1oNpKIPnBNCu3RUyE,6681
13
- dbos/_context.py,sha256=I8sLkdKTTkZEz7wG-MjynaQB6XEF2bLXuwNksiauP7w,19430
14
- dbos/_core.py,sha256=SecObOKLjNinNAXDcYVMVUURHcoaPe0js-axLMMNwqY,45098
13
+ dbos/_context.py,sha256=aHzJxO7LLAz9w3G2dkZnOcFW_GG-Yaxd02AaoLu4Et8,21861
14
+ dbos/_core.py,sha256=ylTVSv02h2M5SmDgYEJAZmNiKX35zPq0z-9WA-f4byY,47900
15
15
  dbos/_croniter.py,sha256=XHAyUyibs_59sJQfSNWkP7rqQY6_XrlfuuCxk4jYqek,47559
16
16
  dbos/_dbos.py,sha256=bbio_FjBfU__Zk1BFegfS16IrPPejFxOKm5rUg5nW1o,47185
17
17
  dbos/_dbos_config.py,sha256=m05IFjM0jSwZBsnFMF_4qP2JkjVFc0gqyM2tnotXq20,20636
@@ -29,6 +29,7 @@ dbos/_migrations/script.py.mako,sha256=MEqL-2qATlST9TAOeYgscMn1uy6HUS9NFvDgl93dM
29
29
  dbos/_migrations/versions/04ca4f231047_workflow_queues_executor_id.py,sha256=ICLPl8CN9tQXMsLDsAj8z1TsL831-Z3F8jSBvrR-wyw,736
30
30
  dbos/_migrations/versions/50f3227f0b4b_fix_job_queue.py,sha256=ZBYrtTdxy64HxIAlOes89fVIk2P1gNaJack7wuC_epg,873
31
31
  dbos/_migrations/versions/5c361fc04708_added_system_tables.py,sha256=Xr9hBDJjkAtymlauOmAy00yUHj0VVUaEz7kNwEM9IwE,6403
32
+ dbos/_migrations/versions/83f3732ae8e7_workflow_timeout.py,sha256=Q_R35pb8AfVI3sg5mzKwyoPfYB88Ychcc8gwxpM9R7A,1035
32
33
  dbos/_migrations/versions/a3b18ad34abe_added_triggers.py,sha256=Rv0ZsZYZ_WdgGEULYsPfnp4YzaO5L198gDTgYY39AVA,2022
33
34
  dbos/_migrations/versions/d76646551a6b_job_queue_limiter.py,sha256=8PyFi8rd6CN-mUro43wGhsg5wcQWKZPRHD6jw8R5pVc,986
34
35
  dbos/_migrations/versions/d76646551a6c_workflow_queue.py,sha256=G942nophZ2uC2vc4hGBC02Ptng1715roTjY3xiyzZU4,729
@@ -43,9 +44,9 @@ dbos/_roles.py,sha256=iOsgmIAf1XVzxs3gYWdGRe1B880YfOw5fpU7Jwx8_A8,2271
43
44
  dbos/_scheduler.py,sha256=SR1oRZRcVzYsj-JauV2LA8JtwTkt8mru7qf6H1AzQ1U,2027
44
45
  dbos/_schemas/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
45
46
  dbos/_schemas/application_database.py,sha256=SypAS9l9EsaBHFn9FR8jmnqt01M74d9AF1AMa4m2hhI,1040
46
- dbos/_schemas/system_database.py,sha256=W9eSpL7SZzQkxcEZ4W07BOcwkkDr35b9oCjUOgfHWek,5336
47
+ dbos/_schemas/system_database.py,sha256=aChSK7uLECD-v-7BZeOfuZFbtWayllaS3PaowaKDHwY,5490
47
48
  dbos/_serialization.py,sha256=YCYv0qKAwAZ1djZisBC7khvKqG-5OcIv9t9EC5PFIog,1743
48
- dbos/_sys_db.py,sha256=M3BVJVhG0YXkLhw5axSrKjBN1AOS3KmvgWEYn2l94pw,78203
49
+ dbos/_sys_db.py,sha256=SjYTleSEPtZVrPRimgXKeIvTjY8VN9G9jlgbcPT8ghg,80631
49
50
  dbos/_templates/dbos-db-starter/README.md,sha256=GhxhBj42wjTt1fWEtwNriHbJuKb66Vzu89G4pxNHw2g,930
50
51
  dbos/_templates/dbos-db-starter/__package/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
51
52
  dbos/_templates/dbos-db-starter/__package/main.py,sha256=nJMN3ZD2lmwg4Dcgmiwqc-tQGuCJuJal2Xl85iA277U,2453
@@ -62,7 +63,7 @@ dbos/_workflow_commands.py,sha256=7wyxTfIyh2IVIqlkaTr8CMBq8yxWP3Hhddyv1YJY8zE,35
62
63
  dbos/cli/_github_init.py,sha256=Y_bDF9gfO2jB1id4FV5h1oIxEJRWyqVjhb7bNEa5nQ0,3224
63
64
  dbos/cli/_template_init.py,sha256=-WW3kbq0W_Tq4WbMqb1UGJG3xvJb3woEY5VspG95Srk,2857
64
65
  dbos/cli/cli.py,sha256=1qCTs__A9LOEfU44XZ6TufwmRwe68ZEwbWEPli3vnVM,17873
65
- dbos/dbos-config.schema.json,sha256=i7jcxXqByKq0Jzv3nAUavONtj03vTwj6vWP4ylmBr8o,5694
66
+ dbos/dbos-config.schema.json,sha256=3EfMhI83kmQmhRNIYgtbila0zL28GX9huYALzbQyABw,6052
66
67
  dbos/py.typed,sha256=QfzXT1Ktfk3Rj84akygc7_42z0lRpCq0Ilh8OXI6Zas,44
67
68
  version/__init__.py,sha256=L4sNxecRuqdtSFdpUGX3TtBi9KL3k7YsZVIvv-fv9-A,1678
68
- dbos-0.26.0a21.dist-info/RECORD,,
69
+ dbos-0.26.0a23.dist-info/RECORD,,