dbos 0.26.0a21__py3-none-any.whl → 0.26.0a22__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 +9 -0
- dbos/_context.py +62 -0
- dbos/_core.py +88 -19
- dbos/_migrations/versions/83f3732ae8e7_workflow_timeout.py +44 -0
- dbos/_schemas/system_database.py +3 -1
- dbos/_sys_db.py +97 -50
- {dbos-0.26.0a21.dist-info → dbos-0.26.0a22.dist-info}/METADATA +1 -1
- {dbos-0.26.0a21.dist-info → dbos-0.26.0a22.dist-info}/RECORD +11 -10
- {dbos-0.26.0a21.dist-info → dbos-0.26.0a22.dist-info}/WHEEL +0 -0
- {dbos-0.26.0a21.dist-info → dbos-0.26.0a22.dist-info}/entry_points.txt +0 -0
- {dbos-0.26.0a21.dist-info → dbos-0.26.0a22.dist-info}/licenses/LICENSE +0 -0
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
|
-
|
277
|
-
|
278
|
-
|
279
|
-
|
280
|
-
|
281
|
-
|
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")
|
dbos/_schemas/system_database.py
CHANGED
@@ -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[
|
382
|
-
err_msg = f"Workflow already exists with a different function name: {row[
|
383
|
-
elif row[
|
384
|
-
err_msg = f"Workflow already exists with a different class name: {row[
|
385
|
-
elif row[
|
386
|
-
err_msg = f"Workflow already exists with a different config name: {row[
|
387
|
-
elif row[
|
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[
|
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(
|
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
|
-
#
|
1104
|
-
|
1105
|
-
|
1106
|
-
|
1107
|
-
|
1108
|
-
|
1109
|
-
|
1110
|
-
|
1111
|
-
|
1112
|
-
.
|
1113
|
-
|
1114
|
-
|
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
|
-
|
1125
|
-
|
1146
|
+
|
1147
|
+
# Execute both queries
|
1126
1148
|
if conn is not None:
|
1127
|
-
|
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
|
-
|
1131
|
-
|
1132
|
-
|
1133
|
-
|
1134
|
-
|
1135
|
-
|
1136
|
-
|
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
|
-
|
1144
|
-
|
1169
|
+
|
1170
|
+
# If there are no operation outputs, return None
|
1171
|
+
if not operation_output_rows:
|
1145
1172
|
return None
|
1146
|
-
|
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:
|
@@ -1,17 +1,17 @@
|
|
1
|
-
dbos-0.26.
|
2
|
-
dbos-0.26.
|
3
|
-
dbos-0.26.
|
4
|
-
dbos-0.26.
|
1
|
+
dbos-0.26.0a22.dist-info/METADATA,sha256=Tc4eDxO8J5OnULO6LrEY6mzhVwYZfmFN_okn3ZnSRLc,5554
|
2
|
+
dbos-0.26.0a22.dist-info/WHEEL,sha256=tSfRZzRHthuv7vxpI4aehrdN9scLjk-dCJkPLzkHxGg,90
|
3
|
+
dbos-0.26.0a22.dist-info/entry_points.txt,sha256=_QOQ3tVfEjtjBlr1jS4sHqHya9lI2aIEIWkz8dqYp14,58
|
4
|
+
dbos-0.26.0a22.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=
|
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=
|
14
|
-
dbos/_core.py,sha256=
|
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=
|
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=
|
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
|
@@ -65,4 +66,4 @@ dbos/cli/cli.py,sha256=1qCTs__A9LOEfU44XZ6TufwmRwe68ZEwbWEPli3vnVM,17873
|
|
65
66
|
dbos/dbos-config.schema.json,sha256=i7jcxXqByKq0Jzv3nAUavONtj03vTwj6vWP4ylmBr8o,5694
|
66
67
|
dbos/py.typed,sha256=QfzXT1Ktfk3Rj84akygc7_42z0lRpCq0Ilh8OXI6Zas,44
|
67
68
|
version/__init__.py,sha256=L4sNxecRuqdtSFdpUGX3TtBi9KL3k7YsZVIvv-fv9-A,1678
|
68
|
-
dbos-0.26.
|
69
|
+
dbos-0.26.0a22.dist-info/RECORD,,
|
File without changes
|
File without changes
|
File without changes
|