dbos 0.18.0a1__py3-none-any.whl → 0.19.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.

Potentially problematic release.


This version of dbos might be problematic. Click here for more details.

dbos/_sys_db.py CHANGED
@@ -13,7 +13,6 @@ from typing import (
13
13
  Optional,
14
14
  Sequence,
15
15
  Set,
16
- Tuple,
17
16
  TypedDict,
18
17
  cast,
19
18
  )
@@ -23,12 +22,15 @@ import sqlalchemy as sa
23
22
  import sqlalchemy.dialects.postgresql as pg
24
23
  from alembic import command
25
24
  from alembic.config import Config
25
+ from sqlalchemy import or_
26
26
  from sqlalchemy.exc import DBAPIError
27
27
 
28
28
  from . import _serialization
29
29
  from ._dbos_config import ConfigFile
30
30
  from ._error import (
31
+ DBOSConflictingWorkflowError,
31
32
  DBOSDeadLetterQueueError,
33
+ DBOSException,
32
34
  DBOSNonExistentWorkflowError,
33
35
  DBOSWorkflowConflictIDError,
34
36
  )
@@ -249,7 +251,9 @@ class SystemDatabase:
249
251
  *,
250
252
  conn: Optional[sa.Connection] = None,
251
253
  max_recovery_attempts: int = DEFAULT_MAX_RECOVERY_ATTEMPTS,
252
- ) -> None:
254
+ ) -> WorkflowStatuses:
255
+ wf_status: WorkflowStatuses = status["status"]
256
+
253
257
  cmd = pg.insert(SystemSchema.workflow_status).values(
254
258
  workflow_uuid=status["workflow_uuid"],
255
259
  status=status["status"],
@@ -285,49 +289,75 @@ class SystemDatabase:
285
289
  ),
286
290
  )
287
291
  else:
288
- cmd = cmd.on_conflict_do_nothing()
289
- cmd = cmd.returning(SystemSchema.workflow_status.c.recovery_attempts) # type: ignore
292
+ # A blank update so that we can return the existing status
293
+ cmd = cmd.on_conflict_do_update(
294
+ index_elements=["workflow_uuid"],
295
+ set_=dict(
296
+ recovery_attempts=SystemSchema.workflow_status.c.recovery_attempts
297
+ ),
298
+ )
299
+ 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
290
300
 
291
301
  if conn is not None:
292
302
  results = conn.execute(cmd)
293
303
  else:
294
304
  with self.engine.begin() as c:
295
305
  results = c.execute(cmd)
296
- if in_recovery:
297
- row = results.fetchone()
298
- if row is not None:
299
- recovery_attempts: int = row[0]
300
- if recovery_attempts > max_recovery_attempts:
301
- with self.engine.begin() as c:
302
- c.execute(
303
- sa.delete(SystemSchema.workflow_queue).where(
304
- SystemSchema.workflow_queue.c.workflow_uuid
305
- == status["workflow_uuid"]
306
- )
306
+
307
+ row = results.fetchone()
308
+ if row is not None:
309
+ # Check the started workflow matches the expected name, class_name, config_name, and queue_name
310
+ # A mismatch indicates a workflow starting with the same UUID but different functions, which would throw an exception.
311
+ recovery_attempts: int = row[0]
312
+ wf_status = row[1]
313
+ err_msg: Optional[str] = None
314
+ if row[2] != status["name"]:
315
+ err_msg = f"Workflow already exists with a different function name: {row[2]}, but the provided function name is: {status['name']}"
316
+ elif row[3] != status["class_name"]:
317
+ err_msg = f"Workflow already exists with a different class name: {row[3]}, but the provided class name is: {status['class_name']}"
318
+ elif row[4] != status["config_name"]:
319
+ err_msg = f"Workflow already exists with a different config name: {row[4]}, but the provided config name is: {status['config_name']}"
320
+ elif row[5] != status["queue_name"]:
321
+ # This is a warning because a different queue name is not necessarily an error.
322
+ dbos_logger.warning(
323
+ f"Workflow already exists in queue: {row[5]}, but the provided queue name is: {status['queue_name']}. The queue is not updated."
324
+ )
325
+ if err_msg is not None:
326
+ raise DBOSConflictingWorkflowError(status["workflow_uuid"], err_msg)
327
+
328
+ if in_recovery and recovery_attempts > max_recovery_attempts:
329
+ with self.engine.begin() as c:
330
+ c.execute(
331
+ sa.delete(SystemSchema.workflow_queue).where(
332
+ SystemSchema.workflow_queue.c.workflow_uuid
333
+ == status["workflow_uuid"]
334
+ )
335
+ )
336
+ c.execute(
337
+ sa.update(SystemSchema.workflow_status)
338
+ .where(
339
+ SystemSchema.workflow_status.c.workflow_uuid
340
+ == status["workflow_uuid"]
341
+ )
342
+ .where(
343
+ SystemSchema.workflow_status.c.status
344
+ == WorkflowStatusString.PENDING.value
307
345
  )
308
- c.execute(
309
- sa.update(SystemSchema.workflow_status)
310
- .where(
311
- SystemSchema.workflow_status.c.workflow_uuid
312
- == status["workflow_uuid"]
313
- )
314
- .where(
315
- SystemSchema.workflow_status.c.status
316
- == WorkflowStatusString.PENDING.value
317
- )
318
- .values(
319
- status=WorkflowStatusString.RETRIES_EXCEEDED.value,
320
- queue_name=None,
321
- )
346
+ .values(
347
+ status=WorkflowStatusString.RETRIES_EXCEEDED.value,
348
+ queue_name=None,
322
349
  )
323
- raise DBOSDeadLetterQueueError(
324
- status["workflow_uuid"], max_recovery_attempts
325
350
  )
351
+ raise DBOSDeadLetterQueueError(
352
+ status["workflow_uuid"], max_recovery_attempts
353
+ )
326
354
 
327
355
  # Record we have exported status for this single-transaction workflow
328
356
  if status["workflow_uuid"] in self._temp_txn_wf_ids:
329
357
  self._exported_temp_txn_wf_status.add(status["workflow_uuid"])
330
358
 
359
+ return wf_status
360
+
331
361
  def set_workflow_status(
332
362
  self,
333
363
  workflow_uuid: str,
@@ -349,7 +379,7 @@ class SystemDatabase:
349
379
  stmt = (
350
380
  sa.update(SystemSchema.workflow_status)
351
381
  .where(
352
- SystemSchema.workflow_inputs.c.workflow_uuid == workflow_uuid
382
+ SystemSchema.workflow_status.c.workflow_uuid == workflow_uuid
353
383
  )
354
384
  .values(recovery_attempts=reset_recovery_attempts)
355
385
  )
@@ -405,7 +435,10 @@ class SystemDatabase:
405
435
  res["output"]
406
436
  )
407
437
  return resstat
408
- return None
438
+ else:
439
+ raise DBOSException(
440
+ "Workflow status record not found. This should not happen! \033[1m Hint: Check if your workflow is deterministic.\033[0m"
441
+ )
409
442
  stat = self.get_workflow_status(workflow_uuid)
410
443
  self.record_operation_result(
411
444
  {
@@ -528,18 +561,27 @@ class SystemDatabase:
528
561
  workflow_uuid=workflow_uuid,
529
562
  inputs=inputs,
530
563
  )
531
- .on_conflict_do_nothing()
564
+ .on_conflict_do_update(
565
+ index_elements=["workflow_uuid"],
566
+ set_=dict(workflow_uuid=SystemSchema.workflow_inputs.c.workflow_uuid),
567
+ )
568
+ .returning(SystemSchema.workflow_inputs.c.inputs)
532
569
  )
533
570
  if conn is not None:
534
- conn.execute(cmd)
571
+ row = conn.execute(cmd).fetchone()
535
572
  else:
536
573
  with self.engine.begin() as c:
537
- c.execute(cmd)
538
-
574
+ row = c.execute(cmd).fetchone()
575
+ if row is not None and row[0] != inputs:
576
+ dbos_logger.warning(
577
+ f"Workflow inputs for {workflow_uuid} changed since the first call! Use the original inputs."
578
+ )
579
+ # TODO: actually changing the input
539
580
  if workflow_uuid in self._temp_txn_wf_ids:
540
581
  # Clean up the single-transaction tracking sets
541
582
  self._exported_temp_txn_wf_status.discard(workflow_uuid)
542
583
  self._temp_txn_wf_ids.discard(workflow_uuid)
584
+ return
543
585
 
544
586
  def get_workflow_inputs(
545
587
  self, workflow_uuid: str
@@ -572,12 +614,12 @@ class SystemDatabase:
572
614
  if input.start_time:
573
615
  query = query.where(
574
616
  SystemSchema.workflow_status.c.created_at
575
- >= datetime.datetime.fromisoformat(input.start_time).timestamp()
617
+ >= datetime.datetime.fromisoformat(input.start_time).timestamp() * 1000
576
618
  )
577
619
  if input.end_time:
578
620
  query = query.where(
579
621
  SystemSchema.workflow_status.c.created_at
580
- <= datetime.datetime.fromisoformat(input.end_time).timestamp()
622
+ <= datetime.datetime.fromisoformat(input.end_time).timestamp() * 1000
581
623
  )
582
624
  if input.status:
583
625
  query = query.where(SystemSchema.workflow_status.c.status == input.status)
@@ -1130,27 +1172,38 @@ class SystemDatabase:
1130
1172
  if num_recent_queries >= queue.limiter["limit"]:
1131
1173
  return []
1132
1174
 
1133
- # Select not-yet-completed functions in the queue ordered by the
1134
- # time at which they were enqueued.
1135
- # If there is a concurrency limit N, select only the N most recent
1175
+ # Dequeue functions eligible for this worker and ordered by the time at which they were enqueued.
1176
+ # If there is a global or local concurrency limit N, select only the N oldest enqueued
1136
1177
  # functions, else select all of them.
1137
1178
  query = (
1138
1179
  sa.select(
1139
1180
  SystemSchema.workflow_queue.c.workflow_uuid,
1140
1181
  SystemSchema.workflow_queue.c.started_at_epoch_ms,
1182
+ SystemSchema.workflow_queue.c.executor_id,
1141
1183
  )
1142
1184
  .where(SystemSchema.workflow_queue.c.queue_name == queue.name)
1143
1185
  .where(SystemSchema.workflow_queue.c.completed_at_epoch_ms == None)
1186
+ .where(
1187
+ # Only select functions that have not been started yet or have been started by this worker
1188
+ or_(
1189
+ SystemSchema.workflow_queue.c.executor_id == None,
1190
+ SystemSchema.workflow_queue.c.executor_id == executor_id,
1191
+ )
1192
+ )
1144
1193
  .order_by(SystemSchema.workflow_queue.c.created_at_epoch_ms.asc())
1145
1194
  )
1146
- if queue.concurrency is not None:
1195
+ # Set a dequeue limit if necessary
1196
+ if queue.worker_concurrency is not None:
1197
+ query = query.limit(queue.worker_concurrency)
1198
+ elif queue.concurrency is not None:
1147
1199
  query = query.limit(queue.concurrency)
1148
1200
 
1149
- # From the functions retrieved, get the workflow IDs of the functions
1150
- # that have not yet been started so we can start them.
1151
1201
  rows = c.execute(query).fetchall()
1202
+
1203
+ # Now, get the workflow IDs of functions that have not yet been started
1152
1204
  dequeued_ids: List[str] = [row[0] for row in rows if row[1] is None]
1153
1205
  ret_ids: list[str] = []
1206
+ dbos_logger.debug(f"[{queue.name}] dequeueing {len(dequeued_ids)} task(s)")
1154
1207
  for id in dequeued_ids:
1155
1208
 
1156
1209
  # If we have a limiter, stop starting functions when the number
@@ -1173,11 +1226,11 @@ class SystemDatabase:
1173
1226
  )
1174
1227
  )
1175
1228
 
1176
- # Then give it a start time
1229
+ # Then give it a start time and assign the executor ID
1177
1230
  c.execute(
1178
1231
  SystemSchema.workflow_queue.update()
1179
1232
  .where(SystemSchema.workflow_queue.c.workflow_uuid == id)
1180
- .values(started_at_epoch_ms=start_time_ms)
1233
+ .values(started_at_epoch_ms=start_time_ms, executor_id=executor_id)
1181
1234
  )
1182
1235
  ret_ids.append(id)
1183
1236
 
@@ -9,10 +9,6 @@ runtimeConfig:
9
9
  start:
10
10
  - "fastapi run ${package_name}/main.py"
11
11
  database:
12
- hostname: localhost
13
- port: 5432
14
- username: postgres
15
- password: ${PGPASSWORD}
16
12
  migrate:
17
13
  - ${migration_command}
18
14
  telemetry:
@@ -0,0 +1,172 @@
1
+ from typing import Any, List, Optional, cast
2
+
3
+ import typer
4
+ from rich import print
5
+
6
+ from dbos import DBOS
7
+
8
+ from . import _serialization, load_config
9
+ from ._dbos_config import ConfigFile, _is_valid_app_name
10
+ from ._sys_db import (
11
+ GetWorkflowsInput,
12
+ GetWorkflowsOutput,
13
+ SystemDatabase,
14
+ WorkflowStatuses,
15
+ WorkflowStatusInternal,
16
+ WorkflowStatusString,
17
+ )
18
+
19
+
20
+ class WorkflowInformation:
21
+ workflowUUID: str
22
+ status: WorkflowStatuses
23
+ workflowName: str
24
+ workflowClassName: Optional[str]
25
+ workflowConfigName: Optional[str]
26
+ input: Optional[_serialization.WorkflowInputs] # JSON (jsonpickle)
27
+ output: Optional[str] # JSON (jsonpickle)
28
+ error: Optional[str] # JSON (jsonpickle)
29
+ executor_id: Optional[str]
30
+ app_version: Optional[str]
31
+ app_id: Optional[str]
32
+ request: Optional[str] # JSON (jsonpickle)
33
+ recovery_attempts: Optional[int]
34
+ authenticated_user: Optional[str]
35
+ assumed_role: Optional[str]
36
+ authenticated_roles: Optional[str] # JSON list of roles.
37
+ queue_name: Optional[str]
38
+
39
+
40
+ def _list_workflows(
41
+ config: ConfigFile,
42
+ li: int,
43
+ user: Optional[str],
44
+ starttime: Optional[str],
45
+ endtime: Optional[str],
46
+ status: Optional[str],
47
+ request: bool,
48
+ appversion: Optional[str],
49
+ ) -> List[WorkflowInformation]:
50
+
51
+ sys_db = None
52
+
53
+ try:
54
+ sys_db = SystemDatabase(config)
55
+
56
+ input = GetWorkflowsInput()
57
+ input.authenticated_user = user
58
+ input.start_time = starttime
59
+ input.end_time = endtime
60
+ if status is not None:
61
+ input.status = cast(WorkflowStatuses, status)
62
+ input.application_version = appversion
63
+ input.limit = li
64
+
65
+ output: GetWorkflowsOutput = sys_db.get_workflows(input)
66
+
67
+ infos: List[WorkflowInformation] = []
68
+
69
+ if output.workflow_uuids is None:
70
+ typer.echo("No workflows found")
71
+ return {}
72
+
73
+ for workflow_id in output.workflow_uuids:
74
+ info = _get_workflow_info(
75
+ sys_db, workflow_id, request
76
+ ) # Call the method for each ID
77
+
78
+ if info is not None:
79
+ infos.append(info)
80
+
81
+ return infos
82
+ except Exception as e:
83
+ typer.echo(f"Error listing workflows: {e}")
84
+ return []
85
+ finally:
86
+ if sys_db:
87
+ sys_db.destroy()
88
+
89
+
90
+ def _get_workflow(
91
+ config: ConfigFile, uuid: str, request: bool
92
+ ) -> Optional[WorkflowInformation]:
93
+ sys_db = None
94
+
95
+ try:
96
+ sys_db = SystemDatabase(config)
97
+
98
+ info = _get_workflow_info(sys_db, uuid, request)
99
+ return info
100
+
101
+ except Exception as e:
102
+ typer.echo(f"Error getting workflow: {e}")
103
+ return None
104
+ finally:
105
+ if sys_db:
106
+ sys_db.destroy()
107
+
108
+
109
+ def _cancel_workflow(config: ConfigFile, uuid: str) -> None:
110
+ # config = load_config()
111
+ sys_db = None
112
+
113
+ try:
114
+ sys_db = SystemDatabase(config)
115
+ sys_db.set_workflow_status(uuid, WorkflowStatusString.CANCELLED, False)
116
+ return
117
+
118
+ except Exception as e:
119
+ typer.echo(f"Failed to connect to DBOS system database: {e}")
120
+ return None
121
+ finally:
122
+ if sys_db:
123
+ sys_db.destroy()
124
+
125
+
126
+ def _reattempt_workflow(uuid: str, startNewWorkflow: bool) -> None:
127
+ print(f"Reattempt workflow info for {uuid} not implemented")
128
+ return
129
+
130
+
131
+ def _get_workflow_info(
132
+ sys_db: SystemDatabase, workflowUUID: str, getRequest: bool
133
+ ) -> Optional[WorkflowInformation]:
134
+
135
+ info = sys_db.get_workflow_status(workflowUUID)
136
+ if info is None:
137
+ return None
138
+
139
+ winfo = WorkflowInformation()
140
+
141
+ winfo.workflowUUID = workflowUUID
142
+ winfo.status = info["status"]
143
+ winfo.workflowName = info["name"]
144
+ winfo.workflowClassName = info["class_name"]
145
+ winfo.workflowConfigName = info["config_name"]
146
+ winfo.executor_id = info["executor_id"]
147
+ winfo.app_version = info["app_version"]
148
+ winfo.app_id = info["app_id"]
149
+ winfo.recovery_attempts = info["recovery_attempts"]
150
+ winfo.authenticated_user = info["authenticated_user"]
151
+ winfo.assumed_role = info["assumed_role"]
152
+ winfo.authenticated_roles = info["authenticated_roles"]
153
+ winfo.queue_name = info["queue_name"]
154
+
155
+ # no input field
156
+ input_data = sys_db.get_workflow_inputs(workflowUUID)
157
+ if input_data is not None:
158
+ winfo.input = input_data
159
+
160
+ if info.get("status") == "SUCCESS":
161
+ result = sys_db.await_workflow_result(workflowUUID)
162
+ winfo.output = result
163
+ elif info.get("status") == "ERROR":
164
+ try:
165
+ sys_db.await_workflow_result(workflowUUID)
166
+ except Exception as e:
167
+ winfo.error = str(e)
168
+
169
+ if not getRequest:
170
+ winfo.request = None
171
+
172
+ return winfo
dbos/cli.py CHANGED
@@ -8,6 +8,7 @@ import typing
8
8
  from os import path
9
9
  from typing import Any
10
10
 
11
+ import jsonpickle # type: ignore
11
12
  import sqlalchemy as sa
12
13
  import tomlkit
13
14
  import typer
@@ -17,12 +18,21 @@ from typing_extensions import Annotated
17
18
 
18
19
  from dbos._schemas.system_database import SystemSchema
19
20
 
20
- from . import load_config
21
+ from . import _serialization, load_config
21
22
  from ._app_db import ApplicationDatabase
22
23
  from ._dbos_config import _is_valid_app_name
23
24
  from ._sys_db import SystemDatabase
25
+ from ._workflow_commands import (
26
+ _cancel_workflow,
27
+ _get_workflow,
28
+ _list_workflows,
29
+ _reattempt_workflow,
30
+ )
24
31
 
25
32
  app = typer.Typer()
33
+ workflow = typer.Typer()
34
+
35
+ app.add_typer(workflow, name="workflow", help="Manage DBOS workflows")
26
36
 
27
37
 
28
38
  def _on_windows() -> bool:
@@ -333,5 +343,94 @@ def reset(
333
343
  sys_db.destroy()
334
344
 
335
345
 
346
+ @workflow.command(help="List workflows for your application")
347
+ def list(
348
+ limit: Annotated[
349
+ int,
350
+ typer.Option("--limit", "-l", help="Limit the results returned"),
351
+ ] = 10,
352
+ user: Annotated[
353
+ typing.Optional[str],
354
+ typer.Option("--user", "-u", help="Retrieve workflows run by this user"),
355
+ ] = None,
356
+ starttime: Annotated[
357
+ typing.Optional[str],
358
+ typer.Option(
359
+ "--start-time",
360
+ "-s",
361
+ help="Retrieve workflows starting after this timestamp (ISO 8601 format)",
362
+ ),
363
+ ] = None,
364
+ endtime: Annotated[
365
+ typing.Optional[str],
366
+ typer.Option(
367
+ "--end-time",
368
+ "-e",
369
+ help="Retrieve workflows starting before this timestamp (ISO 8601 format)",
370
+ ),
371
+ ] = None,
372
+ status: Annotated[
373
+ typing.Optional[str],
374
+ typer.Option(
375
+ "--status",
376
+ "-S",
377
+ help="Retrieve workflows with this status (PENDING, SUCCESS, ERROR, RETRIES_EXCEEDED, ENQUEUED, or CANCELLED)",
378
+ ),
379
+ ] = None,
380
+ appversion: Annotated[
381
+ typing.Optional[str],
382
+ typer.Option(
383
+ "--application-version",
384
+ "-v",
385
+ help="Retrieve workflows with this application version",
386
+ ),
387
+ ] = None,
388
+ request: Annotated[
389
+ bool,
390
+ typer.Option("--request", help="Retrieve workflow request information"),
391
+ ] = True,
392
+ appdir: Annotated[
393
+ typing.Optional[str],
394
+ typer.Option("--app-dir", "-d", help="Specify the application root directory"),
395
+ ] = None,
396
+ ) -> None:
397
+ config = load_config()
398
+ workflows = _list_workflows(
399
+ config, limit, user, starttime, endtime, status, request, appversion
400
+ )
401
+ print(jsonpickle.encode(workflows, unpicklable=False))
402
+
403
+
404
+ @workflow.command(help="Retrieve the status of a workflow")
405
+ def get(
406
+ uuid: Annotated[str, typer.Argument()],
407
+ appdir: Annotated[
408
+ typing.Optional[str],
409
+ typer.Option("--app-dir", "-d", help="Specify the application root directory"),
410
+ ] = None,
411
+ request: Annotated[
412
+ bool,
413
+ typer.Option("--request", help="Retrieve workflow request information"),
414
+ ] = True,
415
+ ) -> None:
416
+ config = load_config()
417
+ print(jsonpickle.encode(_get_workflow(config, uuid, request), unpicklable=False))
418
+
419
+
420
+ @workflow.command(
421
+ help="Cancel a workflow so it is no longer automatically retried or restarted"
422
+ )
423
+ def cancel(
424
+ uuid: Annotated[str, typer.Argument()],
425
+ appdir: Annotated[
426
+ typing.Optional[str],
427
+ typer.Option("--app-dir", "-d", help="Specify the application root directory"),
428
+ ] = None,
429
+ ) -> None:
430
+ config = load_config()
431
+ _cancel_workflow(config, uuid)
432
+ print(f"Workflow {uuid} has been cancelled")
433
+
434
+
336
435
  if __name__ == "__main__":
337
436
  app()
@@ -81,13 +81,7 @@
81
81
  "type": "array",
82
82
  "description": "Specify a list of user DB rollback commands to run"
83
83
  }
84
- },
85
- "required": [
86
- "hostname",
87
- "port",
88
- "username",
89
- "password"
90
- ]
84
+ }
91
85
  },
92
86
  "telemetry": {
93
87
  "type": "object",
@@ -181,9 +175,6 @@
181
175
  "type": "string",
182
176
  "deprecated": true
183
177
  }
184
- },
185
- "required": [
186
- "database"
187
- ]
178
+ }
188
179
  }
189
180