dbos 0.20.0a9__tar.gz → 0.21.0a4__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 (90) hide show
  1. {dbos-0.20.0a9 → dbos-0.21.0a4}/PKG-INFO +1 -1
  2. {dbos-0.20.0a9 → dbos-0.21.0a4}/dbos/_context.py +0 -30
  3. {dbos-0.20.0a9 → dbos-0.21.0a4}/dbos/_core.py +2 -2
  4. {dbos-0.20.0a9 → dbos-0.21.0a4}/dbos/_dbos.py +4 -5
  5. {dbos-0.20.0a9 → dbos-0.21.0a4}/dbos/_recovery.py +2 -5
  6. {dbos-0.20.0a9 → dbos-0.21.0a4}/dbos/_sys_db.py +121 -66
  7. {dbos-0.20.0a9 → dbos-0.21.0a4}/dbos/_workflow_commands.py +8 -26
  8. {dbos-0.20.0a9 → dbos-0.21.0a4}/dbos/cli/cli.py +4 -4
  9. {dbos-0.20.0a9 → dbos-0.21.0a4}/pyproject.toml +1 -1
  10. {dbos-0.20.0a9 → dbos-0.21.0a4}/tests/test_admin_server.py +35 -30
  11. {dbos-0.20.0a9 → dbos-0.21.0a4}/tests/test_dbos.py +50 -88
  12. {dbos-0.20.0a9 → dbos-0.21.0a4}/tests/test_failures.py +21 -44
  13. {dbos-0.20.0a9 → dbos-0.21.0a4}/tests/test_queue.py +188 -0
  14. dbos-0.21.0a4/tests/test_sqlalchemy.py +113 -0
  15. {dbos-0.20.0a9 → dbos-0.21.0a4}/tests/test_workflow_cmds.py +14 -14
  16. {dbos-0.20.0a9 → dbos-0.21.0a4}/LICENSE +0 -0
  17. {dbos-0.20.0a9 → dbos-0.21.0a4}/README.md +0 -0
  18. {dbos-0.20.0a9 → dbos-0.21.0a4}/dbos/__init__.py +0 -0
  19. {dbos-0.20.0a9 → dbos-0.21.0a4}/dbos/_admin_server.py +0 -0
  20. {dbos-0.20.0a9 → dbos-0.21.0a4}/dbos/_app_db.py +0 -0
  21. {dbos-0.20.0a9 → dbos-0.21.0a4}/dbos/_classproperty.py +0 -0
  22. {dbos-0.20.0a9 → dbos-0.21.0a4}/dbos/_cloudutils/authentication.py +0 -0
  23. {dbos-0.20.0a9 → dbos-0.21.0a4}/dbos/_cloudutils/cloudutils.py +0 -0
  24. {dbos-0.20.0a9 → dbos-0.21.0a4}/dbos/_cloudutils/databases.py +0 -0
  25. {dbos-0.20.0a9 → dbos-0.21.0a4}/dbos/_croniter.py +0 -0
  26. {dbos-0.20.0a9 → dbos-0.21.0a4}/dbos/_db_wizard.py +0 -0
  27. {dbos-0.20.0a9 → dbos-0.21.0a4}/dbos/_dbos_config.py +0 -0
  28. {dbos-0.20.0a9 → dbos-0.21.0a4}/dbos/_error.py +0 -0
  29. {dbos-0.20.0a9 → dbos-0.21.0a4}/dbos/_fastapi.py +0 -0
  30. {dbos-0.20.0a9 → dbos-0.21.0a4}/dbos/_flask.py +0 -0
  31. {dbos-0.20.0a9 → dbos-0.21.0a4}/dbos/_kafka.py +0 -0
  32. {dbos-0.20.0a9 → dbos-0.21.0a4}/dbos/_kafka_message.py +0 -0
  33. {dbos-0.20.0a9 → dbos-0.21.0a4}/dbos/_logger.py +0 -0
  34. {dbos-0.20.0a9 → dbos-0.21.0a4}/dbos/_migrations/env.py +0 -0
  35. {dbos-0.20.0a9 → dbos-0.21.0a4}/dbos/_migrations/script.py.mako +0 -0
  36. {dbos-0.20.0a9 → dbos-0.21.0a4}/dbos/_migrations/versions/04ca4f231047_workflow_queues_executor_id.py +0 -0
  37. {dbos-0.20.0a9 → dbos-0.21.0a4}/dbos/_migrations/versions/50f3227f0b4b_fix_job_queue.py +0 -0
  38. {dbos-0.20.0a9 → dbos-0.21.0a4}/dbos/_migrations/versions/5c361fc04708_added_system_tables.py +0 -0
  39. {dbos-0.20.0a9 → dbos-0.21.0a4}/dbos/_migrations/versions/a3b18ad34abe_added_triggers.py +0 -0
  40. {dbos-0.20.0a9 → dbos-0.21.0a4}/dbos/_migrations/versions/d76646551a6b_job_queue_limiter.py +0 -0
  41. {dbos-0.20.0a9 → dbos-0.21.0a4}/dbos/_migrations/versions/d76646551a6c_workflow_queue.py +0 -0
  42. {dbos-0.20.0a9 → dbos-0.21.0a4}/dbos/_migrations/versions/eab0cc1d9a14_job_queue.py +0 -0
  43. {dbos-0.20.0a9 → dbos-0.21.0a4}/dbos/_outcome.py +0 -0
  44. {dbos-0.20.0a9 → dbos-0.21.0a4}/dbos/_queue.py +0 -0
  45. {dbos-0.20.0a9 → dbos-0.21.0a4}/dbos/_registrations.py +0 -0
  46. {dbos-0.20.0a9 → dbos-0.21.0a4}/dbos/_request.py +0 -0
  47. {dbos-0.20.0a9 → dbos-0.21.0a4}/dbos/_roles.py +0 -0
  48. {dbos-0.20.0a9 → dbos-0.21.0a4}/dbos/_scheduler.py +0 -0
  49. {dbos-0.20.0a9 → dbos-0.21.0a4}/dbos/_schemas/__init__.py +0 -0
  50. {dbos-0.20.0a9 → dbos-0.21.0a4}/dbos/_schemas/application_database.py +0 -0
  51. {dbos-0.20.0a9 → dbos-0.21.0a4}/dbos/_schemas/system_database.py +0 -0
  52. {dbos-0.20.0a9 → dbos-0.21.0a4}/dbos/_serialization.py +0 -0
  53. {dbos-0.20.0a9 → dbos-0.21.0a4}/dbos/_templates/dbos-db-starter/README.md +0 -0
  54. {dbos-0.20.0a9 → dbos-0.21.0a4}/dbos/_templates/dbos-db-starter/__package/__init__.py +0 -0
  55. {dbos-0.20.0a9 → dbos-0.21.0a4}/dbos/_templates/dbos-db-starter/__package/main.py +0 -0
  56. {dbos-0.20.0a9 → dbos-0.21.0a4}/dbos/_templates/dbos-db-starter/__package/schema.py +0 -0
  57. {dbos-0.20.0a9 → dbos-0.21.0a4}/dbos/_templates/dbos-db-starter/alembic.ini +0 -0
  58. {dbos-0.20.0a9 → dbos-0.21.0a4}/dbos/_templates/dbos-db-starter/dbos-config.yaml.dbos +0 -0
  59. {dbos-0.20.0a9 → dbos-0.21.0a4}/dbos/_templates/dbos-db-starter/migrations/env.py.dbos +0 -0
  60. {dbos-0.20.0a9 → dbos-0.21.0a4}/dbos/_templates/dbos-db-starter/migrations/script.py.mako +0 -0
  61. {dbos-0.20.0a9 → dbos-0.21.0a4}/dbos/_templates/dbos-db-starter/migrations/versions/2024_07_31_180642_init.py +0 -0
  62. {dbos-0.20.0a9 → dbos-0.21.0a4}/dbos/_templates/dbos-db-starter/start_postgres_docker.py +0 -0
  63. {dbos-0.20.0a9 → dbos-0.21.0a4}/dbos/_tracer.py +0 -0
  64. {dbos-0.20.0a9 → dbos-0.21.0a4}/dbos/cli/_github_init.py +0 -0
  65. {dbos-0.20.0a9 → dbos-0.21.0a4}/dbos/cli/_template_init.py +0 -0
  66. {dbos-0.20.0a9 → dbos-0.21.0a4}/dbos/dbos-config.schema.json +0 -0
  67. {dbos-0.20.0a9 → dbos-0.21.0a4}/dbos/py.typed +0 -0
  68. {dbos-0.20.0a9 → dbos-0.21.0a4}/tests/__init__.py +0 -0
  69. {dbos-0.20.0a9 → dbos-0.21.0a4}/tests/atexit_no_ctor.py +0 -0
  70. {dbos-0.20.0a9 → dbos-0.21.0a4}/tests/atexit_no_launch.py +0 -0
  71. {dbos-0.20.0a9 → dbos-0.21.0a4}/tests/classdefs.py +0 -0
  72. {dbos-0.20.0a9 → dbos-0.21.0a4}/tests/conftest.py +0 -0
  73. {dbos-0.20.0a9 → dbos-0.21.0a4}/tests/more_classdefs.py +0 -0
  74. {dbos-0.20.0a9 → dbos-0.21.0a4}/tests/queuedworkflow.py +0 -0
  75. {dbos-0.20.0a9 → dbos-0.21.0a4}/tests/test_async.py +0 -0
  76. {dbos-0.20.0a9 → dbos-0.21.0a4}/tests/test_classdecorators.py +0 -0
  77. {dbos-0.20.0a9 → dbos-0.21.0a4}/tests/test_concurrency.py +0 -0
  78. {dbos-0.20.0a9 → dbos-0.21.0a4}/tests/test_config.py +0 -0
  79. {dbos-0.20.0a9 → dbos-0.21.0a4}/tests/test_croniter.py +0 -0
  80. {dbos-0.20.0a9 → dbos-0.21.0a4}/tests/test_fastapi.py +0 -0
  81. {dbos-0.20.0a9 → dbos-0.21.0a4}/tests/test_fastapi_roles.py +0 -0
  82. {dbos-0.20.0a9 → dbos-0.21.0a4}/tests/test_flask.py +0 -0
  83. {dbos-0.20.0a9 → dbos-0.21.0a4}/tests/test_kafka.py +0 -0
  84. {dbos-0.20.0a9 → dbos-0.21.0a4}/tests/test_outcome.py +0 -0
  85. {dbos-0.20.0a9 → dbos-0.21.0a4}/tests/test_package.py +0 -0
  86. {dbos-0.20.0a9 → dbos-0.21.0a4}/tests/test_scheduler.py +0 -0
  87. {dbos-0.20.0a9 → dbos-0.21.0a4}/tests/test_schema_migration.py +0 -0
  88. {dbos-0.20.0a9 → dbos-0.21.0a4}/tests/test_singleton.py +0 -0
  89. {dbos-0.20.0a9 → dbos-0.21.0a4}/tests/test_spans.py +0 -0
  90. {dbos-0.20.0a9 → dbos-0.21.0a4}/version/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: dbos
3
- Version: 0.20.0a9
3
+ Version: 0.21.0a4
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))
@@ -800,14 +800,13 @@ class DBOS:
800
800
  @classmethod
801
801
  def cancel_workflow(cls, workflow_id: str) -> None:
802
802
  """Cancel a workflow by ID."""
803
- _get_dbos_instance()._sys_db.set_workflow_status(
804
- workflow_id, WorkflowStatusString.CANCELLED, False
805
- )
803
+ _get_dbos_instance()._sys_db.cancel_workflow(workflow_id)
806
804
 
807
805
  @classmethod
808
- def resume_workflow(cls, workflow_id: str) -> None:
806
+ def resume_workflow(cls, workflow_id: str) -> WorkflowHandle[Any]:
809
807
  """Resume a workflow by ID."""
810
- execute_workflow_by_id(_get_dbos_instance(), workflow_id, False)
808
+ _get_dbos_instance()._sys_db.resume_workflow(workflow_id)
809
+ return execute_workflow_by_id(_get_dbos_instance(), workflow_id, False)
811
810
 
812
811
  @classproperty
813
812
  def logger(cls) -> Logger:
@@ -4,7 +4,6 @@ import time
4
4
  import traceback
5
5
  from typing import TYPE_CHECKING, Any, List
6
6
 
7
- from ._context import SetWorkflowRecovery
8
7
  from ._core import execute_workflow_by_id
9
8
  from ._error import DBOSWorkflowFunctionNotFoundError
10
9
 
@@ -19,8 +18,7 @@ def startup_recovery_thread(dbos: "DBOS", workflow_ids: List[str]) -> None:
19
18
  while not stop_event.is_set() and len(workflow_ids) > 0:
20
19
  try:
21
20
  for workflowID in list(workflow_ids):
22
- with SetWorkflowRecovery():
23
- execute_workflow_by_id(dbos, workflowID)
21
+ execute_workflow_by_id(dbos, workflowID)
24
22
  workflow_ids.remove(workflowID)
25
23
  except DBOSWorkflowFunctionNotFoundError:
26
24
  time.sleep(1)
@@ -45,8 +43,7 @@ def recover_pending_workflows(
45
43
  dbos.logger.debug(f"Pending workflows: {workflow_ids}")
46
44
 
47
45
  for workflowID in workflow_ids:
48
- with SetWorkflowRecovery():
49
- handle = execute_workflow_by_id(dbos, workflowID)
46
+ handle = execute_workflow_by_id(dbos, workflowID)
50
47
  workflow_handles.append(handle)
51
48
 
52
49
  dbos.logger.info("Recovered pending workflows")
@@ -243,66 +243,50 @@ class SystemDatabase:
243
243
  dbos_logger.debug("Waiting for system buffers to be exported")
244
244
  time.sleep(1)
245
245
 
246
- def update_workflow_status(
246
+ def insert_workflow_status(
247
247
  self,
248
248
  status: WorkflowStatusInternal,
249
- replace: bool = True,
250
- in_recovery: bool = False,
251
249
  *,
252
- conn: Optional[sa.Connection] = None,
253
250
  max_recovery_attempts: int = DEFAULT_MAX_RECOVERY_ATTEMPTS,
254
251
  ) -> WorkflowStatuses:
255
252
  wf_status: WorkflowStatuses = status["status"]
256
253
 
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,
254
+ cmd = (
255
+ pg.insert(SystemSchema.workflow_status)
256
+ .values(
257
+ workflow_uuid=status["workflow_uuid"],
258
+ status=status["status"],
259
+ name=status["name"],
260
+ class_name=status["class_name"],
261
+ config_name=status["config_name"],
262
+ output=status["output"],
263
+ error=status["error"],
264
+ executor_id=status["executor_id"],
265
+ application_version=status["app_version"],
266
+ application_id=status["app_id"],
267
+ request=status["request"],
268
+ authenticated_user=status["authenticated_user"],
269
+ authenticated_roles=status["authenticated_roles"],
270
+ assumed_role=status["assumed_role"],
271
+ queue_name=status["queue_name"],
272
+ recovery_attempts=(
273
+ 1 if wf_status != WorkflowStatusString.ENQUEUED.value else 0
289
274
  ),
290
275
  )
291
- else:
292
- # A blank update so that we can return the existing status
293
- cmd = cmd.on_conflict_do_update(
276
+ .on_conflict_do_update(
294
277
  index_elements=["workflow_uuid"],
295
278
  set_=dict(
296
- recovery_attempts=SystemSchema.workflow_status.c.recovery_attempts
279
+ recovery_attempts=(
280
+ SystemSchema.workflow_status.c.recovery_attempts + 1
281
+ ),
297
282
  ),
298
283
  )
284
+ )
285
+
299
286
  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
287
 
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)
288
+ with self.engine.begin() as c:
289
+ results = c.execute(cmd)
306
290
 
307
291
  row = results.fetchone()
308
292
  if row is not None:
@@ -325,7 +309,9 @@ class SystemDatabase:
325
309
  if err_msg is not None:
326
310
  raise DBOSConflictingWorkflowError(status["workflow_uuid"], err_msg)
327
311
 
328
- if in_recovery and recovery_attempts > max_recovery_attempts:
312
+ # Every time we start executing a workflow (and thus attempt to insert its status), we increment `recovery_attempts` by 1.
313
+ # When this number becomes equal to `maxRetries + 1`, we mark the workflow as `RETRIES_EXCEEDED`.
314
+ if recovery_attempts > max_recovery_attempts + 1:
329
315
  with self.engine.begin() as c:
330
316
  c.execute(
331
317
  sa.delete(SystemSchema.workflow_queue).where(
@@ -352,38 +338,107 @@ class SystemDatabase:
352
338
  status["workflow_uuid"], max_recovery_attempts
353
339
  )
354
340
 
355
- # Record we have exported status for this single-transaction workflow
341
+ return wf_status
342
+
343
+ def update_workflow_status(
344
+ self,
345
+ status: WorkflowStatusInternal,
346
+ *,
347
+ conn: Optional[sa.Connection] = None,
348
+ ) -> None:
349
+ wf_status: WorkflowStatuses = status["status"]
350
+
351
+ cmd = (
352
+ pg.insert(SystemSchema.workflow_status)
353
+ .values(
354
+ workflow_uuid=status["workflow_uuid"],
355
+ status=status["status"],
356
+ name=status["name"],
357
+ class_name=status["class_name"],
358
+ config_name=status["config_name"],
359
+ output=status["output"],
360
+ error=status["error"],
361
+ executor_id=status["executor_id"],
362
+ application_version=status["app_version"],
363
+ application_id=status["app_id"],
364
+ request=status["request"],
365
+ authenticated_user=status["authenticated_user"],
366
+ authenticated_roles=status["authenticated_roles"],
367
+ assumed_role=status["assumed_role"],
368
+ queue_name=status["queue_name"],
369
+ recovery_attempts=(
370
+ 1 if wf_status != WorkflowStatusString.ENQUEUED.value else 0
371
+ ),
372
+ )
373
+ .on_conflict_do_update(
374
+ index_elements=["workflow_uuid"],
375
+ set_=dict(
376
+ status=status["status"],
377
+ output=status["output"],
378
+ error=status["error"],
379
+ ),
380
+ )
381
+ )
382
+
383
+ if conn is not None:
384
+ conn.execute(cmd)
385
+ else:
386
+ with self.engine.begin() as c:
387
+ c.execute(cmd)
388
+
389
+ # If this is a single-transaction workflow, record that its status has been exported
356
390
  if status["workflow_uuid"] in self._temp_txn_wf_ids:
357
391
  self._exported_temp_txn_wf_status.add(status["workflow_uuid"])
358
392
 
359
- return wf_status
360
-
361
- def set_workflow_status(
393
+ def cancel_workflow(
362
394
  self,
363
- workflow_uuid: str,
364
- status: WorkflowStatusString,
365
- reset_recovery_attempts: bool,
395
+ workflow_id: str,
366
396
  ) -> None:
367
397
  with self.engine.begin() as c:
368
- stmt = (
398
+ # Remove the workflow from the queues table so it does not block the table
399
+ c.execute(
400
+ sa.delete(SystemSchema.workflow_queue).where(
401
+ SystemSchema.workflow_queue.c.workflow_uuid == workflow_id
402
+ )
403
+ )
404
+ # Set the workflow's status to CANCELLED
405
+ c.execute(
369
406
  sa.update(SystemSchema.workflow_status)
370
- .where(SystemSchema.workflow_status.c.workflow_uuid == workflow_uuid)
407
+ .where(SystemSchema.workflow_status.c.workflow_uuid == workflow_id)
371
408
  .values(
372
- status=status,
409
+ status=WorkflowStatusString.CANCELLED.value,
373
410
  )
374
411
  )
375
- c.execute(stmt)
376
412
 
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)
413
+ def resume_workflow(
414
+ self,
415
+ workflow_id: str,
416
+ ) -> None:
417
+ with self.engine.begin() as c:
418
+ # Check the status of the workflow. If it is complete, do nothing.
419
+ row = c.execute(
420
+ sa.select(
421
+ SystemSchema.workflow_status.c.status,
422
+ ).where(SystemSchema.workflow_status.c.workflow_uuid == workflow_id)
423
+ ).fetchone()
424
+ if (
425
+ row is None
426
+ or row[0] == WorkflowStatusString.SUCCESS.value
427
+ or row[0] == WorkflowStatusString.ERROR.value
428
+ ):
429
+ return
430
+ # Remove the workflow from the queues table so resume can safely be called on an ENQUEUED workflow
431
+ c.execute(
432
+ sa.delete(SystemSchema.workflow_queue).where(
433
+ SystemSchema.workflow_queue.c.workflow_uuid == workflow_id
385
434
  )
386
- c.execute(stmt)
435
+ )
436
+ # Set the workflow's status to PENDING and clear its recovery attempts.
437
+ c.execute(
438
+ sa.update(SystemSchema.workflow_status)
439
+ .where(SystemSchema.workflow_status.c.workflow_uuid == workflow_id)
440
+ .values(status=WorkflowStatusString.PENDING.value, recovery_attempts=0)
441
+ )
387
442
 
388
443
  def get_workflow_status(
389
444
  self, workflow_uuid: str
@@ -1,23 +1,14 @@
1
- import importlib
2
- import os
3
- import sys
4
- from typing import Any, List, Optional, cast
1
+ from typing import List, Optional, cast
5
2
 
6
3
  import typer
7
- from rich import print
8
4
 
9
- from dbos import DBOS
10
-
11
- from . import _serialization, load_config
12
- from ._core import execute_workflow_by_id
13
- from ._dbos_config import ConfigFile, _is_valid_app_name
5
+ from . import _serialization
6
+ from ._dbos_config import ConfigFile
14
7
  from ._sys_db import (
15
8
  GetWorkflowsInput,
16
9
  GetWorkflowsOutput,
17
10
  SystemDatabase,
18
11
  WorkflowStatuses,
19
- WorkflowStatusInternal,
20
- WorkflowStatusString,
21
12
  )
22
13
 
23
14
 
@@ -41,7 +32,7 @@ class WorkflowInformation:
41
32
  queue_name: Optional[str]
42
33
 
43
34
 
44
- def _list_workflows(
35
+ def list_workflows(
45
36
  config: ConfigFile,
46
37
  li: int,
47
38
  user: Optional[str],
@@ -91,17 +82,13 @@ def _list_workflows(
91
82
  sys_db.destroy()
92
83
 
93
84
 
94
- def _get_workflow(
85
+ def get_workflow(
95
86
  config: ConfigFile, uuid: str, request: bool
96
87
  ) -> Optional[WorkflowInformation]:
97
- sys_db = None
98
-
99
88
  try:
100
89
  sys_db = SystemDatabase(config)
101
-
102
90
  info = _get_workflow_info(sys_db, uuid, request)
103
91
  return info
104
-
105
92
  except Exception as e:
106
93
  typer.echo(f"Error getting workflow: {e}")
107
94
  return None
@@ -110,18 +97,13 @@ def _get_workflow(
110
97
  sys_db.destroy()
111
98
 
112
99
 
113
- def _cancel_workflow(config: ConfigFile, uuid: str) -> None:
114
- # config = load_config()
115
- sys_db = None
116
-
100
+ def cancel_workflow(config: ConfigFile, uuid: str) -> None:
117
101
  try:
118
102
  sys_db = SystemDatabase(config)
119
- sys_db.set_workflow_status(uuid, WorkflowStatusString.CANCELLED, False)
120
- return
121
-
103
+ sys_db.cancel_workflow(uuid)
122
104
  except Exception as e:
123
105
  typer.echo(f"Failed to connect to DBOS system database: {e}")
124
- return None
106
+ raise e
125
107
  finally:
126
108
  if sys_db:
127
109
  sys_db.destroy()
@@ -19,7 +19,7 @@ from .. import load_config
19
19
  from .._app_db import ApplicationDatabase
20
20
  from .._dbos_config import _is_valid_app_name
21
21
  from .._sys_db import SystemDatabase, reset_system_database
22
- from .._workflow_commands import _cancel_workflow, _get_workflow, _list_workflows
22
+ from .._workflow_commands import cancel_workflow, get_workflow, list_workflows
23
23
  from ..cli._github_init import create_template_from_github
24
24
  from ._template_init import copy_template, get_project_name, get_templates_directory
25
25
 
@@ -282,7 +282,7 @@ def list(
282
282
  ] = None,
283
283
  ) -> None:
284
284
  config = load_config()
285
- workflows = _list_workflows(
285
+ workflows = list_workflows(
286
286
  config, limit, user, starttime, endtime, status, request, appversion
287
287
  )
288
288
  print(jsonpickle.encode(workflows, unpicklable=False))
@@ -301,7 +301,7 @@ def get(
301
301
  ] = True,
302
302
  ) -> None:
303
303
  config = load_config()
304
- print(jsonpickle.encode(_get_workflow(config, uuid, request), unpicklable=False))
304
+ print(jsonpickle.encode(get_workflow(config, uuid, request), unpicklable=False))
305
305
 
306
306
 
307
307
  @workflow.command(
@@ -315,7 +315,7 @@ def cancel(
315
315
  ] = None,
316
316
  ) -> None:
317
317
  config = load_config()
318
- _cancel_workflow(config, uuid)
318
+ cancel_workflow(config, uuid)
319
319
  print(f"Workflow {uuid} has been cancelled")
320
320
 
321
321
 
@@ -27,7 +27,7 @@ dependencies = [
27
27
  ]
28
28
  requires-python = ">=3.9"
29
29
  readme = "README.md"
30
- version = "0.20.0a9"
30
+ version = "0.21.0a4"
31
31
 
32
32
  [project.license]
33
33
  text = "MIT"
@@ -151,54 +151,59 @@ runtimeConfig:
151
151
 
152
152
 
153
153
  def test_admin_workflow_resume(dbos: DBOS, config: ConfigFile) -> None:
154
+ counter: int = 0
154
155
 
155
156
  @DBOS.workflow()
156
157
  def simple_workflow() -> None:
157
- print("Executed Simple workflow")
158
- return
158
+ nonlocal counter
159
+ counter += 1
159
160
 
160
- # run the workflow
161
+ # Run the workflow and flush its results
161
162
  simple_workflow()
162
- time.sleep(1)
163
+ assert counter == 1
164
+ dbos._sys_db.wait_for_buffer_flush()
163
165
 
164
- # get the workflow list
165
- output = _workflow_commands._list_workflows(
166
+ # Verify the workflow has succeeded
167
+ output = _workflow_commands.list_workflows(
166
168
  config, 10, None, None, None, None, False, None
167
169
  )
168
170
  assert len(output) == 1, f"Expected list length to be 1, but got {len(output)}"
169
-
170
171
  assert output[0] != None, "Expected output to be not None"
171
-
172
172
  wfUuid = output[0].workflowUUID
173
-
174
- info = _workflow_commands._get_workflow(config, wfUuid, True)
173
+ info = _workflow_commands.get_workflow(config, wfUuid, True)
175
174
  assert info is not None, "Expected output to be not None"
176
-
177
175
  assert info.status == "SUCCESS", f"Expected status to be SUCCESS"
178
176
 
177
+ # Cancel the workflow. Verify it was cancelled
179
178
  response = requests.post(
180
179
  f"http://localhost:3001/workflows/{wfUuid}/cancel", json=[], timeout=5
181
180
  )
182
181
  assert response.status_code == 204
182
+ info = _workflow_commands.get_workflow(config, wfUuid, True)
183
+ assert info is not None
184
+ assert info.status == "CANCELLED", f"Expected status to be CANCELLED"
183
185
 
184
- info = _workflow_commands._get_workflow(config, wfUuid, True)
185
- if info is not None:
186
- assert info.status == "CANCELLED", f"Expected status to be CANCELLED"
187
- else:
188
- assert False, "Expected info to be not None"
189
-
186
+ # Resume the workflow. Verify that it succeeds again.
190
187
  response = requests.post(
191
188
  f"http://localhost:3001/workflows/{wfUuid}/resume", json=[], timeout=5
192
189
  )
193
190
  assert response.status_code == 204
191
+ dbos._sys_db.wait_for_buffer_flush()
192
+ assert counter == 2
193
+ info = _workflow_commands.get_workflow(config, wfUuid, True)
194
+ assert info is not None
195
+ assert info.status == "SUCCESS", f"Expected status to be SUCCESS"
194
196
 
195
- time.sleep(1)
196
-
197
- info = _workflow_commands._get_workflow(config, wfUuid, True)
198
- if info is not None:
199
- assert info.status == "SUCCESS", f"Expected status to be SUCCESS"
200
- else:
201
- assert False, "Expected info to be not None"
197
+ # Resume the workflow. Verify it does not run and status remains SUCCESS
198
+ response = requests.post(
199
+ f"http://localhost:3001/workflows/{wfUuid}/resume", json=[], timeout=5
200
+ )
201
+ assert response.status_code == 204
202
+ dbos._sys_db.wait_for_buffer_flush()
203
+ info = _workflow_commands.get_workflow(config, wfUuid, True)
204
+ assert info is not None
205
+ assert info.status == "SUCCESS", f"Expected status to be SUCCESS"
206
+ assert counter == 2
202
207
 
203
208
 
204
209
  def test_admin_workflow_restart(dbos: DBOS, config: ConfigFile) -> None:
@@ -213,7 +218,7 @@ def test_admin_workflow_restart(dbos: DBOS, config: ConfigFile) -> None:
213
218
  time.sleep(1)
214
219
 
215
220
  # get the workflow list
216
- output = _workflow_commands._list_workflows(
221
+ output = _workflow_commands.list_workflows(
217
222
  config, 10, None, None, None, None, False, None
218
223
  )
219
224
  assert len(output) == 1, f"Expected list length to be 1, but got {len(output)}"
@@ -222,7 +227,7 @@ def test_admin_workflow_restart(dbos: DBOS, config: ConfigFile) -> None:
222
227
 
223
228
  wfUuid = output[0].workflowUUID
224
229
 
225
- info = _workflow_commands._get_workflow(config, wfUuid, True)
230
+ info = _workflow_commands.get_workflow(config, wfUuid, True)
226
231
  assert info is not None, "Expected output to be not None"
227
232
 
228
233
  assert info.status == "SUCCESS", f"Expected status to be SUCCESS"
@@ -232,7 +237,7 @@ def test_admin_workflow_restart(dbos: DBOS, config: ConfigFile) -> None:
232
237
  )
233
238
  assert response.status_code == 204
234
239
 
235
- info = _workflow_commands._get_workflow(config, wfUuid, True)
240
+ info = _workflow_commands.get_workflow(config, wfUuid, True)
236
241
  if info is not None:
237
242
  assert info.status == "CANCELLED", f"Expected status to be CANCELLED"
238
243
  else:
@@ -245,13 +250,13 @@ def test_admin_workflow_restart(dbos: DBOS, config: ConfigFile) -> None:
245
250
 
246
251
  time.sleep(1)
247
252
 
248
- info = _workflow_commands._get_workflow(config, wfUuid, True)
253
+ info = _workflow_commands.get_workflow(config, wfUuid, True)
249
254
  if info is not None:
250
255
  assert info.status == "CANCELLED", f"Expected status to be CANCELLED"
251
256
  else:
252
257
  assert False, "Expected info to be not None"
253
258
 
254
- output = _workflow_commands._list_workflows(
259
+ output = _workflow_commands.list_workflows(
255
260
  config, 10, None, None, None, None, False, None
256
261
  )
257
262
  assert len(output) == 2, f"Expected list length to be 2, but got {len(output)}"
@@ -261,7 +266,7 @@ def test_admin_workflow_restart(dbos: DBOS, config: ConfigFile) -> None:
261
266
  else:
262
267
  new_wfUuid = output[0].workflowUUID
263
268
 
264
- info = _workflow_commands._get_workflow(config, new_wfUuid, True)
269
+ info = _workflow_commands.get_workflow(config, new_wfUuid, True)
265
270
  if info is not None:
266
271
  assert info.status == "SUCCESS", f"Expected status to be SUCCESS"
267
272
  else: