dbos 0.20.0a8__tar.gz → 0.21.0__tar.gz

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.

Files changed (92) hide show
  1. {dbos-0.20.0a8 → dbos-0.21.0}/PKG-INFO +1 -1
  2. {dbos-0.20.0a8 → dbos-0.21.0}/dbos/_context.py +0 -30
  3. {dbos-0.20.0a8 → dbos-0.21.0}/dbos/_core.py +2 -2
  4. {dbos-0.20.0a8 → dbos-0.21.0}/dbos/_dbos.py +8 -6
  5. {dbos-0.20.0a8 → dbos-0.21.0}/dbos/_dbos_config.py +17 -13
  6. dbos-0.21.0/dbos/_recovery.py +70 -0
  7. {dbos-0.20.0a8 → dbos-0.21.0}/dbos/_sys_db.py +212 -72
  8. {dbos-0.20.0a8 → dbos-0.21.0}/dbos/_workflow_commands.py +51 -39
  9. {dbos-0.20.0a8 → dbos-0.21.0}/dbos/cli/cli.py +88 -20
  10. {dbos-0.20.0a8 → dbos-0.21.0}/pyproject.toml +1 -1
  11. {dbos-0.20.0a8 → dbos-0.21.0}/tests/test_admin_server.py +38 -33
  12. {dbos-0.20.0a8 → dbos-0.21.0}/tests/test_dbos.py +53 -91
  13. {dbos-0.20.0a8 → dbos-0.21.0}/tests/test_failures.py +22 -45
  14. {dbos-0.20.0a8 → dbos-0.21.0}/tests/test_package.py +46 -0
  15. {dbos-0.20.0a8 → dbos-0.21.0}/tests/test_queue.py +292 -0
  16. dbos-0.21.0/tests/test_sqlalchemy.py +113 -0
  17. dbos-0.21.0/tests/test_workflow_cmds.py +289 -0
  18. dbos-0.20.0a8/dbos/_recovery.py +0 -53
  19. dbos-0.20.0a8/tests/test_workflow_cmds.py +0 -216
  20. {dbos-0.20.0a8 → dbos-0.21.0}/LICENSE +0 -0
  21. {dbos-0.20.0a8 → dbos-0.21.0}/README.md +0 -0
  22. {dbos-0.20.0a8 → dbos-0.21.0}/dbos/__init__.py +0 -0
  23. {dbos-0.20.0a8 → dbos-0.21.0}/dbos/_admin_server.py +0 -0
  24. {dbos-0.20.0a8 → dbos-0.21.0}/dbos/_app_db.py +0 -0
  25. {dbos-0.20.0a8 → dbos-0.21.0}/dbos/_classproperty.py +0 -0
  26. {dbos-0.20.0a8 → dbos-0.21.0}/dbos/_cloudutils/authentication.py +0 -0
  27. {dbos-0.20.0a8 → dbos-0.21.0}/dbos/_cloudutils/cloudutils.py +0 -0
  28. {dbos-0.20.0a8 → dbos-0.21.0}/dbos/_cloudutils/databases.py +0 -0
  29. {dbos-0.20.0a8 → dbos-0.21.0}/dbos/_croniter.py +0 -0
  30. {dbos-0.20.0a8 → dbos-0.21.0}/dbos/_db_wizard.py +0 -0
  31. {dbos-0.20.0a8 → dbos-0.21.0}/dbos/_error.py +0 -0
  32. {dbos-0.20.0a8 → dbos-0.21.0}/dbos/_fastapi.py +0 -0
  33. {dbos-0.20.0a8 → dbos-0.21.0}/dbos/_flask.py +0 -0
  34. {dbos-0.20.0a8 → dbos-0.21.0}/dbos/_kafka.py +0 -0
  35. {dbos-0.20.0a8 → dbos-0.21.0}/dbos/_kafka_message.py +0 -0
  36. {dbos-0.20.0a8 → dbos-0.21.0}/dbos/_logger.py +0 -0
  37. {dbos-0.20.0a8 → dbos-0.21.0}/dbos/_migrations/env.py +0 -0
  38. {dbos-0.20.0a8 → dbos-0.21.0}/dbos/_migrations/script.py.mako +0 -0
  39. {dbos-0.20.0a8 → dbos-0.21.0}/dbos/_migrations/versions/04ca4f231047_workflow_queues_executor_id.py +0 -0
  40. {dbos-0.20.0a8 → dbos-0.21.0}/dbos/_migrations/versions/50f3227f0b4b_fix_job_queue.py +0 -0
  41. {dbos-0.20.0a8 → dbos-0.21.0}/dbos/_migrations/versions/5c361fc04708_added_system_tables.py +0 -0
  42. {dbos-0.20.0a8 → dbos-0.21.0}/dbos/_migrations/versions/a3b18ad34abe_added_triggers.py +0 -0
  43. {dbos-0.20.0a8 → dbos-0.21.0}/dbos/_migrations/versions/d76646551a6b_job_queue_limiter.py +0 -0
  44. {dbos-0.20.0a8 → dbos-0.21.0}/dbos/_migrations/versions/d76646551a6c_workflow_queue.py +0 -0
  45. {dbos-0.20.0a8 → dbos-0.21.0}/dbos/_migrations/versions/eab0cc1d9a14_job_queue.py +0 -0
  46. {dbos-0.20.0a8 → dbos-0.21.0}/dbos/_outcome.py +0 -0
  47. {dbos-0.20.0a8 → dbos-0.21.0}/dbos/_queue.py +0 -0
  48. {dbos-0.20.0a8 → dbos-0.21.0}/dbos/_registrations.py +0 -0
  49. {dbos-0.20.0a8 → dbos-0.21.0}/dbos/_request.py +0 -0
  50. {dbos-0.20.0a8 → dbos-0.21.0}/dbos/_roles.py +0 -0
  51. {dbos-0.20.0a8 → dbos-0.21.0}/dbos/_scheduler.py +0 -0
  52. {dbos-0.20.0a8 → dbos-0.21.0}/dbos/_schemas/__init__.py +0 -0
  53. {dbos-0.20.0a8 → dbos-0.21.0}/dbos/_schemas/application_database.py +0 -0
  54. {dbos-0.20.0a8 → dbos-0.21.0}/dbos/_schemas/system_database.py +0 -0
  55. {dbos-0.20.0a8 → dbos-0.21.0}/dbos/_serialization.py +0 -0
  56. {dbos-0.20.0a8 → dbos-0.21.0}/dbos/_templates/dbos-db-starter/README.md +0 -0
  57. {dbos-0.20.0a8 → dbos-0.21.0}/dbos/_templates/dbos-db-starter/__package/__init__.py +0 -0
  58. {dbos-0.20.0a8 → dbos-0.21.0}/dbos/_templates/dbos-db-starter/__package/main.py +0 -0
  59. {dbos-0.20.0a8 → dbos-0.21.0}/dbos/_templates/dbos-db-starter/__package/schema.py +0 -0
  60. {dbos-0.20.0a8 → dbos-0.21.0}/dbos/_templates/dbos-db-starter/alembic.ini +0 -0
  61. {dbos-0.20.0a8 → dbos-0.21.0}/dbos/_templates/dbos-db-starter/dbos-config.yaml.dbos +0 -0
  62. {dbos-0.20.0a8 → dbos-0.21.0}/dbos/_templates/dbos-db-starter/migrations/env.py.dbos +0 -0
  63. {dbos-0.20.0a8 → dbos-0.21.0}/dbos/_templates/dbos-db-starter/migrations/script.py.mako +0 -0
  64. {dbos-0.20.0a8 → dbos-0.21.0}/dbos/_templates/dbos-db-starter/migrations/versions/2024_07_31_180642_init.py +0 -0
  65. {dbos-0.20.0a8 → dbos-0.21.0}/dbos/_templates/dbos-db-starter/start_postgres_docker.py +0 -0
  66. {dbos-0.20.0a8 → dbos-0.21.0}/dbos/_tracer.py +0 -0
  67. {dbos-0.20.0a8 → dbos-0.21.0}/dbos/cli/_github_init.py +0 -0
  68. {dbos-0.20.0a8 → dbos-0.21.0}/dbos/cli/_template_init.py +0 -0
  69. {dbos-0.20.0a8 → dbos-0.21.0}/dbos/dbos-config.schema.json +0 -0
  70. {dbos-0.20.0a8 → dbos-0.21.0}/dbos/py.typed +0 -0
  71. {dbos-0.20.0a8 → dbos-0.21.0}/tests/__init__.py +0 -0
  72. {dbos-0.20.0a8 → dbos-0.21.0}/tests/atexit_no_ctor.py +0 -0
  73. {dbos-0.20.0a8 → dbos-0.21.0}/tests/atexit_no_launch.py +0 -0
  74. {dbos-0.20.0a8 → dbos-0.21.0}/tests/classdefs.py +0 -0
  75. {dbos-0.20.0a8 → dbos-0.21.0}/tests/conftest.py +0 -0
  76. {dbos-0.20.0a8 → dbos-0.21.0}/tests/more_classdefs.py +0 -0
  77. {dbos-0.20.0a8 → dbos-0.21.0}/tests/queuedworkflow.py +0 -0
  78. {dbos-0.20.0a8 → dbos-0.21.0}/tests/test_async.py +0 -0
  79. {dbos-0.20.0a8 → dbos-0.21.0}/tests/test_classdecorators.py +0 -0
  80. {dbos-0.20.0a8 → dbos-0.21.0}/tests/test_concurrency.py +0 -0
  81. {dbos-0.20.0a8 → dbos-0.21.0}/tests/test_config.py +0 -0
  82. {dbos-0.20.0a8 → dbos-0.21.0}/tests/test_croniter.py +0 -0
  83. {dbos-0.20.0a8 → dbos-0.21.0}/tests/test_fastapi.py +0 -0
  84. {dbos-0.20.0a8 → dbos-0.21.0}/tests/test_fastapi_roles.py +0 -0
  85. {dbos-0.20.0a8 → dbos-0.21.0}/tests/test_flask.py +0 -0
  86. {dbos-0.20.0a8 → dbos-0.21.0}/tests/test_kafka.py +0 -0
  87. {dbos-0.20.0a8 → dbos-0.21.0}/tests/test_outcome.py +0 -0
  88. {dbos-0.20.0a8 → dbos-0.21.0}/tests/test_scheduler.py +0 -0
  89. {dbos-0.20.0a8 → dbos-0.21.0}/tests/test_schema_migration.py +0 -0
  90. {dbos-0.20.0a8 → dbos-0.21.0}/tests/test_singleton.py +0 -0
  91. {dbos-0.20.0a8 → dbos-0.21.0}/tests/test_spans.py +0 -0
  92. {dbos-0.20.0a8 → dbos-0.21.0}/version/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: dbos
3
- Version: 0.20.0a8
3
+ Version: 0.21.0
4
4
  Summary: Ultra-lightweight durable execution in Python
5
5
  Author-Email: "DBOS, Inc." <contact@dbos.dev>
6
6
  License: MIT
@@ -63,7 +63,6 @@ class DBOSContext:
63
63
  self.parent_workflow_fid: int = -1
64
64
  self.workflow_id: str = ""
65
65
  self.function_id: int = -1
66
- self.in_recovery: bool = False
67
66
 
68
67
  self.curr_step_function_id: int = -1
69
68
  self.curr_tx_function_id: int = -1
@@ -82,7 +81,6 @@ class DBOSContext:
82
81
  rv.is_within_set_workflow_id_block = self.is_within_set_workflow_id_block
83
82
  rv.parent_workflow_id = self.workflow_id
84
83
  rv.parent_workflow_fid = self.function_id
85
- rv.in_recovery = self.in_recovery
86
84
  rv.authenticated_user = self.authenticated_user
87
85
  rv.authenticated_roles = (
88
86
  self.authenticated_roles[:]
@@ -335,34 +333,6 @@ class SetWorkflowID:
335
333
  return False # Did not handle
336
334
 
337
335
 
338
- class SetWorkflowRecovery:
339
- def __init__(self) -> None:
340
- self.created_ctx = False
341
-
342
- def __enter__(self) -> SetWorkflowRecovery:
343
- # Code to create a basic context
344
- ctx = get_local_dbos_context()
345
- if ctx is None:
346
- self.created_ctx = True
347
- _set_local_dbos_context(DBOSContext())
348
- assert_current_dbos_context().in_recovery = True
349
-
350
- return self
351
-
352
- def __exit__(
353
- self,
354
- exc_type: Optional[Type[BaseException]],
355
- exc_value: Optional[BaseException],
356
- traceback: Optional[TracebackType],
357
- ) -> Literal[False]:
358
- assert assert_current_dbos_context().in_recovery == True
359
- assert_current_dbos_context().in_recovery = False
360
- # Code to clean up the basic context if we created it
361
- if self.created_ctx:
362
- _clear_local_dbos_context()
363
- return False # Did not handle
364
-
365
-
366
336
  class EnterDBOSWorkflow(AbstractContextManager[DBOSContext, Literal[False]]):
367
337
  def __init__(self, attributes: TracedAttributes) -> None:
368
338
  self.created_ctx = False
@@ -185,8 +185,8 @@ def _init_workflow(
185
185
  # Synchronously record the status and inputs for workflows and single-step workflows
186
186
  # We also have to do this for single-step workflows because of the foreign key constraint on the operation outputs table
187
187
  # TODO: Make this transactional (and with the queue step below)
188
- wf_status = dbos._sys_db.update_workflow_status(
189
- status, False, ctx.in_recovery, max_recovery_attempts=max_recovery_attempts
188
+ wf_status = dbos._sys_db.insert_workflow_status(
189
+ status, max_recovery_attempts=max_recovery_attempts
190
190
  )
191
191
  # TODO: Modify the inputs if they were changed by `update_workflow_inputs`
192
192
  dbos._sys_db.update_workflow_inputs(wfid, _serialization.serialize_args(inputs))
@@ -56,7 +56,7 @@ from ._registrations import (
56
56
  )
57
57
  from ._roles import default_required_roles, required_roles
58
58
  from ._scheduler import ScheduledWorkflow, scheduled
59
- from ._sys_db import WorkflowStatusString, reset_system_database
59
+ from ._sys_db import reset_system_database
60
60
  from ._tracer import dbos_tracer
61
61
 
62
62
  if TYPE_CHECKING:
@@ -613,6 +613,7 @@ class DBOS:
613
613
  workflow_id=workflow_id,
614
614
  status=stat["status"],
615
615
  name=stat["name"],
616
+ executor_id=stat["executor_id"],
616
617
  recovery_attempts=stat["recovery_attempts"],
617
618
  class_name=stat["class_name"],
618
619
  config_name=stat["config_name"],
@@ -800,14 +801,13 @@ class DBOS:
800
801
  @classmethod
801
802
  def cancel_workflow(cls, workflow_id: str) -> None:
802
803
  """Cancel a workflow by ID."""
803
- _get_dbos_instance()._sys_db.set_workflow_status(
804
- workflow_id, WorkflowStatusString.CANCELLED, False
805
- )
804
+ _get_dbos_instance()._sys_db.cancel_workflow(workflow_id)
806
805
 
807
806
  @classmethod
808
- def resume_workflow(cls, workflow_id: str) -> None:
807
+ def resume_workflow(cls, workflow_id: str) -> WorkflowHandle[Any]:
809
808
  """Resume a workflow by ID."""
810
- execute_workflow_by_id(_get_dbos_instance(), workflow_id, False)
809
+ _get_dbos_instance()._sys_db.resume_workflow(workflow_id)
810
+ return execute_workflow_by_id(_get_dbos_instance(), workflow_id, False)
811
811
 
812
812
  @classproperty
813
813
  def logger(cls) -> Logger:
@@ -910,6 +910,7 @@ class WorkflowStatus:
910
910
  workflow_id(str): The ID of the workflow execution
911
911
  status(str): The status of the execution, from `WorkflowStatusString`
912
912
  name(str): The workflow function name
913
+ executor_id(str): The ID of the executor running the workflow
913
914
  class_name(str): For member functions, the name of the class containing the workflow function
914
915
  config_name(str): For instance member functions, the name of the class instance for the execution
915
916
  queue_name(str): For workflows that are or were queued, the queue name
@@ -923,6 +924,7 @@ class WorkflowStatus:
923
924
  workflow_id: str
924
925
  status: str
925
926
  name: str
927
+ executor_id: Optional[str]
926
928
  class_name: Optional[str]
927
929
  config_name: Optional[str]
928
930
  queue_name: Optional[str]
@@ -123,7 +123,10 @@ def get_dbos_database_url(config_file_path: str = DBOS_CONFIG_PATH) -> str:
123
123
 
124
124
 
125
125
  def load_config(
126
- config_file_path: str = DBOS_CONFIG_PATH, *, use_db_wizard: bool = True
126
+ config_file_path: str = DBOS_CONFIG_PATH,
127
+ *,
128
+ use_db_wizard: bool = True,
129
+ silent: bool = False,
127
130
  ) -> ConfigFile:
128
131
  """
129
132
  Load the DBOS `ConfigFile` from the specified path (typically `dbos-config.yaml`).
@@ -188,18 +191,19 @@ def load_config(
188
191
  # Load the DB connection file. Use its values for missing fields from dbos-config.yaml. Use defaults otherwise.
189
192
  data = cast(ConfigFile, data)
190
193
  db_connection = load_db_connection()
191
- if data["database"].get("hostname"):
192
- print(
193
- "[bold blue]Loading database connection parameters from dbos-config.yaml[/bold blue]"
194
- )
195
- elif db_connection.get("hostname"):
196
- print(
197
- "[bold blue]Loading database connection parameters from .dbos/db_connection[/bold blue]"
198
- )
199
- else:
200
- print(
201
- "[bold blue]Using default database connection parameters (localhost)[/bold blue]"
202
- )
194
+ if not silent:
195
+ if data["database"].get("hostname"):
196
+ print(
197
+ "[bold blue]Loading database connection parameters from dbos-config.yaml[/bold blue]"
198
+ )
199
+ elif db_connection.get("hostname"):
200
+ print(
201
+ "[bold blue]Loading database connection parameters from .dbos/db_connection[/bold blue]"
202
+ )
203
+ else:
204
+ print(
205
+ "[bold blue]Using default database connection parameters (localhost)[/bold blue]"
206
+ )
203
207
 
204
208
  data["database"]["hostname"] = (
205
209
  data["database"].get("hostname") or db_connection.get("hostname") or "localhost"
@@ -0,0 +1,70 @@
1
+ import os
2
+ import threading
3
+ import time
4
+ import traceback
5
+ from typing import TYPE_CHECKING, Any, List
6
+
7
+ from ._core import execute_workflow_by_id
8
+ from ._error import DBOSWorkflowFunctionNotFoundError
9
+ from ._sys_db import GetPendingWorkflowsOutput
10
+
11
+ if TYPE_CHECKING:
12
+ from ._dbos import DBOS, WorkflowHandle
13
+
14
+
15
+ def startup_recovery_thread(
16
+ dbos: "DBOS", pending_workflows: List[GetPendingWorkflowsOutput]
17
+ ) -> None:
18
+ """Attempt to recover local pending workflows on startup using a background thread."""
19
+ stop_event = threading.Event()
20
+ dbos.stop_events.append(stop_event)
21
+ while not stop_event.is_set() and len(pending_workflows) > 0:
22
+ try:
23
+ for pending_workflow in list(pending_workflows):
24
+ if (
25
+ pending_workflow.queue_name
26
+ and pending_workflow.queue_name != "_dbos_internal_queue"
27
+ ):
28
+ dbos._sys_db.clear_queue_assignment(pending_workflow.workflow_uuid)
29
+ continue
30
+ execute_workflow_by_id(dbos, pending_workflow.workflow_uuid)
31
+ pending_workflows.remove(pending_workflow)
32
+ except DBOSWorkflowFunctionNotFoundError:
33
+ time.sleep(1)
34
+ except Exception as e:
35
+ dbos.logger.error(
36
+ f"Exception encountered when recovering workflows: {traceback.format_exc()}"
37
+ )
38
+ raise e
39
+
40
+
41
+ def recover_pending_workflows(
42
+ dbos: "DBOS", executor_ids: List[str] = ["local"]
43
+ ) -> List["WorkflowHandle[Any]"]:
44
+ workflow_handles: List["WorkflowHandle[Any]"] = []
45
+ for executor_id in executor_ids:
46
+ if executor_id == "local" and os.environ.get("DBOS__VMID"):
47
+ dbos.logger.debug(
48
+ f"Skip local recovery because it's running in a VM: {os.environ.get('DBOS__VMID')}"
49
+ )
50
+ dbos.logger.debug(f"Recovering pending workflows for executor: {executor_id}")
51
+ pending_workflows = dbos._sys_db.get_pending_workflows(executor_id)
52
+ for pending_workflow in pending_workflows:
53
+ if (
54
+ pending_workflow.queue_name
55
+ and pending_workflow.queue_name != "_dbos_internal_queue"
56
+ ):
57
+ try:
58
+ dbos._sys_db.clear_queue_assignment(pending_workflow.workflow_uuid)
59
+ workflow_handles.append(
60
+ dbos.retrieve_workflow(pending_workflow.workflow_uuid)
61
+ )
62
+ except Exception as e:
63
+ dbos.logger.error(e)
64
+ else:
65
+ workflow_handles.append(
66
+ execute_workflow_by_id(dbos, pending_workflow.workflow_uuid)
67
+ )
68
+
69
+ dbos.logger.info("Recovered pending workflows")
70
+ return workflow_handles
@@ -126,11 +126,26 @@ class GetWorkflowsInput:
126
126
  )
127
127
 
128
128
 
129
+ class GetQueuedWorkflowsInput(TypedDict):
130
+ queue_name: Optional[str]
131
+ status: Optional[str]
132
+ start_time: Optional[str] # Timestamp in ISO 8601 format
133
+ end_time: Optional[str] # Timestamp in ISO 8601 format
134
+ limit: Optional[int] # Return up to this many workflows IDs.
135
+ name: Optional[str] # The name of the workflow function
136
+
137
+
129
138
  class GetWorkflowsOutput:
130
139
  def __init__(self, workflow_uuids: List[str]):
131
140
  self.workflow_uuids = workflow_uuids
132
141
 
133
142
 
143
+ class GetPendingWorkflowsOutput:
144
+ def __init__(self, *, workflow_uuid: str, queue_name: Optional[str] = None):
145
+ self.workflow_uuid: str = workflow_uuid
146
+ self.queue_name: Optional[str] = queue_name
147
+
148
+
134
149
  class WorkflowInformation(TypedDict, total=False):
135
150
  workflow_uuid: str
136
151
  status: WorkflowStatuses # The status of the workflow.
@@ -243,66 +258,50 @@ class SystemDatabase:
243
258
  dbos_logger.debug("Waiting for system buffers to be exported")
244
259
  time.sleep(1)
245
260
 
246
- def update_workflow_status(
261
+ def insert_workflow_status(
247
262
  self,
248
263
  status: WorkflowStatusInternal,
249
- replace: bool = True,
250
- in_recovery: bool = False,
251
264
  *,
252
- conn: Optional[sa.Connection] = None,
253
265
  max_recovery_attempts: int = DEFAULT_MAX_RECOVERY_ATTEMPTS,
254
266
  ) -> WorkflowStatuses:
255
267
  wf_status: WorkflowStatuses = status["status"]
256
268
 
257
- cmd = pg.insert(SystemSchema.workflow_status).values(
258
- workflow_uuid=status["workflow_uuid"],
259
- status=status["status"],
260
- name=status["name"],
261
- class_name=status["class_name"],
262
- config_name=status["config_name"],
263
- output=status["output"],
264
- error=status["error"],
265
- executor_id=status["executor_id"],
266
- application_version=status["app_version"],
267
- application_id=status["app_id"],
268
- request=status["request"],
269
- authenticated_user=status["authenticated_user"],
270
- authenticated_roles=status["authenticated_roles"],
271
- assumed_role=status["assumed_role"],
272
- queue_name=status["queue_name"],
273
- )
274
- if replace:
275
- cmd = cmd.on_conflict_do_update(
276
- index_elements=["workflow_uuid"],
277
- set_=dict(
278
- status=status["status"],
279
- output=status["output"],
280
- error=status["error"],
281
- ),
282
- )
283
- elif in_recovery:
284
- cmd = cmd.on_conflict_do_update(
285
- index_elements=["workflow_uuid"],
286
- set_=dict(
287
- recovery_attempts=SystemSchema.workflow_status.c.recovery_attempts
288
- + 1,
269
+ cmd = (
270
+ pg.insert(SystemSchema.workflow_status)
271
+ .values(
272
+ workflow_uuid=status["workflow_uuid"],
273
+ status=status["status"],
274
+ name=status["name"],
275
+ class_name=status["class_name"],
276
+ config_name=status["config_name"],
277
+ output=status["output"],
278
+ error=status["error"],
279
+ executor_id=status["executor_id"],
280
+ application_version=status["app_version"],
281
+ application_id=status["app_id"],
282
+ request=status["request"],
283
+ authenticated_user=status["authenticated_user"],
284
+ authenticated_roles=status["authenticated_roles"],
285
+ assumed_role=status["assumed_role"],
286
+ queue_name=status["queue_name"],
287
+ recovery_attempts=(
288
+ 1 if wf_status != WorkflowStatusString.ENQUEUED.value else 0
289
289
  ),
290
290
  )
291
- else:
292
- # A blank update so that we can return the existing status
293
- cmd = cmd.on_conflict_do_update(
291
+ .on_conflict_do_update(
294
292
  index_elements=["workflow_uuid"],
295
293
  set_=dict(
296
- recovery_attempts=SystemSchema.workflow_status.c.recovery_attempts
294
+ recovery_attempts=(
295
+ SystemSchema.workflow_status.c.recovery_attempts + 1
296
+ ),
297
297
  ),
298
298
  )
299
+ )
300
+
299
301
  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
300
302
 
301
- if conn is not None:
302
- results = conn.execute(cmd)
303
- else:
304
- with self.engine.begin() as c:
305
- results = c.execute(cmd)
303
+ with self.engine.begin() as c:
304
+ results = c.execute(cmd)
306
305
 
307
306
  row = results.fetchone()
308
307
  if row is not None:
@@ -325,7 +324,9 @@ class SystemDatabase:
325
324
  if err_msg is not None:
326
325
  raise DBOSConflictingWorkflowError(status["workflow_uuid"], err_msg)
327
326
 
328
- if in_recovery and recovery_attempts > max_recovery_attempts:
327
+ # Every time we start executing a workflow (and thus attempt to insert its status), we increment `recovery_attempts` by 1.
328
+ # When this number becomes equal to `maxRetries + 1`, we mark the workflow as `RETRIES_EXCEEDED`.
329
+ if recovery_attempts > max_recovery_attempts + 1:
329
330
  with self.engine.begin() as c:
330
331
  c.execute(
331
332
  sa.delete(SystemSchema.workflow_queue).where(
@@ -352,38 +353,107 @@ class SystemDatabase:
352
353
  status["workflow_uuid"], max_recovery_attempts
353
354
  )
354
355
 
355
- # Record we have exported status for this single-transaction workflow
356
+ return wf_status
357
+
358
+ def update_workflow_status(
359
+ self,
360
+ status: WorkflowStatusInternal,
361
+ *,
362
+ conn: Optional[sa.Connection] = None,
363
+ ) -> None:
364
+ wf_status: WorkflowStatuses = status["status"]
365
+
366
+ cmd = (
367
+ pg.insert(SystemSchema.workflow_status)
368
+ .values(
369
+ workflow_uuid=status["workflow_uuid"],
370
+ status=status["status"],
371
+ name=status["name"],
372
+ class_name=status["class_name"],
373
+ config_name=status["config_name"],
374
+ output=status["output"],
375
+ error=status["error"],
376
+ executor_id=status["executor_id"],
377
+ application_version=status["app_version"],
378
+ application_id=status["app_id"],
379
+ request=status["request"],
380
+ authenticated_user=status["authenticated_user"],
381
+ authenticated_roles=status["authenticated_roles"],
382
+ assumed_role=status["assumed_role"],
383
+ queue_name=status["queue_name"],
384
+ recovery_attempts=(
385
+ 1 if wf_status != WorkflowStatusString.ENQUEUED.value else 0
386
+ ),
387
+ )
388
+ .on_conflict_do_update(
389
+ index_elements=["workflow_uuid"],
390
+ set_=dict(
391
+ status=status["status"],
392
+ output=status["output"],
393
+ error=status["error"],
394
+ ),
395
+ )
396
+ )
397
+
398
+ if conn is not None:
399
+ conn.execute(cmd)
400
+ else:
401
+ with self.engine.begin() as c:
402
+ c.execute(cmd)
403
+
404
+ # If this is a single-transaction workflow, record that its status has been exported
356
405
  if status["workflow_uuid"] in self._temp_txn_wf_ids:
357
406
  self._exported_temp_txn_wf_status.add(status["workflow_uuid"])
358
407
 
359
- return wf_status
360
-
361
- def set_workflow_status(
408
+ def cancel_workflow(
362
409
  self,
363
- workflow_uuid: str,
364
- status: WorkflowStatusString,
365
- reset_recovery_attempts: bool,
410
+ workflow_id: str,
366
411
  ) -> None:
367
412
  with self.engine.begin() as c:
368
- stmt = (
413
+ # Remove the workflow from the queues table so it does not block the table
414
+ c.execute(
415
+ sa.delete(SystemSchema.workflow_queue).where(
416
+ SystemSchema.workflow_queue.c.workflow_uuid == workflow_id
417
+ )
418
+ )
419
+ # Set the workflow's status to CANCELLED
420
+ c.execute(
369
421
  sa.update(SystemSchema.workflow_status)
370
- .where(SystemSchema.workflow_status.c.workflow_uuid == workflow_uuid)
422
+ .where(SystemSchema.workflow_status.c.workflow_uuid == workflow_id)
371
423
  .values(
372
- status=status,
424
+ status=WorkflowStatusString.CANCELLED.value,
373
425
  )
374
426
  )
375
- c.execute(stmt)
376
427
 
377
- if reset_recovery_attempts:
378
- with self.engine.begin() as c:
379
- stmt = (
380
- sa.update(SystemSchema.workflow_status)
381
- .where(
382
- SystemSchema.workflow_status.c.workflow_uuid == workflow_uuid
383
- )
384
- .values(recovery_attempts=reset_recovery_attempts)
428
+ def resume_workflow(
429
+ self,
430
+ workflow_id: str,
431
+ ) -> None:
432
+ with self.engine.begin() as c:
433
+ # Check the status of the workflow. If it is complete, do nothing.
434
+ row = c.execute(
435
+ sa.select(
436
+ SystemSchema.workflow_status.c.status,
437
+ ).where(SystemSchema.workflow_status.c.workflow_uuid == workflow_id)
438
+ ).fetchone()
439
+ if (
440
+ row is None
441
+ or row[0] == WorkflowStatusString.SUCCESS.value
442
+ or row[0] == WorkflowStatusString.ERROR.value
443
+ ):
444
+ return
445
+ # Remove the workflow from the queues table so resume can safely be called on an ENQUEUED workflow
446
+ c.execute(
447
+ sa.delete(SystemSchema.workflow_queue).where(
448
+ SystemSchema.workflow_queue.c.workflow_uuid == workflow_id
385
449
  )
386
- c.execute(stmt)
450
+ )
451
+ # Set the workflow's status to PENDING and clear its recovery attempts.
452
+ c.execute(
453
+ sa.update(SystemSchema.workflow_status)
454
+ .where(SystemSchema.workflow_status.c.workflow_uuid == workflow_id)
455
+ .values(status=WorkflowStatusString.PENDING.value, recovery_attempts=0)
456
+ )
387
457
 
388
458
  def get_workflow_status(
389
459
  self, workflow_uuid: str
@@ -401,6 +471,7 @@ class SystemDatabase:
401
471
  SystemSchema.workflow_status.c.authenticated_roles,
402
472
  SystemSchema.workflow_status.c.assumed_role,
403
473
  SystemSchema.workflow_status.c.queue_name,
474
+ SystemSchema.workflow_status.c.executor_id,
404
475
  ).where(SystemSchema.workflow_status.c.workflow_uuid == workflow_uuid)
405
476
  ).fetchone()
406
477
  if row is None:
@@ -415,7 +486,7 @@ class SystemDatabase:
415
486
  "error": None,
416
487
  "app_id": None,
417
488
  "app_version": None,
418
- "executor_id": None,
489
+ "executor_id": row[10],
419
490
  "request": row[2],
420
491
  "recovery_attempts": row[3],
421
492
  "authenticated_user": row[6],
@@ -601,9 +672,8 @@ class SystemDatabase:
601
672
 
602
673
  def get_workflows(self, input: GetWorkflowsInput) -> GetWorkflowsOutput:
603
674
  query = sa.select(SystemSchema.workflow_status.c.workflow_uuid).order_by(
604
- SystemSchema.workflow_status.c.created_at.desc()
675
+ SystemSchema.workflow_status.c.created_at.asc()
605
676
  )
606
-
607
677
  if input.name:
608
678
  query = query.where(SystemSchema.workflow_status.c.name == input.name)
609
679
  if input.authenticated_user:
@@ -637,16 +707,73 @@ class SystemDatabase:
637
707
 
638
708
  return GetWorkflowsOutput(workflow_uuids)
639
709
 
640
- def get_pending_workflows(self, executor_id: str) -> list[str]:
710
+ def get_queued_workflows(
711
+ self, input: GetQueuedWorkflowsInput
712
+ ) -> GetWorkflowsOutput:
713
+
714
+ query = (
715
+ sa.select(SystemSchema.workflow_queue.c.workflow_uuid)
716
+ .join(
717
+ SystemSchema.workflow_status,
718
+ SystemSchema.workflow_queue.c.workflow_uuid
719
+ == SystemSchema.workflow_status.c.workflow_uuid,
720
+ )
721
+ .order_by(SystemSchema.workflow_status.c.created_at.asc())
722
+ )
723
+
724
+ if input.get("name"):
725
+ query = query.where(SystemSchema.workflow_status.c.name == input["name"])
726
+
727
+ if input.get("queue_name"):
728
+ query = query.where(
729
+ SystemSchema.workflow_queue.c.queue_name == input["queue_name"]
730
+ )
731
+
732
+ if input.get("status"):
733
+ query = query.where(
734
+ SystemSchema.workflow_status.c.status == input["status"]
735
+ )
736
+ if "start_time" in input and input["start_time"] is not None:
737
+ query = query.where(
738
+ SystemSchema.workflow_status.c.created_at
739
+ >= datetime.datetime.fromisoformat(input["start_time"]).timestamp()
740
+ * 1000
741
+ )
742
+ if "end_time" in input and input["end_time"] is not None:
743
+ query = query.where(
744
+ SystemSchema.workflow_status.c.created_at
745
+ <= datetime.datetime.fromisoformat(input["end_time"]).timestamp() * 1000
746
+ )
747
+ if input.get("limit"):
748
+ query = query.limit(input["limit"])
749
+
750
+ with self.engine.begin() as c:
751
+ rows = c.execute(query)
752
+ workflow_uuids = [row[0] for row in rows]
753
+
754
+ return GetWorkflowsOutput(workflow_uuids)
755
+
756
+ def get_pending_workflows(
757
+ self, executor_id: str
758
+ ) -> list[GetPendingWorkflowsOutput]:
641
759
  with self.engine.begin() as c:
642
760
  rows = c.execute(
643
- sa.select(SystemSchema.workflow_status.c.workflow_uuid).where(
761
+ sa.select(
762
+ SystemSchema.workflow_status.c.workflow_uuid,
763
+ SystemSchema.workflow_status.c.queue_name,
764
+ ).where(
644
765
  SystemSchema.workflow_status.c.status
645
766
  == WorkflowStatusString.PENDING.value,
646
767
  SystemSchema.workflow_status.c.executor_id == executor_id,
647
768
  )
648
769
  ).fetchall()
649
- return [row[0] for row in rows]
770
+ return [
771
+ GetPendingWorkflowsOutput(
772
+ workflow_uuid=row.workflow_uuid,
773
+ queue_name=row.queue_name,
774
+ )
775
+ for row in rows
776
+ ]
650
777
 
651
778
  def record_operation_result(
652
779
  self, result: OperationResultInternal, conn: Optional[sa.Connection] = None
@@ -1266,6 +1393,19 @@ class SystemDatabase:
1266
1393
  .values(completed_at_epoch_ms=int(time.time() * 1000))
1267
1394
  )
1268
1395
 
1396
+ def clear_queue_assignment(self, workflow_id: str) -> None:
1397
+ with self.engine.begin() as c:
1398
+ c.execute(
1399
+ sa.update(SystemSchema.workflow_queue)
1400
+ .where(SystemSchema.workflow_queue.c.workflow_uuid == workflow_id)
1401
+ .values(executor_id=None, started_at_epoch_ms=None)
1402
+ )
1403
+ c.execute(
1404
+ sa.update(SystemSchema.workflow_status)
1405
+ .where(SystemSchema.workflow_status.c.workflow_uuid == workflow_id)
1406
+ .values(executor_id=None, status=WorkflowStatusString.ENQUEUED.value)
1407
+ )
1408
+
1269
1409
 
1270
1410
  def reset_system_database(config: ConfigFile) -> None:
1271
1411
  sysdb_name = (